# Legend Cube — Implementation Plan

> **Status:** approved 2026-10-05 · branch `cubeV1` (rebased on master `a32e5c0fb`, where the spec landed as
> `docs/design/WIP-CUBE-SPEC.md` in #5589)
> **Inputs:** [docs/design/WIP-CUBE-SPEC.md](../../../docs/design/WIP-CUBE-SPEC.md), the planning brief, and an investigation of
> `legend-studio` + `legend-engine` (HEAD `93d92b4`) with ~1,500 checks against a live engine on `localhost:6300`.
> **Evidence markers:** ✅ verified live against the engine · 📄 traced in code · 💭 inference, to be verified in the
> milestone that needs it.
> **Implementation status** is tracked in [PROGRESS.md](PROGRESS.md), not here.

---

## 0. Decisions

| #   | Decision                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Source                             |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| D1  | Saved cubes will live in a **new dedicated Cube store** in `legend-engine-application-query`. **Not in v1.** v1 defines and tests the saved spec format (codec + round trip) only; actual saving and the engine store come after the POC works end to end.                                                                                                                                                                                                                                                                                                                                                            | user                               |
| D2  | The slice runs **locally** against a **Cube-owned Northwind fixture sent as an inline model** (no depot). Entry points and the sources modal get expanded later, once designed. v1 scope: one model, one Database element, one runtime per query.                                                                                                                                                                                                                                                                                                                                                                     | user (default)                     |
| D3  | **ag-grid Enterprise license is available** in every deployment. Use `@finos/legend-lego/data-grid` (enterprise modules).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | user                               |
| D4  | NULL semantics: **joins use SQL semantics** (NULL keys never match); **negated filters include NULL rows** (made explicit by the emitter, §8.4: the engine does it only for columns it types `[0..1]`; documented in the UI); **Count = non-null count** of the column.                                                                                                                                                                                                                                                                                                                                               | user (default; wording 2026-10-06) |
| D5  | Engine-driven changes to authoritative sections are accepted: Slice is `[start, stop)`; Join gains **FULL OUTER (in the slice)**; window aggregates use SQL's default frame, none saved or written: running with a sort (from the partition's first row to the current one, ties sharing a value), the whole partition without one (M5, §11.6 Q1); Difference keeps the spec's rows (emulated), with native types (M6, §11.7 Q5); Concat across different precise types is rejected, a type next to its ancestor too (the engine accepts it ✅); Convert types casts within numbers, strings or dates (M4, §11.5 Q5). | user (default)                     |
| D6  | Post-slice source order: services → Pure functions → data products → ingest (data products and ingest moved to M3, §6.7–6.8). Data products and ingest are built against mocks until a lakehouse-enabled engine is available. Services snapshot their converted lambda and check for drift.                                                                                                                                                                                                                                                                                                                           | user (default)                     |
| D7  | Route **`/cube`** inside Legend Query (URL `/query/cube`), hard-wired in the Query router. New module(s) `legend-cube` / `legend-cube-builder` (§3). Further entry points, the sources modal and the final look are revisited in M3.                                                                                                                                                                                                                                                                                                                                                                                  | user + recommendation              |
| D8  | Cube **works around** Studio and engine defects in its own code and depends on none of them being fixed. Upstream fixes are separate, non-blocking PRs and issues (Appendix B).                                                                                                                                                                                                                                                                                                                                                                                                                                       | user (default)                     |
| D9  | Execution is a **Pure relation-function chain** over store accessors (`#>{db.schema.table}#`), built as **protocol JSON** (never Pure text). Legend SQL is only a possible future "SQL source" node.                                                                                                                                                                                                                                                                                                                                                                                                                  | recommendation (§8.1)              |
| D10 | Precise primitives are modeled **inside the host-free domain**. The host adapts the engine's relation-type JSON at the boundary, in a package-local `v1/` folder.                                                                                                                                                                                                                                                                                                                                                                                                                                                     | recommendation (§5)                |
| D11 | **No feature flag.** `/query/cube` is always mounted in Legend Query. (M1.0 first shipped a `TEMPORARY__enableLegendCube` option; it was removed the same day.) _(2026-10-10, reversed by the user: Cube is still a proof of concept, so `/query/cube` mounts only where the config sets `extensions.core.NonProductionFeatureFlag`, Query's option for features not ready for production. It is set by hand in a local `dev/config.json`, not by `yarn setup`, since it also changes the rest of Query.)_                                                                                                            | user                               |
| D12 | **Types: Cube's own registry for the slice, legend-graph's types from M2.0**, for consistency with the rest of Legend. M2.0 first fixes legend-graph's precise primitives (own PR), then rebases `CubeType` on legend-graph's `GenericType` and narrows the core rule to "metamodel only, no `V1_*`, no UI or app packages" (a §2.2 departure). Until then the type seam stays narrow (§4.1) so the switch stays internal. Replaces D10 from M2.0.                                                                                                                                                                    | user + recommendation              |
| D13 | **First merge after M1.8a, as one PR** (2026-10-07), so new sources and operations can then be built in parallel. M1.8b (canvas and editors) joined the same PR before it merged (user, 2026-10-07: it is on the critical path). Show Pure's "numbers as 0" bug is fixed before it. The working docs live in `docs/wip/legend-cube/` (PLAN, PROGRESS, and ISSUES for the known issues later PRs fix); the legend-graph issue list stays out of the repo.                                                                                                                                                              | user                               |

---

## 1. Scope

### 1.1 Milestone 1: the slice

Thin vertical slice, working live on a developer machine:

- **Sources:** relational database tables from an inline model (Cube Northwind fixture, or a pasted Pure model).
- **Transforms:** **Join** (Inner, Left Outer, Right Outer, Full Outer; binary from day one) and **Filter**.
- **Types:** full precise-primitive support (`Varchar(n)`, `SmallInt`, `Numeric(p,s)`, `Timestamp`, …).
- **Path:** pick tables → canvas → live schema inference and validation → lambda → live execution → results grid →
  saved-spec codec (export/import JSON as a dev affordance; no store).
- **Hosting:** Legend Query route `/query/cube`, mounted only with `NonProductionFeatureFlag` (D11, since 2026-10-10).

### 1.2 Not in the slice

Every other transform (Sort, Group, Restrict, Rename, Distinct, Drop, Limit, Slice, Concat, Difference, Partition,
Extend). The Join rename autofix (it needs the Rename node; it lands in M2). Every other source kind. The depot and
project catalog. Persistence to any server. Drill-down, server-side grid mode, export, column formatting. Publishing (§15
is out of scope altogether). Legacy V0 import (none, ever).

### 1.3 After the slice

Section 11.3 lists the milestones, in the recommended order.

---

## 2. Architecture overview

```
legend-application-query            (Legend Query app; hard-wired route /cube; implements CubeHost)
   │  depends on
   ▼
@finos/legend-cube-builder          (React UI, MobX state, Legend engine adapter in src/graph-manager/protocol/pure/v1/,
   │                                  source catalogs, grid, canvas)
   │  depends on                    → legend-graph, legend-application, legend-art, legend-lego, legend-shared,
   ▼                                  @xyflow/react, @dagrejs/dagre, react-dnd, mobx, ag-grid (via legend-lego)
@finos/legend-cube                  (HOST-FREE core: types, schema, query graph, inference + validation, transforms,
                                      filters, Cube IR + emitter, saved-spec codec). Zero @finos deps; no React, no MobX.
```

The four artifacts and how they flow:

```
              decode/encode (core)                   emit (core)            v1 seam (builder)        engine
CubeSpec JSON ◄────────────────────► Query graph ─────────────► Cube IR ─────────────────► protocol JSON ──► execute
(saved, v1)                          (truth, immutable)          (host-free AST)            lambda + SI stamps    lambdaRelationType
                                          │                                                                         │
                                          └──── UI state (builder; mostly derived, never the source of truth) ◄──────┘ rows, types, errors
```

- The **query graph** is the only source of truth. Everything else is derived from it, or round-trips through it.
- The **executed lambda** is derived from the graph. Since §11.18, Import Pure also reads a lambda back into a graph:
  one canonical cube per lambda, which then holds the truth again.
- **UI state** is derived (layout) or ephemeral. Only presentation metadata is saved (§10.4).

---

## 3. A. Code organization

### 3.1 Packages

| Package (folder)                                              | Purpose                                                                                                       | Dependencies                                                                                                                                                                                                                                | Template to copy                     |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `@finos/legend-cube` (`packages/legend-cube`)                 | Host-free core. Spec §2.2 `cube-domain`, plus the IR emitter and the spec codec.                              | **none** at runtime. Dev: `@finos/legend-dev-utils`, `@jest/globals`, `jest`, `typescript`, `eslint`, `rimraf`, `npm-run-all`, `cross-env`.                                                                                                 | `packages/legend-storage` (headless) |
| `@finos/legend-cube-builder` (`packages/legend-cube-builder`) | UI and Legend adapter. Spec §2.2 `cube-engine` + `cube-sources` + `cube-ui` + `cube-persistence`, as folders. | `@finos/legend-cube`, `legend-graph`, `legend-application`, `legend-art`, `legend-lego`, `legend-shared`, `@xyflow/react@12.4.4`, `@dagrejs/dagre@1.1.4`, `react-dnd@16.0.1`, `mobx@6.13.6`, `mobx-react-lite@4.1.0`, `react@19.0.1` (peer) | `packages/legend-data-cube` (UI lib) |

The dependency lists are the target. Each step adds the dependencies it first uses, so nothing is declared unused: M1.0
declares only `@finos/legend-cube`, React and React DOM in the builder.

Why two packages and not five (spec §19.3):

- The rule that the core has **no host imports** is the spec's most important structural rule (§2.2). A package
  boundary enforces it best.
- Each extra package costs about 11 config files, root edits, a changeset and a release line. Engine, sources, UI
  and persistence are folders inside `legend-cube-builder` and can be split later if needed.

**Who depends on whom.**

- `legend-application-query → legend-cube-builder → legend-cube`.
- **`legend-query-builder` does not depend on Cube in v1**, for three reasons:
  1. Routes live in `legend-application-query`. The query builder is a library with no routes.
  2. `legend-query-builder` is consumed by Studio, the Data Cube app, data-space and others. They would all inherit
     the canvas stack.
  3. Keeping the edge out leaves `legend-cube-builder` free to reuse query-builder pieces later (parameter value
     editors, the saved-query loader for a "Legend Query" source). That would be a cycle if the query builder
     depended on Cube.
- If "Open in Cube" from the query builder (inside Studio) is wanted later, add the edge then, as
  `legend-query-builder → legend-data-cube` already does. `legend-cube-builder` must never import
  `legend-query-builder` after that.
- No library package depends on `legend-application-*` (AGENTS.md).

### 3.2 Folder layout

```
packages/legend-cube/src/
  types/        CubeType, type registry (precise primitives), families, comparison classes, display
  values/       LiteralValue, coercion + validation per type
  schema/       SchemaColumn (name, type, nullable), Schema
  graph/        QueryNode, Connection, Query (invariants, operations, ids), CubeDocument (context + query + meta)
  inference/    buildSchemasAndValidity, validation combinators, sentinels, query-level rules
  nodes/        registry; sources/RelationalTableSource; transforms/Join, Filter; UnknownNode
  filter/       Filter tree, operators, availability matrix, builder helpers (§8.5)
  messages/     §16 catalogue (verbatim) + additions
  ir/           Cube IR (host-free Pure AST), emitter per node, join algorithm, debug printer
  spec/         CubeSpec v1 codec, Meta, rest-preservation, node codecs; the migrations are a list in CubeSpecCodec
                (CUBE_SPEC_MIGRATIONS, empty in v1)
  utils/        internal helpers, e.g. the exhaustive-switch assertion
  index.ts
packages/legend-cube-builder/src/
  graph-manager/CubeEngine.ts       CubeEngine port + CubeModelOutline / CubeResult / CubeEngineError (no V1_* symbols)
  graph-manager/CubeModelOutlineHelper.ts
                                    getRuntimesForDatabase: the runtimes keyed by exactly that database (§6.2.5)
  graph-manager/protocol/pure/CubeEngineBuilder.ts
                                    buildCubeEngine(config, tracerService): CubeEngine. The ONLY place that constructs
                                    V1_LegendCubeEngine (precedent: QueryBuilder_PureGraphManagerExtensionBuilder.ts)
  graph-manager/protocol/pure/v1/   V1_CubeLambdaSerializer (IR → protocol JSON + sourceInformation stamps),
                                    V1_CubeRelationTypeAdapter (relation-type JSON → CubeType),
                                    V1_CubeModelOutlineBuilder (parsed model → databases, flags, runtimes),
                                    V1_CubeExecutionResultReader (lossless), V1_CubeEngineErrors (payload → node error),
                                    V1_LegendCubeEngine (implements the port; builds its own client; imports only the
                                    port, legend-graph, legend-shared and @finos/legend-cube)
  stores/       CubeEditorState, CubeExecutionState, CubeNodeEditorState (the floating node editor), CubeSourcePickerState,
                CubeShowPureState, CubeSpecTransferState (Export/Import), LocalModelCatalog (the bundled model texts;
                talks only to the port; loadModel parses a model context once and returns its databases and runtimes as
                plain data), CubeHost interface, editors/ (node drafts and their registry, §7.4), fixtures/ (Cube
                Northwind model as a TS string)
  components/   CubeEditor (layout), CubeButton, CubeNodeIcon (node icons), canvas/, palette/, editors/ (Join, Filter,
                Source, and the editor registry), source-picker/, grid/, show-pure/, spec-transfer/
  __lib__/      labels, help text (§17.9), command config (§3.5), test ids
  __test-utils__/  Cube-local axios engine helpers for engine-backed tests (§3.4), the fake engine, page and canvas
                   helpers for jsdom tests
packages/legend-cube-builder/style/index.scss   built to lib/index.css, which the host imports
```

**The repo's `@finos/legend/enforce-module-import-hierarchy` lint rule** (error level) forbids imports in **both**
directions between `stores/`/`components/` and `graph-manager/protocol/*/v*/`
([enforce-module-import-hierarchy.js](../../../packages/eslint-plugin/src/rules/enforce-module-import-hierarchy.js)) 📄.
That is why the port sits in `graph-manager/`, the factory sits just outside `v1/`, and `stores/` and `components/`
never import from `v1/`. The Query host calls `buildCubeEngine` from the builder's index, so no `V1_*` symbol appears
in `legend-application-query`.

### 3.3 Guarding the core's purity

The core gets four layers of protection, because `tsc` resolves _undeclared_ workspace packages through the root
`node_modules/@finos` symlinks 📄. All four have been in place since M1.0 ✅. Each was checked with a probe file that
imports `mobx` and reads `window`: lint, the build and the test all reject it.

1. **No dependencies.** `packages/legend-cube/package.json` declares no runtime, peer or optional dependencies. Its only
   dev dependencies are tooling: `@finos/legend-dev-utils`, Jest, TypeScript, ESLint and the script helpers. The test
   in item 4 checks this.
2. **Relative imports only (ESLint).** A root `eslint.config.js` block covers the non-test files under
   `packages/legend-cube/src/**`.
   - `no-restricted-imports` rejects every specifier that doesn't start with `./` or `../`. That is stricter than a
     list of host packages and needs no upkeep.
   - `no-restricted-globals` flags `window`, `document`, `localStorage`, `sessionStorage`, `navigator`,
     `location`, `fetch`, `XMLHttpRequest`, `process`, `console`, `setTimeout`, `setInterval` and
     `structuredClone`, for feedback while typing.
3. **ECMAScript globals only (build).** `packages/legend-cube/tsconfig.json` sets `lib: ["esnext"]` and
   `types: ["node"]`, because the tests need Node. `tsconfig.build.json` also sets `types: []` and leaves out the
   test folders.
   - So `yarn build` fails on **any** browser or Node global outside tests, not just the names above: `URL`,
     `TextEncoder`, `crypto`, `queueMicrotask` and the like are out too.
   - The core needs none of them. Write plain ECMAScript instead; for example, copy values without
     `structuredClone`.
4. **A unit test** (`src/__tests__/LegendCubeHostFree.test.ts`) runs in CI even when lint is skipped.
   - It parses each non-test source file and rejects any module reference that isn't relative: static imports and
     re-exports (type-only ones too), `import()`, `require()`, `import x = require()` and triple-slash directives.
   - It compiles the non-test sources against the ECMAScript library with no ambient types, and checks the package's
     dependencies.
   - Each check also runs against small bad fixtures, so the test proves it can fail.

### 3.4 Build, test and lint conventions

What the repo actually enforces:

- **Per package:** `package.json`, `tsconfig.json`, `tsconfig.build.json`, `tsconfig.publish.json`,
  `_package.config.js`, `jest.config.js`, `.npmignore`, `README.md`, `CHANGELOG.md`, `src/index.ts`. The builder
  also needs `style/index.scss` and the `build:sass`/`dev:sass` scripts.
- **Root:** both packages are referenced in `tsconfig.json` and `tsconfig.build.json`. Each dependent package
  references them in both of its tsconfigs. `yarn check:ts` only sees a new package once it is `git add`-ed 📄.
- **Tests:**
  - Core: `getBaseJestProjectConfig` (node environment). Builder: `getBaseJestDOMProjectConfig`.
  - Engine-backed tests are named `*.engine-roundtrip-test.ts`. CI runs them against the docker engine.
  - `fetch` is blocked in Jest (`legend-dev-utils/jest/blockFetch.js`), and `V1_EngineServerClient` uses `fetch`.
    So engine tests use axios against the hard-coded `http://localhost:6300/api`
    ([EngineTestSupport.ts](../../../packages/legend-graph/src/graph-manager/__test-utils__/EngineTestSupport.ts)) 📄.
  - **How the engine tests are wired:**
    - Engine tests drive the real `V1_LegendCubeEngine`. They spy its `V1_EngineServerClient` methods and route
      them to Cube-local axios helpers in `legend-cube-builder/src/__test-utils__/`, with `axios` as a devDependency
      as in legend-graph. The precedent is
      [LegendDataCubeStoreTestUtils.tsx:470-503](../../../packages/legend-application-data-cube/src/components/__test-utils__/LegendDataCubeStoreTestUtils.tsx#L470).
    - Cube-local helpers are needed because `EngineTestSupport` has no `/lambdaRelationType/batch` helper, and its
      `execute` helper returns already-parsed JSON. That would bypass the lossless result reader M1.7 must test.
    - The helpers cover:
      - `grammarToJson/model` and `grammarToJson/lambda` (the A.11 golden and the printIR-parse comparison);
      - `lambdaRelationType/batch` (a plain JSON POST);
      - `compilation/compile` (the corpus compile check; a `text` context works ✅);
      - `jsonToGrammar/lambda`;
      - `GET /api/server/v1/info` (the engine commit, logged by Part A);
      - `execute` (`responseType: 'text'`), returning `{ ok: true, status: 200, text: async () => body }` so the
        lossless reader runs exactly as in the browser.
- **CSS:**
  - From M1.8b, the canvas imports `@xyflow/react/dist/style.css` from its `.tsx` file, as Studio's database
    diagram does, so the stylesheet loads with Cube's lazy chunk and not in Query's global sheet: xyflow 12 and
    the lineage viewer's reactflow 11 share the `.react-flow__*` class names (decided in M1.8b S13).
  - Legend Query's bootstrap `style/index.scss` imports `@finos/legend-cube-builder/lib/index.css`.
  - Tailwind only scans `../legend-*/src/**/*.tsx`: the package folder must start with `legend-`, and Tailwind
    classes go in `.tsx` files 📄.
- **Release hygiene:**
  - New files carry `Copyright (c) 2026-present, Goldman Sachs` (from the template, not copied from a neighbour).
  - New packages start at `0.0.1` with a `patch` changeset. `legend-application-query` also gets a `patch` entry.
- **Not enforced:**
  - `yarn constraints` is a no-op: the rules sit in `.yarn/constraints.pro`, which Yarn 4 does not read 📄.
    Version alignment is by convention, so pin the exact versions already in `yarn.lock` (§3.6).
  - There is **no lint rule for package dependency direction** 📄. §3.3 covers the core.

### 3.5 Legend Query integration (slice)

- **Route:**
  - `CUBE: '/cube'` is in `LEGEND_QUERY_ROUTE_PATTERN`
    ([LegendQueryNavigation.ts:69](../../../packages/legend-application-query/src/__lib__/LegendQueryNavigation.ts#L69)) and
    mounted in [LegendQueryWebApplication.tsx](../../../packages/legend-application-query/src/components/LegendQueryWebApplication.tsx#L142)
    since M1.0. M1.8a only swaps the route element for a Query-side wrapper that builds the host, loaded lazily
    (Settled before M1.8).
  - Query's `baseUrl` is `/query/`, so the URL is **`/query/cube`** 📄.
  - Plugin page entries are not used: they force an `/extensions/` prefix 📄.
  - `/cube/:cubeId` is reserved for saved cubes (M8).
- **No feature flag** (D11): the route is always mounted. _(2026-10-10: it now mounts only with
  `extensions.core.NonProductionFeatureFlag`, D11.)_
- **Keyboard shortcuts:** Legend binds keys only through plugins' `getExtraKeyedCommandConfigEntries()`, collected
  at app start 📄. F9 is already bound in Query to the query builder's compile command, which is only registered
  while the query builder is mounted.
  - The builder exports `LEGEND_CUBE_COMMAND_CONFIG` (execute: F9; undo: Control+KeyZ / Meta+KeyZ).
  - Query contributes it via `getExtraKeyedCommandConfigEntries()` on a core application plugin. This is the only
    plugin hook Cube uses. Query has two: `Core_LegendQuery_LegendApplicationPlugin` (`src/application/`) and
    `Core_LegendQueryApplicationPlugin` (`src/components/`); before M1.8b neither overrode the hook, and Query's
    test helper installs only the second. **M1.8b (S20) added the override to `Core_LegendQuery_LegendApplicationPlugin`**,
    the one the app installs first (a new Query key binding must be merged into that override's entries), and a Query test shows that F9 on `/cube` reaches Cube's Execute with the query builder's plugin
    also binding F9 (the query builder's Compile is registered only while it is mounted).
  - The Cube page registers the commands with `applicationStore.commandService`, as the query builder's
    `useCommands` does.
  - The undo command's `trigger` returns `false` while focus is in an input, textarea or contenteditable.
- **Hosts** (§11.19, folded in at AN.6): Legend Query (`/query/cube`) and Legend Analytics (`/analytics/`, its own
  application, `@finos/legend-application-analytics`) host the page through one `LegendCubeHost` in the builder, built
  from a plain `LegendCubeHostConfig` of the deployment's servers (the engine; the depot, lakehouse, platform and
  marketplace where it has them), so both offer the same sources by the same rules. Each app keeps only its config,
  its shell and its shortcuts' plugin; Analytics also copies the rules of Query's light mode that the page's
  legend-art components use. Studio's embedded cube (#5690) keeps a host of its own.
- **Host code:** `legend-application-query/src/components/cube/` and `src/stores/cube/` hold a thin
  `LegendQueryCubeHost`, now a subclass of the builder's `LegendCubeHost` given Query's config
  (`buildLegendQueryCubeHostConfig`). It implements `CubeHost` (defined in the builder) from Query's application store:

  - engine server client config (Query's `engineServerUrl`, as at
    [QueryEditorStore.ts:629](../../../packages/legend-application-query/src/stores/QueryEditorStore.ts#L629));
  - notifications and alerts;
  - telemetry;
  - the bundled model catalog.

  The engine URL exists only on Query's config 📄, which is why the host injects it.

- **Entry links** (landing-page action, editor menu item, deep links) are **not** in the slice. They are designed in M3.
- **Look (provisional):**
  - Legend Query's look: legend-art components, resizable panels, `ContextMenu`, `Dialog`.
  - Tailwind utilities for layout and legend-art colour tokens. Root CSS class `.legend-cube`; the `.data-cube`
    class and the cube icon belong to Data Cube.
  - Components stay thin so the look can change in M3.

### 3.6 UI libraries (all already in `yarn.lock`; no new third-party packages)

| Need          | Library (version)                                                      | Precedent                                                                                                                                                                                         |
| ------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Canvas        | `@xyflow/react` 12.4.4 (MIT)                                           | Studio database diagram [DatabaseDiagramCanvas.tsx](../../../packages/legend-application-studio/src/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.tsx)                     |
| Layout        | `@dagrejs/dagre` 1.1.4 (MIT)                                           | [DatabaseDiagramHelper.ts](../../../packages/legend-application-studio/src/components/editor/editor-group/database-editor/DatabaseDiagramHelper.ts) (left-to-right, centre → top-left conversion) |
| Drag and drop | `react-dnd` 16.0.1 (MIT); `DndProvider` already wraps every Legend app | query builder, legend-lego                                                                                                                                                                        |
| Grid          | ag-grid 35.0.0 via `@finos/legend-lego/data-grid` (enterprise, D3)     | [DataGrid.tsx](../../../packages/legend-lego/src/data-grid/DataGrid.tsx)                                                                                                                          |
| State         | `mobx` 6.13.6, `mobx-react-lite` 4.1.0                                 | repo-wide                                                                                                                                                                                         |

Not used: `reactflow` 11 (legacy, lineage viewer only), elkjs (not installed; EPL), Data Cube's floating-window layout
manager.

### 3.7 Housekeeping found along the way (separate, optional)

- [AGENTS.md:120](../../../AGENTS.md#L120) says DataCube consumes metamodel only, but `legend-data-cube` uses `V1_*` throughout,
  outside `v1/` folders 📄. Cube follows the stricter rule: V1 symbols appear only under
  `legend-cube-builder/src/graph-manager/protocol/pure/v1/`, as `legend-query-builder` does. AGENTS.md should be
  clarified separately. **Test exception** (user, 2026-10-08): the engine-backed tests in
  `legend-cube-builder/src/__tests__/` (`LegendCubeNorthwind`, `CubeSpecCorpus` and `CubeEditorState`
  `.engine-roundtrip-test.ts`) import `V1_*` there. They need both the `v1/` adapter and `stores/` (the Northwind
  fixture, and `CubeEditorState` itself), and the import-hierarchy rule (§3.2), which lints tests too, forbids `v1/`
  from importing `stores/`. Product code keeps the rule.
- `.yarn/constraints.pro` is dead; see Appendix B.

---

## 4. Domain model (`@finos/legend-cube`), detailed for the slice

Everything in this section is host-free and test-driven. Spec sections are cited where behaviour is kept **verbatim**.
M2's unary operations (§11.4) are folded in: the column-name rule in §4.2, row order in §4.4, the nodes in §4.5, the
Join autofix in §4.7, the grid's Filter by rule in §4.8 and the new messages in §4.11. M4's Group and Concat (§11.5)
are folded in the same way: output names and converted types in §4.2, port labels in §4.3, row order in §4.4 and the
nodes in §4.5.

### 4.1 Types

See §5 for the rationale. In summary:

```ts
type TypeFamily =
  | 'BOOLEAN'
  | 'STRING'
  | 'INTEGER'
  | 'FLOAT'
  | 'DECIMAL'
  | 'NUMBER'
  | 'STRICT_DATE'
  | 'DATETIME'
  | 'DATE'
  | 'STRICT_TIME'
  | 'VARIANT'
  | 'ENUM'
  | 'OPAQUE';

abstract class CubeType {
  abstract readonly path: string;
  equals(o: CubeType): boolean;
  toString(): string;
}
class PrimitiveType extends CubeType {
  path;
  params: readonly number[];
  info: PrimitiveTypeInfo;
} // interned by (path, params)
class EnumType extends CubeType {
  path;
  values: readonly string[];
} // equal iff same path (spec §3.1)
class OpaqueType extends CubeType {
  path;
  params: readonly number[];
} // unknown engine types; carried, never compared
```

- `PrimitiveTypeInfo` comes from a static registry (§5.3) and holds the short name, family, parent, parameter arity
  and integer range.
- Primitives are interned singletons per `(path, params)`, which keeps spec §3.1's "singleton" intent.
  `Varchar(5)` and `Varchar(40)` are distinct values.
- Enum value qualification helpers stay as in §3.1 (`qualifyEnumValue` / `unqualifyEnumValue`).
- **Equality is structural** (spec §3.1): same family and same full name, which for an enumeration is its path. Type
  guards go by the family, never `instanceof`, so types also work across copies of the module.
- **The seam (D12).** Code outside `types/` uses only the type API: `family`, `equals`, `displayName`,
  `fullName`, `path`, `params`, the type guards, and the functions in `types/` and `values/`. It never reaches
  into `info` or builds types ad hoc: types come only from `resolveCubeType`, `PrimitiveType.get` and
  `new EnumType` (in the engine adapter, the spec codec and test helpers). That keeps the M2.0 switch to legend-graph's
  types internal to `types/`.

### 4.2 Schema

- `SchemaColumn { name: string (non-empty); type: CubeType; nullable: boolean }`. **`nullable` is new**: outer joins
  and later aggregations need it, and the engine reports it wrongly (§4.7, §5.7).
- `Schema` keeps the §3.2 operations: `lookup`, `type`, `names`, `equals`.
  - `equals` is order-sensitive and compares names and types. Nullability is ignored, matching the engine's
    `concatenate` ✅.
  - Concat's output (M4) is therefore schema1 with `nullable = nullable1 || nullable2` per column, as the engine
    merges it ✅. With Convert types, a column whose types differ takes their least common ancestor (§11.5 Q5).
  - **New:** a schema asserts unique column names, because the engine rejects duplicates everywhere ✅.
  - Source schema **drift detection** compares name, type **and** nullability, not `equals`.
- **Column names** (M2, `schema/ColumnName.ts`; why in §11.4): `isValidColumnName` replaces spec §7.5's
  `/^[A-Za-z0-9_ ]{1,100}$/u` (Appendix A). A name is not empty, is trimmed, has no `"`, no `\`, no backtick
  (Databricks and MemSQL quote names with backticks, and the engine escapes none: M6d, §11.14) and no control
  character, and has at most 128 code points (`MAX_COLUMN_NAME_LENGTH`). Rename and the Join autofix (§4.7) use it,
  and so do Group's output names (M4); Extend will. `foldColumnName` (NFKC, then case folded through upper case, so
  `ß` meets `SS` and fullwidth letters meet ASCII) is close to how SQL Server, MemSQL and DuckDB compare names, so Cube
  never gives a column a name that folds to another column's: a Rename's new names, the autofix's names, Group's
  output names and the temporary columns (`cube_rn`, `cube_d`, and Convert types' `cube_cast`) are all checked folded.

### 4.3 Query graph

Spec §4 is kept verbatim (`QueryNode {key, id, type, ports}`, `Connection`, `Query {nodes, connections, selected}`, the
five invariants, every operation and every `canX` predicate), plus the following fixes and extensions:

| Change                               | Why                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Acyclicity invariant (6)**         | §5.1 says cycles are impossible. They aren't: `connect(F1,F2)` then `connect(F2,F1)` satisfies invariant 3 📄. `canConnect` and `connect(…, port)` reject a target that is the source or upstream of it. `canMove` is **unchanged** from §4.4: move isolates the node first, so it cannot create a cycle, and a stricter rule would forbid moves the spec allows. The constructor asserts the graph is acyclic; `visit()` keeps an in-progress set. |
| **`connect(source, target, port?)`** | The canvas lets users drop on a specific Left or Right handle. With no port, behaviour is spec's "first free port".                                                                                                                                                                                                                                                                                                                                 |
| **Per-type `generateId`**            | §4.4's global max cannot produce Appendix C's ids (`join101` + `filter101`) 📄. Take `max(100, ids of nodes of that type) + 1`; the collision fallback is unchanged.                                                                                                                                                                                                                                                                                |
| **Port labels in metadata**          | §17.3 pitfall 4. Join's `portLabels` getter, `['Left', 'Right']` (inherited from `BinaryNode`); Concat's `['First', 'Second']` (M4, §11.5 Q8).                                                                                                                                                                                                                                                                                                      |

Settled in M1.2 (spec §4.4 leaves these open or assumes unary nodes):

- **Port invariant (7):** a connection must target one of its target's ports. Port capacity stays out of the
  constructor, as in the spec.
- **Healing** (`remove`, `move`): the node's first connected input, in port order, feeds its output's target on the
  same port. A binary node's other input is dropped.
- **`move`** heals like `remove` before splicing, since the spec only says "disconnect". Moving a node after the
  selected node selects it, as `add` does; otherwise the selection is unchanged.
- **Nodes that don't accept new inputs** (`acceptsNewInputs`, false for Unknown, §4.10): `canAdd(…, after)`,
  `canMove`, `canConnect(*, it)` and `canSwapInputs` are false. Changes that keep its ports are allowed: healing into
  it on removal, and splicing a node in front of it.
- **`replace(node)`** swaps the node with the same id for a new node object (spec §4.1's key check) and needs every
  port in use. Every operation asserts its `canX` and throws.
- **`generateId`** counts only ids that are the type followed by digits, for nodes of that type (stricter than the
  spec's lenient `parseInt`). Past safe integers it falls back to the scan, which covers 1–10000.
- **Undo** creates a new object identity, as §17.4 requires: `query.clone()`.
- **`swapInputs`** also applies the node's `withSwappedInputs()`, so settings that name inputs by side follow them
  (added in M1.3 for Join, §4.7). The node it gives must keep its id.
- `CubeDocument` holds `{ context, query, meta }`, where `context = { model: ModelContext; runtime?: string }` is
  query-level (§6.2), and the model is the engine's model context as plain JSON (§6.2.2).

### 4.4 Inference and validation engine

- §5 is kept verbatim: `buildSchemasAndValidity`, port-ordered input schemas, the three sentinel messages,
  `isSchemasError`, and the `validate` / `validateAllItems` combinators.
- Two additions:
  - **Query-level rules pass** (new; runs before the per-node pass, and its errors are added to the offending
    nodes). v1 has one rule: all relational sources must address the same Database element as the first source.
    Message: `Sources from different databases are not supported yet; "<db>" differs from "<db0>".`
  - **Host-supplied issues** (engine errors mapped back by node id, §8.7) live in a separate map and do not take
    part in schema propagation. The UI merges them for display.
- Settled in M1.2:
  - A node with a rule error is invalid, so it has no schema and downstream nodes report `ERR_SCHEMAS`. Rule errors
    come first in its list, before a sentinel or its own errors. The rule takes the first relational source in
    `query.nodes` order, and every relational source counts, connected or not.
  - A node that validates but gives no schema gets `ERR_OTHER`, the converse of "invalid ⇒ no schema".
  - `isIncompleteError` joins `isSchemasError` (which stays `ERR_SCHEMAS` only).
- Settled in M2 (`inference/RowOrder.ts`; why in §11.4):
  - **Row order.** A `RowOrder` lists `OrderKey {column, direction, sortId, keyIndex}`, most significant first: the
    column as named at that node, and the Sort that declared the key with its place among that Sort's keys. It is `[]`
    when nothing orders the rows and `undefined` when that can't be known. `computeRowOrders(query)` gives every node's
    order from its inputs' through `outputOrder` (§4.5): a source, a Join, a Group and a Concat give `[]`; Filter,
    Distinct, Limit, Drop, Slice and Partition (M5) pass their input's on; Rename renames its keys; Restrict keeps
    the longest prefix whose columns it keeps; Sort puts its own keys first, then the input's keys on its other
    columns; an Unknown node
    gives `undefined`, which the nodes that pass an order on carry down. When running a query, the emitter hands a node
    that `consumesInputOrder` (Limit, Drop, Slice) its input's order, written as a sort just before it or in the row
    numbers' `over()` (§8), and sorts the capture by its own before the run's limit; typing lambdas carry no order.
    Orders are derived, never stored.
  - **Lost orders.** `findLostSortOrders(query, rowOrders?, schemas?)` follows each Sort's order down its chain to
    the first node that consumes it, or to the chain's end, and maps the Sort's id to the loss, if any. A full loss
    names the first node whose rows hold none of its keys, e.g. a Join, a Group, a Concat (on either input), a Restrict
    that drops the Sort's first key, or a later Sort on all the same columns. A partial loss names the first node that
    loses some keys, and gives the nodes (Restricts) that removed some (`removals`) and the kept columns that no longer
    order the rows, as they came after a removed one (`cutColumns`, which needs `schemas`). A later Sort on some of the
    same columns is no loss, and nothing is reported where the order becomes unknown. The builder shows losses as
    warnings (§4.11), never validation errors, so Execute stays enabled.

### 4.5 Node contracts and registry

This replaces the per-class statics of spec §4.1/§6.1 with registries that the palette, context menu and codec all
read from (§17.2's single source of truth).

```ts
interface NodeDefinition<N extends QueryNode> {
  type: string;
  label: string;
  icon: string;
  beta: boolean; // menu metadata (§4.1)
  ports(node: N): readonly string[]; // per instance: static for known types, synthetic for Unknown (§4.10)
  portLabels?: readonly string[];
  create(id: string): N;
  decode(json: NodeSpecJson): N;
  encode(node: N): NodeSpecJson; // spec codec (§10.3)
  emit(node: N, inputs: IR.RelationExpr[], ctx: EmitContext): IR.RelationExpr; // §8
  isolationBoundary?: boolean; // true for window-producing nodes (§8.6): Partition, from M5
}
interface SourceDefinition<S extends SourceNode> extends NodeDefinition<S> {
  fromCoordinates(id: string, coordinates: unknown): S; // unresolved source from the picker
  resolve(node: S, result: SourceResolution): S; // new node with schema or error (replaces resolveV1)
}
```

Node instances implement spec §5.2 `validate(inputSchemas, errors)` / `schematize(inputSchemas)` plus `describe()`.
From M2 they also give their row order (§4.4): `outputOrder(inputOrders)`, by default `[]` (no order, so a new node
never claims one), and `consumesInputOrder`, true only for Limit, Drop and Slice, which take rows by that order.

As built in M1.2: a `NodeDefinition {type, label, icon, beta}` with `TransformDefinition {kind: 'transform', create}`
and `SourceDefinition {kind: 'source', fromCoordinates, resolve, queryRules?}`. `decode`/`encode` arrive with the
codec (M1.6) and `emit` with the IR (M1.5). Port labels live on node instances (`portLabels`, Left/Right for binary
nodes). The registry is built by `createNodeRegistry()`, not held as a module-level singleton, and reserves the
`unknown` type.

As built in M2 (§11.4): seven `UnaryNode`s in `src/nodes/transforms/`, each on the port `tds`, registered with the
spec's palette labels in spec menu order (Sort, Filter, Restrict, Rename, Distinct, Drop, Limit, Slice, then Join). Each
keeps its spec section's validation and `describe()` except as noted. A blank column name reports the generic
`<Label> does not have a name.` (`Sort column`, `Column`, `Old column`), as a blank Join key does (§4.7). Constructors
refuse only wrong shapes (not a list, an unknown direction, NaN, Infinity); every other invalid state is constructible
and reported by validation, and `schematize` gives `undefined` for an invalid node. No M2 node redacts anything: names
and sizes are not values.

- **Sort** (`sort`, spec §7.1): `sorts: {column, direction: 'ASC' | 'DESC'}[]`, empty when new; `Sort.byColumn` gives
  the grid's Sort by (one ascending key). Every key must be named, in the input and of a sortable type
  (`isSortableType`: not VARIANT or OPAQUE, else `Sort column "<c>" of type <T> cannot be sorted.`), then
  `Sort columns cannot have duplicates.` The spec's two direction messages are unreachable. A Sort emits nothing where
  it stands (§4.4, §8).
- **Restrict** (`restrict`, spec §7.4): `columns: string[]`. Its messages use the labels `Columns` and `Column`, and
  every column is reported. The output keeps the input's column order.
- **Rename** (`rename`, spec §7.5): `mappings: {from, to}[]`. Each mapping (`validateRenameMapping`) stops at its first
  failure, and the column-name rule (§4.2) replaces the spec's regex. Two checks follow the spec's five, in any case
  (`foldColumnName`): no other mapping's new name may fold to the same one, and the new name may not be an input column
  the Rename leaves as it is: `New column name "X" is already present in the input schema.` (the collision fix). A
  column's own new case (`ORDER_ID` to `order_id`) is fine. Names are never trimmed.
- **Distinct** (`distinct`, spec §7.6): no settings, always valid.
- **Drop** (`drop`) and **Limit** (`limit`), spec §7.7 and §7.8: `size: number | undefined`. The constructor has no
  default, so an explicit `undefined` stays cleared and invalid; `create` gives 10.
- **Slice** (`slice`, spec §7.9): `start` and `stop`, the same way (10 and 20 from `create`), edited together with
  `withRange`. Both bounds are reported before `start < stop` is checked. The range is `[start, stop)`, counting from 0
  (D5): `Take rows 10 to 20 (20 excluded)`, or `Take rows 10 to (blank)` when the stop is cleared.

As built in M4 (§11.5): Group, a `UnaryNode`, and Concat, a plain `BinaryNode` on `tds1` and `tds2`, in spec menu
order (Sort, Group, Filter, Restrict, Rename, Distinct, Drop, Limit, Slice, Concat, Join). Neither keeps a row order
(§4.4). Validation, messages and saved shapes are in §11.5.

- **Group** (`group`, label `Group by Column`, spec §7.2): `columns` (the keys, in stored order) and
  `aggregations: {column, function, name}[]`, the column left out for Count rows. The aggregation model
  (`Aggregation.ts`, §5.7, reused by M5's Partition) gives each type's functions, result types and nullability. Keys
  must be unique, named, in the input and groupable (not VARIANT or OPAQUE, `isSortableType`); then at least one
  aggregation, each checked (`validateColumnAggregation`). The schema is the keys, then one column per aggregation.
  `describe()`: `Group by "a", "b"`, or `Aggregate all rows` with no keys.
- **Concat** (`concat`, label `Concatenate Another Input`, spec §7.10): ports labelled First and Second, and one
  setting, `widenTypes` (Convert types, Q5), false by default. Columns match by position: the same count, the same names
  in the same case, and equal types, or with Convert types a least common ancestor within numbers, strings or dates
  (`getConcatConvertedType`); nullability is never compared. The schema is the first input's names and types (the
  ancestor where converted), nullable where either input's is. Autofixes (`ConcatAutofix.ts`, Q6): a Rename before
  the second input, a Restrict before the wider one. `describe()`: `Concatenate additional input`, plus
  `, converting types` with the setting on.

As built in M5 (§11.6): Partition, a `UnaryNode`, registered after Join (Sort, Group, Filter, Restrict, Rename,
Distinct, Drop, Limit, Slice, Concat, Join, Partition). `TransformDefinition` gains `isolationBoundary`, true only for
Partition: a run binds such a node with a `let` whenever it isn't the capture (`QueryEmitter`, §8.6). A Partition
keeps its input's row order, as Distinct does (`keepInputOrder`): it keeps every row and column, so the Sort's keys
still exist after it, and the window's own sort orders its functions, not the rows. `consumesInputOrder` stays false.
Validation, messages and the saved shape are in §11.6.

- **Partition** (`partition`, label `Apply Window Functions`, spec §7.13): `columns` (partition by, may be empty),
  `sorts` (Sort's `{column, direction}`, may be empty) and `aggregations` (Group's `{column, function, name}`, with
  the window functions, §5.7). The schema is the input's columns, then one per window function, in listed order.
  `describe()`: `Apply 2 Window Functions`, `Apply 1 Window Function`.

As built in M6c (§11.12): Pivot, a `UnaryNode`, Cube's own beside the spec's Group, registered after Group. It keeps
no row order, as a Group. Some databases can't run it (§8.8).

- **Pivot** (`pivot`, label `Pivot by Column`, icon `pivot`): `rows` (the row columns, as listed), `column` (the pivot
  column, `''` until picked), `values` (typed literals, in the engine's order) and `aggregation` (Group's
  `{function, column}` with no name; Count rows when new). Checks stop at the first part that fails: the rows (unique,
  named, in the input, groupable); the column (named, in the input, a String, an integer, a StrictDate or a Boolean,
  not a row column); the values (one to 100, each of the column's type, none twice, no two differing only in case,
  each naming a column); the aggregation (a Group's, but for Distinct Value, on a column neither a row column nor the
  pivot column). The schema is the row columns, then one column per value, named by its text (`_2`, `_3` when a row
  column or an earlier value has it, folded), typed as the aggregation is in a Group. `describe()`:
  `Pivot "year" into 2022, 2023, 2024`, values after the third counted (`and 2 more`); redacted, `Pivot "year"`.

### 4.6 Relational table source (`type: 'relational'`)

- **State:** `{ database: string /* element path */; schema: string; table: string; resolution }`.
  `resolution = unresolved | { schema: Schema } | { error: string }`.
- **Validation:** spec §6.2 validates only that the schema exists (`Required schema of this source could not be
resolved.`). A resolution error is reported verbatim from the engine (first line).
- **`describe()`:** `Table "<table>" from schema "<schema>"` (§6.2), or `(unknown)` when unresolved.
- **No parameters.** Coordinates are validated at construction (non-empty strings).
- **As built in M1.2:** `resolution` is `unresolved | resolved {schema} | failed {message}`. `describe()` is
  `(unknown)` only while unresolved; a failed source still shows its table, so the canvas names the broken one. The
  same-database rule (§4.4) lives with the source.

### 4.7 Join (`type: 'join'`, ports `['leftTds','rightTds']`)

- **State:** `{ leftColumns: string[]; rightColumns: string[]; joinType: 'INNER' | 'LEFT_OUTER' | 'RIGHT_OUTER' |
'FULL_OUTER' }`. The default is `LEFT_OUTER` (§7.11). `FULL_OUTER` is new (D5).
- **Validation order:** §7.11 steps 1–5, verbatim messages.
  - Step 4 uses the new comparison-class rule (§5.4) instead of §3.1's "numeric or identical".
  - Step 5 is the duplicate-column rule, verbatim (`getDuplicateJoinColumns`, positional matching).
- **Output schema:** `buildJoinSchemaColumns` verbatim (left keys, right keys, remaining left, remaining right,
  deduplicated by name). New nullability and merged-key rules:

| Join type   | Columns from the left input | Columns from the right input | Same-named key (deduplicated)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| ----------- | --------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INNER       | as input                    | as input                     | left column (value and type)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| LEFT_OUTER  | as input                    | **nullable**                 | left column                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| RIGHT_OUTER | **nullable**                | as input                     | **right** column (the left value is NULL for unmatched rows ✅; legacy TDS did the same ✅)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| FULL_OUTER  | **nullable**                | **nullable**                 | `coalesce(left, right)`. **Nullable if either key is nullable:** a NULL key never matches (D4), so its row comes out unmatched with the other side NULL, and the coalesce is NULL. The engine still reports `[1..1]` here (507 NULL merged keys on `ORDERS.SHIP_REGION` ⟗ a NOT NULL key ✅), so conformance tests cannot catch this rule. Type = equal type, else the least common ancestor in the registry: `Varchar(15)`+`Varchar(2)` → `String`, `Numeric(10,2)`+`Numeric(12,4)` → `Decimal`, `Int`+`SmallInt` → `Integer`, `SmallInt`+`Double` → `Number` ✅ (§8.4 step 5) |

- **`describe()`:** `Join additional input` (§7.11); once it has key pairs, `<type> join on "A" = "B", …` (2026-10-11). **`swapInputs`:** §4.4.
- **Autofix** (spec §7.11's `renameInputs`; built in M2, `JoinAutofix.ts`, §11.4): a new Rename before each input,
  spliced in on the join's port, renames each column both inputs have that is not a same-named key
  (`getDuplicateJoinColumns`): `c` becomes `c_1` on the Left and `c_2` on the Right, then `c_<side>_2`, `c_<side>_3`…
  when the name is taken, in any case, in either input or by the fix itself (Left names first). The column part is cut
  so the name keeps to 128 code points. The key lists are rewritten through the same renames, so a duplicate that is a
  key at another position, or crossed keys, still join; the Join is replaced only when its keys change. New Renames are
  always added, never merged into existing ones. `planJoinDuplicateFix` plans it (`undefined` when nothing is shared or
  a name is invalid); `canFixJoinDuplicates` holds when the join has both inputs, the duplicate rule is its only error
  and a plan exists; `fixJoinDuplicates` makes the change and keeps the selection. They are not on `Query`. The Join
  editor applies its edits first, and the fix is one undo step.

Settled in M1.3 (the spec leaves these open; the user confirmed the first three on 2026-10-05):

- **Swapping inputs swaps the key columns too.** Spec §4.4 only flips the connections, which leaves a join whose key
  names differ checking its left keys against the new left input, so it always turned invalid. Nodes now have a
  `withSwappedInputs()` hook (default: the node itself) that `Query.swapInputs` applies; Join returns a copy with
  `leftColumns` and `rightColumns` exchanged and the same join type. So a swap turns a LEFT join around (the other
  input's rows are kept) and stays valid. It applies with a single input too.
- **Within a key pair (step 4)** both columns are checked for presence, then their types only when both exist. Every
  pair is checked (`validateAllItems`); steps 1, 2, 3 and 5 each stop at the first failure, in spec order.
- **A blank key name** reports the generic `Left join column does not have a name.` (or `Right …`).
- **Step 5** lists the duplicate names in left input order. `getDuplicateJoinColumns` keeps the spec's `extra`
  argument for Difference, which filters its difference columns out of `buildJoinSchemaColumns`' output rather than
  passing an `exclude` set (M6, §11.7). From M6d (§11.14) it compares names folded (`getCollidingJoinColumns`): a
  Left `ID` and a Right `id` are a duplicate, as SQL Server, MemSQL and DuckDB take them for one column; a key pair
  is exempt only when its names are exact; Difference's columns, which it renames on both sides, are left out. The
  autofix renames each side's own name (`ID_1`, `id_2`), keeps a merged key's name, and adds no Rename to a side with
  nothing to rename.
- **A key may repeat** (`[a, a]` ⋈ `[a, b]`); each name is still one output column.
- **INNER keeps nullability as input**; it does not infer that matched keys are non-null.
- **The FULL merged key** keeps the left type when the two types are equal (for an enumeration, the left one's
  values), else takes their least common ancestor. Compatible types always have one; otherwise it throws.
- `schematize` re-validates and gives `undefined` for an invalid join, so it never builds a schema with duplicate
  names. The helpers the emitter needs are exported: `getSameNamedJoinKeys`, `buildJoinSchemaColumns`,
  `getMergedJoinKeyType`.
- The palette entry is "Join Another Input" (spec §7.0), icon `join`. Join types are labelled Inner, Left Outer,
  Right Outer and Full Outer, in that order.

### 4.8 Filter (`type: 'filter'`)

- **Tree** (§8.1): `ColumnComparisonFilter {key, columnName, operator, value?}`, `CompositeFilter {key, operator: 'And'
| 'Or', rules}`, `NotFilter {key, rule}`. Values are typed `LiteralValue | LiteralValue[]` (§4.9), not `any`.
- **Operators:** the 16 in §8.2, with verbatim descriptions and negation pairs. Availability is **by type family**
  (§5.5).
- **Validation** (§8.3 plus the fix for the §21 type-check gap):

  1. The column exists (label `Filter column`).
  2. **New:** the operator is available for the column's type:
     `Filter operator "<op>" is not supported for column "<c>" of type <T>.`
  3. The value has the right shape (§8.3 verbatim: set operators need a non-empty array, empty operators need
     nothing, everything else needs a value; message `Filter value is required.`).
  4. **New:** each value is valid for the column type (§5.6).

  Composite and Not validation is as in §8.3.

- **Builder helpers** (§8.5) are pure functions in the core:

  - normalize the root to `CompositeFilter(And, …)` and unwrap a single rule on emit;
  - a column change resets the operator to Equal and clears the value when the type family changes;
  - an operator change clears the value when moving into or out of empty-operators or set-operators;
  - from M2, the grid's Filter by rule (`buildQuickFilterRule(column, type, cell)`, §9): Equal on the clicked value read
    as the column's type (a trailing `+0000` dropped), IsEmpty on a null cell, and none when the type has no Equal or
    the value does not read as one of the type. From M6d (§11.14) the grid writes `buildDrilldownKeyRule`'s rule: on a
    float, In the value and its 32-bit value when they differ, so a `REAL` value is found.

  The family-based reset replaces "type differs": changing between `Varchar(5)` and `Varchar(40)` keeps the value.

- **`describe()`:** `Filter by <toString()>` on the canvas. A **redacted** variant (column and operator only) is used
  for telemetry and logs (§21 "filter toString leaks values").

Settled in M1.4 (the spec leaves these open; the user confirmed them on 2026-10-05):

- **Values** are `LiteralValue | InvalidValue`, or a list of them for In/NotIn, so unparseable text is kept for its
  message (§4.9). The saved spec (M1.6) must store `{kind: 'invalid', text}` as well.
- **Validation order** within a comparison, stopping at the first failure: the column (a blank one reports
  `Filter column does not have a name.`), the operator, the value's shape, then each value. In the operator message
  `"<op>"` is the operator's description (`Filter operator "contains" is not supported …`) and `<T>` the type's
  display name. A list on a single-value operator, a single value on In/NotIn, or an empty list all report
  `Filter value is required.` A value on IsEmpty/IsNotEmpty is ignored. Groups check every rule, lists every item.
- **StrictTime** offers only IsEmpty and IsNotEmpty, like Variant and unknown types, since it takes no values.
- **Negation** uses the paired operator when there is one; otherwise a Not wraps the rule, including a single
  comparison such as `not (x is greater than 5)`. So §10.3's "`not` wraps composites only" becomes "composites and
  comparisons without a negated operator" (M1.6).
- **Rule keys** survive edits (`withColumn`, `withOperator`, `withValue`, `withRules` and the helpers), so editor rows
  keep their identity; new rules and new Not or group wrappers get fresh keys.
- **A blank row** has the column `''`, shown as `(blank)` (the spec's `ColumnComparisonFilter("(blank)")`).
- **Normalize/unwrap** act on the top level only; nested single-rule groups are kept. `undefined` normalizes to one
  blank row, so it does not round-trip.
- **Column change:** the operator and value are kept when the new column takes the same values: the same family and,
  for enumerations, the same enumeration. Otherwise the operator resets to Equal (or, for a type without Equal such
  as Variant, its first operator, IsEmpty) and the value clears. (The plan said family only; two different
  enumerations share the ENUM family, so they reset too.) When kept, text that was invalid is read again for the
  new type (300 is out of range for TinyInt but valid for SmallInt). The codec (M1.6) should do the same against the
  resolved schema on load.
- **Construction** refuses an unknown operator, group operator or value shape, as Join refuses an unknown join type,
  and the Filter node refuses anything that is not a filter rule (by shape: `kind`, `validate`, `toRedactedString`);
  every other invalid state (unknown or blank column, unavailable operator, missing or ill-typed value, empty group,
  no filter) is constructible and reported by validation. The codec (M1.6) keeps filters it cannot decode as
  unsupported ("This filter is not supported yet.").
- **Operator change** keeps the value when the new operator takes the same shape (none, one, or a list).
- **Descriptions:** a comparison reads `<column> <operator description> <value>`, with strings and invalid text in
  double quotes, other values as typed, and lists as `(a, b)`; groups join rules with `and` / `or`, nested groups of
  two or more rules in parentheses; a Not reads `not (…)`; nothing reads `(blank)`. The redacted form replaces each
  value with `?`; `describeRedacted()` is on every node (the description by default).
- `Filter.schematize` re-validates, like Join's, and passes its input schema through when valid. A core predicate,
  `isExactFloatComparison`, drives the editor's floating-point hint (§5.5).
- The palette entry is "Filter by Column" (spec §7.0), icon `filter`, before Join (menu order).
- **Filter by Expression** (M6b, §11.11), `ExpressionFilter` (`type: 'expressionFilter'`, port `['tds']`), is Cube's
  own, after Filter by Column, icon `expressionFilter`: one condition, a Pure lambda of one row, which the engine
  checks over the cube's model; its own check (`unresolved`, `checked`, `failed`) is pending as `ERR_CHECKING`, which
  `isTypingError` reads as it reads an Extend's `ERR_TYPING`. It passes its input schema through once checked, and
  keeps its input's order. It refuses, before the engine, a lambda of more than one parameter and a backslash in a
  `startsWith`, `endsWith` or `contains` literal (§8.2's rule).

### 4.9 Literal values

```ts
type LiteralValue =
  | { kind: 'string'; value: string }
  | { kind: 'boolean'; value: boolean }
  | { kind: 'integer'; value: string } // canonical digits; BigInt-safe; never a JS number
  | { kind: 'float'; value: string }
  | { kind: 'decimal'; value: string }
  | { kind: 'strictDate'; value: string } // YYYY-MM-DD
  | { kind: 'dateTime'; value: string } // YYYY-MM-DDTHH:MM:SS[.f{1,9}] — seconds mandatory
  | { kind: 'enum'; value: string };
```

Editor text goes through `parseValue(text, type): LiteralValue | { kind: 'invalid'; text: string } | undefined`.

- **Empty input:** empty trimmed input becomes `undefined`, which fails with `Filter value is required.` STRING is the
  exception: its text is kept **untrimmed**, and `''` is valid.
- **Unparseable text:** kept as `{kind: 'invalid', text}`, shown marked (§17.7), and failing with
  `Filter value "<text>" is not a valid <T>.` Spec §17.7 instead turns it into `undefined`, which would lose the
  text the message needs; Appendix A records the change.
- **Numbers** are canonicalized to the JSON number grammar `^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$`: strip `+` and
  leading zeros, `.5` → `0.5`, `5.` → `5`, `-0` → `0`. The lossless serializer rejects non-canonical tokens such as
  `+5`, `007` and `.5`, and JS `Number()` silently reads `0x10` as 16 ✅. `LiteralValue` only ever holds canonical
  strings, and the v1 serializer asserts the grammar.
- **Kind check:** validation also rejects a value whose `kind` differs from the column family's kind (same message).
  This covers each item of an In/NotIn list, stale kinds after schema drift, and hand-edited specs.
- **Range:** Integer is **not** limited to JS safe integers, because BigInt columns exist.

### 4.10 Unknown node

Spec §7.16 is kept: no schema, invalid, and `describe()` → `Unknown Transform "<id>"`. Two fixes for §21's "Unknown
cannot be re-saved":

- **Ports:** the node gets **instance-level ports**, one synthetic port per raw input (`in0…inN-1`). Its edges are
  then real Connections, so invariants 2–3, remove-heal and layout all apply. Spec §7.16's `ports = []` would drop
  them, and an edited document could no longer be reopened.
- **No rewiring:** `canConnect(*, unknown)` and `swapInputs` are false, because its port semantics are unknown.
- **Encode:** writes the raw JSON back verbatim, except `id` and `inputs`. Those are regenerated from the live
  connections, with `null` for a port that is now empty.

So a query from a newer client can be opened, inspected, edited around and re-saved without losing anything. It
still cannot execute.

### 4.11 Messages

- The §16 catalogue is kept verbatim, in one file.
- **New messages:**
  - `Filter operator "<op>" is not supported for column "<c>" of type <T>.`
  - `Filter value "<v>" is not a valid <T>.`
  - `Filter value "<v>" is out of range for <T>.`
  - `Sources from different databases are not supported yet; "<db>" differs from "<db0>".`
  - `Sort column "<c>" of type <T> cannot be sorted.` (M2). Two other checks M2 adds use the catalogue's generic
    templates: `Sort columns cannot have duplicates.` and, for Rename's collision fix,
    `New column name "<c>" is already present in the input schema.`
  - Group's and Concat's (M4), e.g. `Group column "<c>" of type <T> cannot be grouped.` and Concat's precise messages
    after the catalogue's `Both input schemas must be identical.`: listed in §11.5.
  - Partition's (M5): `Partition column "<c>" of type <T> cannot be partitioned.` (VARIANT, OPAQUE) and
    `Aggregation function "<a>" requires at least one sort column.` (Rank, Dense Rank, Row Number): listed in §11.6.
  - Difference's and Extend's (M6): `Difference column "<c>" cannot be a join column.`, `Difference output column "<c>"
is not valid column name.`, `"<c>" must be a lambda with one parameter, such as x | $x.PRICE.`, the engine's
    failure on a column (`"<c>" can't be typed: …`) or on all, and the pending marker `ERR_TYPING`: listed in §11.7.
- **New warnings** (M2, §4.4), derived and never validation errors; `<node>` is a node's id:
  - `This sort has no effect: <node> does not keep the row order. A sort only orders the query's output, or the rows a later Drop, Limit or Slice takes.`
  - `Sorting by "<c>", … has no effect: <node> removes that column (those columns) before the order is used.`
  - `Sorting by "<c>", … has no effect either: it comes (they come) after a removed column.`
- **Help text** (§17.9) moves to the builder's `__lib__`.

---

## 5. Types: precise primitives (requirement section)

### 5.1 Where they exist (findings)

- **Engine definitions** 📄✅: `legend-pure-m3-precisePrimitives-5.105.0` (engine `pom.xml:125` pins Pure 5.105.0),
  `platform_precise_primitives/precisePrimitives.pure`. 13 types:

| Precise type                                                                       | Extends    | Parameters           |
| ---------------------------------------------------------------------------------- | ---------- | -------------------- |
| `TinyInt`, `UTinyInt`, `SmallInt`, `USmallInt`, `Int`, `UInt`, `BigInt`, `UBigInt` | `Integer`  | –                    |
| `Varchar`                                                                          | `String`   | `(x)`                |
| `Float4`, `Double`                                                                 | `Float`    | –                    |
| `Numeric`                                                                          | `Decimal`  | `(precision, scale)` |
| `Timestamp`                                                                        | `DateTime` | –                    |

There is **no** precise `Date`, `Time` or `Decimal`. Constraints are advisory: `cast(@Varchar(3))` emits no SQL
CAST, and the rows violate the declared type ✅.

- **Relational column → Pure type** (`legend-engine-xts-relationalStore/legend-engine-xt-relationalStore-generation/
legend-engine-xt-relationalStore-grammar/src/main/java/org/finos/legend/engine/language/pure/compiler/toPureGraph/
RelationalCompilerExtension.java:1005-1100`) ✅:

| DB column                         | Pure type                              | DB column                | Pure type                                     |
| --------------------------------- | -------------------------------------- | ------------------------ | --------------------------------------------- |
| `VARCHAR(n)`                      | `Varchar(n)`                           | `DATE`                   | `StrictDate` (plain)                          |
| `CHAR(n)`                         | **`Varchar(1)`** (engine bug, `:1030`) | `TIMESTAMP`              | `Timestamp`                                   |
| `TINYINT/SMALLINT/INTEGER/BIGINT` | `TinyInt/SmallInt/Int/BigInt`          | `BIT`, `BOOLEAN`         | `Boolean` (plain)                             |
| `FLOAT`                           | `Float4`                               | `OTHER`, `ARRAY`         | `String` (values may come back as numbers ✅) |
| `REAL`, `DOUBLE`                  | `Double`                               | `JSON`, `SEMISTRUCTURED` | `meta::pure::metamodel::variant::Variant`     |
| `DECIMAL/NUMERIC(p,s)`            | `Numeric(p,s)`                         | `BINARY`, `VARBINARY`    | **HTTP 500 for the whole table's accessor**   |
| view column (any)                 | **`Varchar(0)[0..1]`**                 | NOT NULL / primary key   | `[1]`, otherwise `[0..1]`                     |

- **Protocol shape:** `{name, genericType:{rawType:{_type:'packageableType', fullPath}, typeVariableValues:[{_type:
'integer', value:n}]}, multiplicity}`. `fullPath` is either `meta::pure::precisePrimitives::X` or a bare base name.
- **The execution result builder is lossy:** it drops parameters, and `relationalType` reads `VARCHAR(1024)` for
  every `Varchar` ✅. **Schemas must come from `lambdaRelationType`.**
- **Studio** 📄:
  - [MetaModelConst.ts:64-82](../../../packages/legend-graph/src/graph/MetaModelConst.ts#L64): `PRECISE_PRIMITIVE_TYPE` lists
    paths the engine does not have (`precisePrimitives::Date`, `::Time`, `::Decimal`) and a wrong `Timestamp`.
  - `PrecisePrimitiveType` extends `DataType` and is indexed by short name; there are three disagreeing
    precise→standard maps.
  - [V1_RemoteEngine.ts:190-205](../../../packages/legend-graph/src/graph-manager/protocol/pure/v1/engine/V1_RemoteEngine.ts#L190):
    `buildRelationTypeMetadata`, behind `getLambdaRelationType`, keeps only each column's type path and drops
    `typeVariableValues`. Its batch variant read `results` while the engine returns `result`, so it threw, until
    #5593 (2026-10-06) fixed it
    ([V1_LambdaReturnType.ts:87-90](../../../packages/legend-graph/src/graph-manager/protocol/pure/v1/engine/compilation/V1_LambdaReturnType.ts#L87)).
  - Client-side table typing
    ([STO_Relational_Helper.ts:222-263](../../../packages/legend-graph/src/graph/helpers/STO_Relational_Helper.ts#L222))
    disagrees with the engine.
  - The query builder normalizes precise types to standard ones for operators and editors.
  - Data Cube keeps only a path string and hard-codes `Varchar(16777216)`
    ([DataCubeQueryBuilderUtils.ts:284](../../../packages/legend-data-cube/src/stores/core/DataCubeQueryBuilderUtils.ts#L284)).
  - **What works:** `V1_relationTypeModelSchema`
    ([V1_TypeSerializationHelper.ts:128](../../../packages/legend-graph/src/graph-manager/protocol/pure/v1/transformation/pureProtocol/serializationHelpers/V1_TypeSerializationHelper.ts#L128))
    keeps the parameters ✅. `V1_buildRelationTypeFromV1RelationType` even works against an empty `PureModel` ✅.
  - **Literals:** JS `JSON.parse` corrupts large Integer and Decimal values. `parseLosslessJSON` /
    `stringifyLosslessJSON` exist in [FormatterUtils.ts:201](../../../packages/legend-shared/src/format/FormatterUtils.ts#L201).

### 5.2 Decision: the domain models precise primitives itself (D10)

- The core cannot import `legend-graph`.
- The host's representations are lossy or wrong (above).
- The engine also returns types that `legend-graph` would mis-resolve (`Variant`; `Timestamp` collapsed with a
  relational class).

So the core owns a small registry. The builder's `v1/` adapter turns raw relation-type JSON into `CubeType`s:
deserialize with `V1_relationTypeModelSchema` from `legend-graph`, then map `fullPath` + `typeVariableValues` +
`multiplicity` directly. Unknown paths become `OpaqueType` instead of failing. Later, user-defined primitives can
resolve to a family through the model's generalization 💭.

### 5.3 Registry (core)

| Path (canonical)                                                                               | Short      | Family                             | Parent                 | Params | Range / format                               |
| ---------------------------------------------------------------------------------------------- | ---------- | ---------------------------------- | ---------------------- | ------ | -------------------------------------------- |
| `Boolean`                                                                                      | Boolean    | BOOLEAN                            | –                      | 0      | `true`/`false`                               |
| `String`                                                                                       | String     | STRING                             | –                      | 0      | any                                          |
| `meta::pure::precisePrimitives::Varchar`                                                       | Varchar    | STRING                             | String                 | 1      | length advisory (no check, §5.6)             |
| `Number` / `Integer` / `Float` / `Decimal`                                                     | …          | NUMBER / INTEGER / FLOAT / DECIMAL | Number (except Number) | 0      | Integer capped at the Java long range (§5.6) |
| `meta::pure::precisePrimitives::{TinyInt,UTinyInt,SmallInt,USmallInt,Int,UInt,BigInt,UBigInt}` | …          | INTEGER                            | Integer                | 0      | signed or unsigned width range               |
| `meta::pure::precisePrimitives::{Float4,Double}`                                               | …          | FLOAT                              | Float                  | 0      | finite                                       |
| `meta::pure::precisePrimitives::Numeric`                                                       | Numeric    | DECIMAL                            | Decimal                | 2      | decimal syntax                               |
| `Date` / `StrictDate` / `DateTime`                                                             | …          | DATE / STRICT_DATE / DATETIME      | Date (except Date)     | 0      | ISO date / datetime with seconds             |
| `meta::pure::precisePrimitives::Timestamp`                                                     | Timestamp  | DATETIME                           | DateTime               | 0      | as DateTime                                  |
| `StrictTime`                                                                                   | StrictTime | STRICT_TIME                        | –                      | 0      | reserved                                     |
| `meta::pure::metamodel::variant::Variant`                                                      | Variant    | VARIANT                            | –                      | 0      | –                                            |

The adapter also accepts short names on input (`Varchar` → canonical), because the metamodel round trip shortens
paths 📄.

### 5.4 Join compatibility (replaces §3.1 `areCompatibleTypes`; affects §7.11 step 4, and later §7.12)

- Two key columns are compatible iff they fall in the same **comparison class**:
  - **NUMERIC** = INTEGER ∪ FLOAT ∪ DECIMAL ∪ NUMBER. This keeps the spec's "any numeric joins any numeric", and
    precise widths are irrelevant ✅.
  - **STRING**: `String` and any `Varchar(n)`; lengths are irrelevant ✅.
  - **BOOLEAN**.
  - **STRICT_DATE**.
  - **DATETIME** (`DateTime`, `Timestamp`).
  - Abstract **DATE** is compatible with STRICT_DATE and DATETIME.
  - **ENUM** only with the same enum path.
  - **STRICT_TIME** only with STRICT_TIME (settled in M1.1; no relational column maps to it yet).
  - VARIANT and OPAQUE are never compatible.
- The **StrictDate ↔ Timestamp** pair is rejected: it executes but silently matches only midnight ✅.
- The message stays verbatim.
- **This check is mandatory:** the engine compiles `==` between any two types. A `Varchar` = `SmallInt` join compiles
  and then fails in H2, or is silently coerced ✅.
- **Columns of unknown physical type.** From a relational table accessor, a bare `String` column only comes from
  `OTHER`/`ARRAY`, because `VARCHAR`/`CHAR` always map to `Varchar(n)`. Its values may be numeric (e.g. FREIGHT in the
  shared Northwind model). Joining one to a `Varchar` compiles and then fails in the database ✅.
  - The relational adapter tags such columns **untyped**: string operators stay available, the picker shows "type
    unknown", and the Join editor shows an inline warning on any pair that uses one.
  - They are **not blocked**: the failure is a loud engine error at execute, not silent wrong data, and v1 has no
    cast transform to work around a block.

### 5.5 Filter operator availability (replaces the §8.2 matrix; the operator list itself is unchanged)

| Family (precise members)                                                               | Operators                                                  |
| -------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| BOOLEAN                                                                                | Equal, NotEqual, IsEmpty, IsNotEmpty (§8.2 row, unchanged) |
| STRING (`String`, `Varchar(n)`)                                                        | §8.2 `String` row, unchanged                               |
| ENUM                                                                                   | §8.2 `Enumeration` row, unchanged                          |
| INTEGER, FLOAT, DECIMAL, NUMBER (all precise ints, `Float4`, `Double`, `Numeric(p,s)`) | §8.2 numeric row, unchanged                                |
| STRICT_DATE, DATETIME (`Timestamp`), DATE                                              | §8.2 date row, unchanged                                   |
| VARIANT, OPAQUE (new)                                                                  | IsEmpty, IsNotEmpty only                                   |

- The matrix is keyed on the family, so precise types inherit the spec's rows. `Decimal` and `Numeric` join the
  numeric row.
- String-only operators must stay out of non-string families: `contains` on a number column silently means
  equality ✅.
- `OTHER` columns are `String` (engine truth), so they get string operators only. Northwind's money columns are fixed
  in the Cube fixture instead (§6.2.4).
- **Exact comparison on FLOAT-family columns** (Equal, NotEqual, In, NotIn on `Float4`/`Double`/`Float`): the
  operators stay available (spec rows unchanged), with an inline hint: "exact comparison on floating-point columns
  may not match".
  - On Northwind's 32-bit `REAL` columns it silently matches nothing: `FREIGHT == 32.38` returns 0 rows although
    order 10248 has 32.38 ✅. Negations match everything.

### 5.6 Value entry and validation (fixes the §21 "filter values not type-checked" gap)

Values are stored as strings (§4.9). Validation per type:

| Family / type            | Accepts                                                                                             | Errors                                                                                                                                                                                                                                                                                                                              |
| ------------------------ | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INTEGER (precise widths) | `^[+-]?\d+$`                                                                                        | out of the width's range, e.g. `SmallInt` ∉ [-32768, 32767]. Plain `Integer`/`BigInt` are capped at the Java long range: a literal one past the range **silently wraps** in the engine ✅. Unsigned values above 2^63−1 are rejected.                                                                                               |
| FLOAT                    | `^[+-]?(\d+(\.\d*)?\|\.\d+)([eE][+-]?\d+)?$`, finite; no hex/binary/octal, `Infinity`, `NaN` or `_` | not a number                                                                                                                                                                                                                                                                                                                        |
| DECIMAL / NUMBER         | same grammar as FLOAT                                                                               | not a decimal. Precision and scale are **not** enforced (`price > 1.555` on `Numeric(10,2)` is meaningful).                                                                                                                                                                                                                         |
| STRING                   | any string, untrimmed (empty allowed)                                                               | – (no length check: `CHAR(n)` is mistyped `Varchar(1)` and views are `Varchar(0)`, so length errors would block valid input ✅). **Except** a backslash in a StartsWith/EndsWith/Contains value or its negation, which the engine misreads (§8.4, Appendix B): `Filter values for "<operator>" cannot contain a backslash (\) yet.` |
| BOOLEAN                  | exactly `true` / `false`                                                                            | anything else                                                                                                                                                                                                                                                                                                                       |
| STRICT_DATE              | valid calendar `YYYY-MM-DD`                                                                         | invalid date                                                                                                                                                                                                                                                                                                                        |
| DATETIME                 | `YYYY-MM-DDTHH:MM:SS[.f{1,9}]`, seconds required                                                    | missing seconds: the engine silently truncates hour/minute literals to the day ✅                                                                                                                                                                                                                                                   |
| ENUM                     | one of the enum's values                                                                            | anything else                                                                                                                                                                                                                                                                                                                       |
| In / NotIn               | a non-empty list, each item validated                                                               | an empty list: `!in([])` silently drops NULL rows ✅                                                                                                                                                                                                                                                                                |

Accepted numeric text is canonicalized to the JSON number grammar (§4.9). Every value that fails becomes
`{kind:'invalid', text}` with `Filter value "<text>" is not a valid <T>.`

Settled in M1.1:

- **FLOAT:** a number a double can't hold (e.g. `1e400`) is **out of range**, not invalid. DECIMAL and NUMBER
  accept it.
- **DATE** (the abstract `Date`) takes a STRICT_DATE or a DATETIME value; the literal kind follows the text.
- **STRICT_TIME, VARIANT and OPAQUE** take no values.
- **DATETIME** also accepts a trailing `Z` or `+0000` and drops it (user, 2026-10-05): the engine returns timestamps as
  UTC (`2024-02-29T13:45:12.123456000+0000`), so values copied from the results paste into filters. Other offsets are
  rejected.
- `checkValue` returns a structured problem (`required`, `invalid` or `outOfRange`). M1.4's Filter turns it into the
  messages above.

Input affordances follow §17.7, with `Timestamp` as `datetime-local` (with seconds) and `Numeric` as text.

### 5.7 Aggregation and Extend result types (replaces §10.2; used from M4 onward)

Measured with `lambdaRelationType` ✅:

| Aggregation                                  | Integer family     | Float family | Numeric(p,s) | StrictDate | Timestamp | Varchar(n) | Boolean | Enum    |
| -------------------------------------------- | ------------------ | ------------ | ------------ | ---------- | --------- | ---------- | ------- | ------- |
| Count, DistinctCount                         | Integer            | Integer      | Integer      | Integer    | Integer   | Integer    | Integer | Integer |
| DistinctValue (`uniqueValueOnly`)            | input precise type | input        | Numeric(p,s) | StrictDate | Timestamp | Varchar(n) | Boolean | Enum    |
| Sum                                          | Integer            | Float        | Number       | –          | –         | –          | –       | –       |
| Average                                      | **Float**          | Float        | Float        | –          | –         | –          | –       | –       |
| Min / Max                                    | Integer            | Float        | Number       | StrictDate | DateTime  | Varchar(n) | Boolean | Enum    |
| Rank, DenseRank, RowNumber (window)          | Integer            |              |              |            |           |            |         |         |
| NTile (window)                               | Integer            |              |              |            |           |            |         |         |
| PercentRank, CumulativeDistribution (window) | Float              |              |              |            |           |            |         |         |
| Lag, Lead, First, Last (window)              | input precise type | input        | Numeric(p,s) | StrictDate | Timestamp | Varchar(n) | Boolean | Enum    |

- **Count rows** (M4, §11.5 Q1): Cube's aggregation with no column, `x|1 : y|$y->count()`, typed Integer. A
  Partition offers it too, as `{p,w,r|1}:y|$y->size()` (M5, §11.6).
- **Windows** (M5, §11.6): a Partition offers the functions a Group does, Count rows included, plus the window-only
  Rank, DenseRank and RowNumber, which take no column, are typed Integer and need a sort. The column functions' types
  are the Group's; the conformance suite checks every function on every family it covers (M5.7).
- **More windows** (M5b, §11.9): NTile (Integer), PercentRank and CumulativeDistribution (Float) take no column; Lag,
  Lead, First and Last take a column of any type a window can partition by (`isSortableType`) and give its type. All
  seven need a sort; a Group never offers them.
- **Nullability:** Count, DistinctCount, Count rows, Rank, DenseRank, RowNumber, NTile, PercentRank and
  CumulativeDistribution are not nullable; every other aggregate output is, Lag, Lead, First and Last included (a
  partition's edges are empty for Lag and Lead ✅). The engine types Min, Max and DistinctValue `[0..1]` but Sum and
  Average `[1]` ✅, though an all-null group's Sum is null ✅, and so are an all-null partition's Sum and Average ✅
  (M5), so the conformance suite declares Sum and Average as columns where Cube is wider (§11.5 Q7).
- **§10.1 availability:** keep the spec's rows; DECIMAL counts as numeric (the spec doesn't name it), and VARIANT and
  OPAQUE offer Count only (M4). Min/Max on String and Boolean, and DistinctCount/DistinctValue on
  enums, are offered from M6d (§11.14): Min and Max typed as the column, run on H2 and DuckDB ✅.
- **Extend** (M6, §11.7): §9.2's local table is wrong against the engine ✅, so Extend's types come from the engine,
  over the **cube's model** (over an empty one, the model's enumerations and functions don't type ✅), as the chain
  Cube types any node: the input's relation, then one `extend` per column, which works for every kind of source. The
  typing is stored on the node with a signature of Cube's input schema and the columns, so `schematize` stays
  synchronous and host-free; it also records a digest of what the engine was given (the input's relation and the
  model), which the editor compares, since Cube's schema can't show all the engine sees (an Extend column is nullable
  to Cube, `[1]` to the engine after `toOne()`). A result is a primitive or an enumeration; an enumeration result
  reads today as a type Cube doesn't know and is refused (`dayOfWeek()`; `->toString()` works around it). Every
  Extend column is nullable: an expression over an empty value is empty in SQL, though the engine types it `[1]` after
  `toOne()`, so the conformance suite declares Extend columns wider (§11.5 Q7).

### 5.8 How types appear in the lambda, the saved spec and the UI

| Where            | Form                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Lambda (slice)   | Mostly not written: accessors carry the types. **Literals are typed by column family**: INTEGER → `{_type:'integer'}`, FLOAT → `float`, DECIMAL/NUMBER → `decimal`, STRICT_DATE → `strictDate`, DATETIME → `dateTime`, ENUM → `EnumPath.VALUE`. Serialized losslessly. **One explicit type:** `->cast(@<common ancestor>)` on a FULL merged key whose types differ (§8.4 step 5), written as a `genericTypeInstance` (§8.3).    |
| Lambda (from M4) | Further casts only where Cube widens types: Concat's Convert types writes a type-only `->cast(@<T>)` within numbers, strings or dates, with no SQL cast ✅ (`toString()` across families, later; §8.8, §11.5 Q5). Pure text uses full paths with parameters, e.g. `meta::pure::precisePrimitives::Varchar(15)`. From M6, an Extend's expression is the engine's own JSON for the user's lambda, written digit for digit (§8.8). |
| Engine responses | `rawType.fullPath` + `typeVariableValues` → `CubeType`; `multiplicity.lowerBound == 0` → `nullable` (but see §4.7 for joins).                                                                                                                                                                                                                                                                                                   |
| Saved spec       | Source schema snapshots `{name, type:{path, params?}, nullable}`; literal values `{kind, value}` (numbers as strings, booleans as JSON booleans), text that is not a valid value `{kind: 'invalid', text}`. An Extend's columns `{name, code, lambda}`, the lambda's number literals as their digits, and its last typing `typed: {signature, types}`, each type `{path, params?}` or `{path, values}` (M6).                    |
| UI               | Short name with parameters (`Varchar(5)`, `Numeric(10,2)`, `SmallInt`) and a nullable marker. A family icon. The full path in a tooltip.                                                                                                                                                                                                                                                                                        |

---

## 6. B. Sources

### 6.1 Source seam (replaces §6.1 details; keeps the idea)

Adding a source kind means two registrations and nothing else; the graph, inference, emitter, grid and codec are
untouched (§6.7's promise). **Not built yet:** the core half exists (`SourceDefinition`), but the builder has no
`SourceKindAdapter`; it is wired to relational tables. A new source kind today also touches `CubeEditorState`
(re-checking tables, the picker's opening), `CubeSourcePickerState`, `CubeJoinDraft` (where a column comes from),
`CubeSourceEditor`, and the port's `resolveSchemas` and `CubeModelOutline`. The builder's picker half is built with
the direct connection, and the core half with data products (§6.8; M2.0 no longer gates either):

- **Core:** a `SourceDefinition` (§4.5), covering coordinates, codec, validate, describe, `emit` (an IR relation
  expression, e.g. an accessor), and the execution requirements it contributes (runtime, and later mapping or
  parameters).
- **Builder:** a `SourceKindAdapter` with:
  - a **catalog** (lists candidates for the picker);
  - a **resolver** (gets the schema and parameters through the engine);
  - a **picker tab** component.

Resolution replaces §6.1's single `resolveGraph` call: **one `POST /api/pure/v1/compilation/lambdaRelationType/batch`**
covers every source, keyed by node id, with per-key errors ✅. It returns `{result, errors}`. Cube parses the raw response itself: Studio's
wrapper drops type parameters (it also read `results` until #5593).

### 6.2 Relational tables, in depth (slice)

**6.2.1 Coordinates.**

- Each source holds `{database, schema, table}`. `context.model` is a model context (§6.2.2) and `context.runtime` is
  a runtime path, both query-level (one model, one runtime per query, D2).
- **Schema and table names** are stored exactly as the Database protocol's `name`, **quote characters included**: a
  quoted table `"a.b"` is the path `["db", "S1", "\"a.b\""]` ✅. The unquoted `["db","S1","a.b"]` fails with
  "Can't find table" ✅. Quotes are stripped only for display.
- **Column names** come from `lambdaRelationType`, unquoted.
- **Dotted names must never round-trip through Pure text:** `#>{db.S1."a.b"}#` silently resolves to a different
  table ✅. M1.7 adds a quoted, dotted table to the fixture so this path is tested.

**6.2.2 Model context** (Settled in M1.6, user OK 2026-10-06; it replaces a `{kind: 'local', id}` /
`{kind: 'project', …}` reference).

```ts
// the engine's V1_PureModelContext as plain JSON; only the builder's v1/ seam types it
interface ModelContext extends JsonObject {
  readonly _type: string;
}
// slice: { _type: 'text', code: '<Pure grammar>' }  (bundled fixture or pasted model; dev and local only)
// M3:    { _type: 'pointer', sdlcInfo: { _type: 'alloy', groupId, artifactId, version, packageableElementPointers: [] } }
```

- **The cube holds the model, not an id.** A `.cube.json` is self-contained: it runs against any engine (e.g. the
  local one on :6300) with no catalog, and a pasted model survives a reload.
- **The core keeps it opaque.** It checks only that the model is an object with a non-empty string `_type`, and
  re-saves it exactly as read. It never imports a `V1_*` type (D12).
- **The builder reads it.** Its `v1/` seam types it as `PlainObject<V1_PureModelContext>` (precedent: Data Cube's
  `FreeformTDSExpressionDataCubeSource.model`, saved raw). It decides which kinds it can run: `text` in the slice,
  `pointer` from M3. A cube whose model has another `_type` still opens and edits offline from its schema snapshots;
  resolving and running report that the model kind isn't supported.
- **Saved as `text`, never `data`:** smaller, readable, and not tied to the element protocol's version.
- **Costs accepted:**
  - the text counts against the 1 MiB cap (Northwind is about 25 KB); a model too large gives the existing
    "too large to save" error;
  - a cube keeps the model it was made with, so a later fix to the bundled fixture doesn't reach it (snapshot drift
    still flags changed tables);
  - a text model with a LocalH2 connection runs its setup SQL on the engine when executed, as a pasted model does,
    so text models stay dev and local only.

**6.2.3 `LocalModelCatalog` (slice).**

1. Take Pure grammar text: a bundled fixture or a pasted model. Picking one copies its text into the cube as
   `{_type: 'text', code}` (§6.2.2).
2. Parse it once per load with `grammarToJSON_model`
   ([V1_EngineServerClient.ts:405](../../../packages/legend-graph/src/graph-manager/protocol/pure/v1/engine/V1_EngineServerClient.ts#L405)).
   The result is a model-context JSON (`{_type:'data', elements}`).
3. Read `Database` elements (schemas → tables) and `PackageableRuntime` elements from it, in the `v1/` seam.
4. Send the cube's saved `text` context with every typing and execution call. Inline `data` and `text` contexts
   both work for `lambdaRelationType` and `execute`, and need no depot ✅.

**6.2.4 Cube Northwind fixture.** `legend-cube-builder/src/stores/fixtures/CubeNorthwindModel.ts`, a TS string.

- It is a corrected copy of the Database in
  [Northwind.pure](../../../packages/legend-manual-tests/src/__tests__/query-builder/model/Northwind.pure). The shared model is
  left untouched, because the query-builder grammar tests use it.
- Corrections:
  - `ORDERS.FREIGHT`, `ORDER_DETAILS.UNIT_PRICE`/`DISCOUNT` and `PRODUCTS.UNIT_PRICE` become `REAL` (→ `Double`). In
    the shared model they are `OTHER` → `String`, so numeric filters fail to compile ✅. **REAL fixes compilation
    and range comparisons only.**
    - The H2 columns are physically 32-bit `REAL`, whatever the model declares. Exact comparisons silently match
      nothing (`FREIGHT == 32.38` → 0 rows), and boundary comparisons are off ✅.
    - So tests and the Part B script only use range filters away from stored values (e.g. `FREIGHT > 50`).
    - Exact float equality is tested on `ALLTYPES.D` (`DOUBLE`).
  - **Drop the BLOB/CLOB columns** (`EMPLOYEES.NOTES`, `PHOTO`, `CATEGORIES.PICTURE`). `NOTES` serializes as
    **invalid JSON** in every format ✅. The accessor's SQL lists only declared columns, so dropping them is safe.
  - `SHIPPERS.PHONE` becomes nullable.
  - Remove the four bogus `*_REGION` joins (text vs SMALLINT) ✅.
- It has a LocalH2 connection with `testDataSetupSqls: ['call loadNorthwindData()', <CUBETEST DDL + inserts>]` and a
  mapping-less `StoreRuntime`.
- **`CUBETEST.ALLTYPES`** adds the precise types Northwind lacks. Setup runs on every connection checkout, so its
  SQLs begin with `drop schema if exists CUBETEST cascade`.
  - **Columns:** `ID INTEGER PRIMARY KEY`, `TI TINYINT`, `SI SMALLINT`, `BI BIGINT`, `F FLOAT`, `D DOUBLE`,
    `DEC DECIMAL(10,2)`, `NUM NUMERIC(12,4)`, `DT DATE`, `TS TIMESTAMP`, `B BIT`, `VC VARCHAR(20)`.
  - **Rows** (designed for the acceptance filters in §11.2 A.5):
    - **ID 1:** `BI` 4, `DEC` 12.34, `D` 2.5, `TS` `2024-01-02 03:04:05.678`, `B` true, `VC` `'abc'`; every other
      column populated.
    - **ID 2:** `BI` 9007199254740993 (above 2^53, to exercise lossless reading), `DEC` 1.25, `D` 0.1,
      `TS` `2024-01-02 13:00:00`, `B` false, `VC` `'xyz'`; every other column populated.
    - **ID 3:** every nullable column NULL.
  - The scratch fixtures behind the type findings (`ops/types.pure`, `precise/model.pure`, kept outside the repo) had
    different rows. These rows are **new** and get verified in M1.7.
- **More `CUBETEST` tables** (Settled before M1.7, user 2026-10-06). The slice has only Source, Join and Filter
  nodes, so some Part A cases need tables shaped for them. All are created after `call loadNorthwindData()`, since
  some copy Northwind columns, and all live in `NorthwindDatabase` (one database per query):
  - **A quoted, dotted table** `"ORDER.LINES"` (e.g. `LINE_ID INT`, `RIGHT_COL VARCHAR(10)`), plus a decoy table
    that a misread dotted path would resolve to, so the "silently another table" hazard (§6.2.1) can fail a test;
  - **problem tables**, one or two rows each: a `BINARY(8)` column, a `CHAR(3)` key, an `OTHER` column, and a view
    over `ALLTYPES`;
  - **narrow copies of Northwind columns** (`CREATE TABLE … AS SELECT`), so the expected counts stay the same:
    `EMP_REGION(EMPLOYEE_ID, REGION)` and `CUST_REGION(CUSTOMER_ID, REGION)` for the REGION join (A.6; joining the
    raw tables breaks Cube's duplicate rule on ADDRESS, CITY, POSTAL_CODE, COUNTRY), `CATEGORY_REGION(CATEGORY_ID,
SHIP_REGION NOT NULL)` copied from `CATEGORY_NAME` for the one-nullable-key FULL join (A.4), and key pairs
    `VARCHAR(15)`/`VARCHAR(2)` and `DECIMAL(10,2)`/`NUMERIC(12,4)` for the parameter-only FULL casts (§8.4).
  - Verified on the engine (2026-10-06, with a probe script kept outside the repo) ✅: every table types
    in one batch call except `PROBLEM_BINARY`, which fails alone; `CHAR(3)` types as `Varchar(1)`, `OTHER` as
    `String`, the view's columns as `Varchar(0)`; the dotted table's Pure-text path silently reads the decoy, and
    its unquoted path fails with "Can't find table"; `EMP_REGION ⋈ CUST_REGION` gives 15 / 19 / 103 / 107 rows;
    `ORDERS ⟗ CATEGORY_REGION` gives 838 rows, with the 507 NULL-key orders; `KEY_VC15 ⟗ KEY_VC2` 5 rows and
    `KEY_DEC ⟗ KEY_NUM` 3 rows. ALLTYPES reads back as designed, `BI` as `9007199254740993` and `TS` as
    `2024-01-02T03:04:05.678000000+0000`.

**6.2.5 Runtime rule.**

- The engine matches a runtime's connection to a store **by exact element**, and with no match it **silently falls
  back to the first connection** ✅. Northwind queries ran against another database in tests.
- So the picker offers only runtimes whose store keys **exactly** contain the source database.
- Store keys are read from both runtime syntaxes: `connections[].store` and `connectionStores[].storePointers[]` ✅.
- Includes are ignored, and `localEngineRuntime` and dataspace pointers are treated as unresolvable (hidden).

**6.2.6 Schema resolution.** `{nodeId: |#>{db.schema.table}#}` goes into the batch endpoint (no `from()` needed),
and each result maps to a `Schema`.

Problem tables are flagged in the picker rather than crashing the canvas:

| Case                      | Behaviour                                     | Flag shown                           |
| ------------------------- | --------------------------------------------- | ------------------------------------ |
| a `BINARY` column         | the whole table's accessor fails ✅           | shown, "unavailable", not selectable |
| a view                    | every column is `Varchar(0)` ✅               | hidden in v1                         |
| a `CHAR(n)` column        | typed `Varchar(1)` ✅                         | "length unknown"                     |
| an `OTHER`/`ARRAY` column | typed bare `String`, values may be numbers ✅ | "type unknown" (untyped, §5.4)       |

**Settled before M1.7 (user, 2026-10-06): the engine types tables now, a local typer later.**

- M1.7 types tables with the engine, as above: `resolveSchemas` makes one batched `lambdaRelationType` call per
  load, and one per table picked. Downstream schemas are inferred by Cube, so the number of calls doesn't grow with
  the graph.
- Typing tables locally from the `Database` definition (no call per pick; column details for every table up
  front) replaces that one port method later, once legend-graph is fixed (the legend-graph issue list kept outside the repo, groups A,
  B, D and E; M2.0 or after). The Pure text still needs one engine parse per load either way.
- M1.7 records the engine's relation type of every table in the Cube Northwind + ALLTYPES fixture (the quoted,
  dotted table and the problem tables included) as an engine-backed parity test. The local typer must reproduce it
  exactly.

**6.2.7 Picker UI (slice).** A minimal dialog. M3 redesigns it.

1. Pick a model (bundled "Northwind (Cube fixture)" or "Paste Pure model…"). Since 2026-10-10 this is the **Sample
   Data** tab's Dataset step, offering the three bundled datasets only (§6.9).
2. Pick a database, then a runtime filtered to it (§6.2.5; the filter needs the database, so it comes first —
   Settled before M1.8). A step with a single choice is picked automatically.
3. Pick schema → a table list with search, column counts and the flags above.

Confirming resolves the table first and then adds the node (§17.8: "lands with its schema populated"). The first
source fixes `context.model` and `context.runtime`; later picks are limited to that runtime's database.

### 6.3 Relational with depot (M3, built 2026-10-10)

A cube can read tables of the Databases in a published project, through the source dialog's **Project Database** tab.

- **What it saves:** the engine's alloy pointer at a released version (§6.2.2's M3 form,
  `graph-manager/CubeProject.ts`), plus the runtime's path. The engine types and runs it as saved, and fetches the
  project from its depot. SNAPSHOT versions and the `latest` and `HEAD` aliases are refused, saved or not (user,
  2026-10-09: released versions only).
- **The catalog** (`CubeProjectCatalog`, implemented over the host's `DepotServerClient`): the project list
  (`project-configurations`), a project's released versions (newest first, by number: 1.10.0 before 1.9.0), and a
  version's outline. The outline is built from the version's model twice over: with its dependencies, for the
  runtimes, and without, for the Databases offered. Only the project's own Databases are listed (user, 2026-10-09).
  An outline is read once per version and page; a failed read is tried again.
- **The tab** extends the Model tab: project, version (the newest picked, marked "(latest)"), then the Model tab's
  Database, runtime, schema and table steps (shared component). A Database that no runtime connects to says so and
  can't be added. A cube on a project belongs to the tab: the dialog reopens on its project and version, fixed, and a
  host without a catalog offers nothing on it.
- **The run's database type** comes from the project's outline, so §11.4's workarounds apply to project cubes too.
- **Errors:** the engine's "unable to load information from the Pure SDLC" HTML, e.g. for a version the depot doesn't
  have or a depot that is down, reads as plain words naming the URL tried.
- **Hosts:** `CubeHost.projectCatalog` (optional), built with `buildCubeProjectCatalog(depotServerClient)`. Legend
  Query builds it from `depot.url`; it reads nothing until the Project tab opens. The Model tab stays the dialog's
  default tab.
- **Tests:** unit and page tests with a fake catalog; a CI engine test that the mock depot's sample projects compile
  and type (`CubeDepotSamples.engine-roundtrip-test.ts`); and the manual `cube-local` group, end to end through the
  mock depot and the engine (`CubeDepot.cube-local-test.ts`).
- **The mock depot** (`fixtures/legend-mock-server`) serves two small sample projects beside its test project:
  `cube-sales` (releases 1.0.0, 1.9.0 and 1.10.0, a master-SNAPSHOT, Databases with one, two and no runtimes) and
  `cube-reference`, a dependency. `scripts/generate-cube-depot.mjs` writes them from Pure through an engine. These
  replace the EMIT-generated dataset the requirements planned: with released versions only and no dependency
  Databases, small hand-written projects cover the cases.
- **Not yet:** moving a cube to another version; a search across projects' Databases; SNAPSHOT versions; Databases
  from dependencies; paging the project list (unmeasured at a real depot's size).

### 6.4 Services (work in progress, 2026-10-12)

A cube can pick a deployed service through the source dialog's **Service** tab. It can't read one yet: how a
service is read is still to be written (user, 2026-10-12).

- **The list:** the engine's service registry, `GET /api/service/v1/list/detailsFromCache` (legend-graph's
  `getServicesInfo()`, which Marketplace's Data APIs page reads): each service's name, title, URL pattern,
  documentation and owners. The tab searches it by name, title and pattern.
- **Its badge:** WIP, not BETA, in the palette, Add Items and its editor (user, 2026-10-13).
- **Hosts:** `CubeHost.serviceCatalog` (optional), built with `buildCubeServiceCatalog(engineServerClient)`. The
  endpoint isn't in the open-source engine, so a host without one offers no Service tab or palette item, as with
  the depot. Legend Query builds one from its engine.
- **What it saves:** the source `{type: 'service', pattern, service}` (the pattern, which the engine's SQL reads a
  service by, and the service's name); the cube's model is `{_type: 'cubeService'}`, with no runtime.
- **Not yet (TODO):**
  - reading it, through the engine's SQL island: `#SQL{select * from service('/pattern')}#`, a relation the cube
    goes on from (the engine's SQL reads `service('/…')` in a `FROM`, `testTranspile.pure`). The emitter
    (`ServiceSourceEmitter.ts`) and the engine's model kind are the two places marked;
  - typing it, which the editor asks for in one place (`CubeServiceRecheck.ts`); until then a service source is
    invalid with "Cube can't read services yet", and nothing runs;
  - its parameters, which SQL passes by name (`service('/pattern', name => 'value')`);
  - what it runs on: the service's own execution, or a runtime the cube picks.
- **The earlier outline** (copying the service's lambda, converting TDS with
  `POST /api/pure/v1/compilation/autofix/transformTdsToRelation/lambda` ✅, binding parameters with `let`, and
  saving `{gav, servicePath, executionKey?, parameterValues}` with a hash) is set aside for the SQL route.

### 6.5 Pure functions returning a Relation (built 2026-10-12)

A cube can read a function of a published project that returns a relation, through the source dialog's **Project
Function** tab. A `function f(…):Relation<(…)>[1]` is called in the cube's lambda (`p::f(1.0)`), composes with
tables and every operation, and pushes down into one SQL statement ✅. This covers spec §6.5 (PURE Native
Function).

**Two kinds of function.** A function either reads its rows through a `from(runtime)` of its own, directly or
through a function it calls, or leaves the runtime to its caller. Probed on an engine, 2026-10-12, with two
runtimes on different data:

- a function without a `from()` needs the cube's: alone, the engine refuses it ("RelationStoreAccessor should be
  used with a from(Runtime) expression");
- a function with its own `from(R1)`, run by a cube on `R2`, reads different databases depending on the query's
  shape: a plain chain reads `R1`, but the `let` form Cube writes for windows (§8.6) reads `R2`, and so does a
  join with a table, which then reads `R1` throughout. So **a cube on a function with its own runtime runs on that
  runtime**: the same query reads the same rows whatever its shape. A `let` needs a `from()` outside it anyway.

**Decisions (user, 2026-10-12):**

- **Which functions:** the project's own, as with its Databases (§6.3), at a released version.
- **Mixing:** a function joins the tables of the same project and version, in one cube: one model and one runtime.
- **Parameters:** primitives (String, Integer, Float, Decimal, Boolean, StrictDate, DateTime, Date), `[1]` or
  `[0..1]`, set in the source's editor. A function with an enum or class parameter, or one of `[*]`, is listed but
  can't be added, with the reason.
- **Two PRs:** the services WIP (§6.4) first, then the function source as its own milestone with a demo video.

**The tabs (as built):** a **Project Function** tab of its own (user, 2026-10-13), with the Project Database
tab's steps: a published project, then one of its released versions (the newest picked), then the version's
functions. Project Database reads tables only. Sample Data (and a pasted model) keeps a **Read: Tables |
Functions** choice, shown when the model has functions. A project cube is served by both project tabs, so a
function and the tables of the same project and version join in one cube, each added from its own tab; the
palette's **Function** item opens Project Function on such a cube, Sample Data on a new one, and nothing on a
connection's cube.

- **The list:** the model's functions that return a `Relation<…>[1]`, from its outline with no engine call
  (`V1_CubeFunctionOutlineBuilder`): the path the lambda calls, the full name, the parameters and the declared
  columns. A project lists its own functions only (§6.3), read with its dependencies for their runtimes. One that
  can't be added is listed disabled with why: a parameter of another type or of `[*]`, a runtime given by an
  expression, or two runtimes.
- **Its runtime:** Cube walks the function's body for `from(<runtime>)`, following calls to the model's functions
  (by path, or by name when one function has it). Found, it is the cube's runtime, shown read-only ("Runs on …, set
  by the function"); a cube already on another runtime refuses it. Not found, the user picks one of the model's
  runtimes, or the cube's once it has one. The table steps then take the cube's runtime too.
- **Its parameters:** a field per parameter, checked against its type as it is typed. Empty text is no value: an
  optional one passes `[]` (an optional String is never `''`), a required one is missing. Add needs a valid value
  for each required one; the source's editor changes them later (Apply, one undo step).
- **Adding it:** the engine types the call (`{| f(…)}`), with placeholders for values that aren't valid yet: the
  columns don't depend on them. The first source sets the cube's model and runtime.
- **Runs:** the call is a source like a table, `f(…)->…->from(runtime)`, and a window's `let` form works on it
  (engine test). A run's database type, for §11.4's workarounds, is that of the runtime's connections, which a
  function may read any of.
- **On the canvas:** the function's name, with its values as the muted line. Show Pure prints a call with
  arguments in the engine's arrow form (`'Rates'->deskBook([])`), as the engine's grammar composer writes it.
- **Samples:** the Trades sample has two functions, `tradesAbove(minNotionalUsd)`, which leaves the runtime to the
  cube, and `deskBook(desk, side?)`, which reads through its own; its text before them stays an earlier model, so
  cubes saved on it open on the same dataset. The mock depot's `cube-sales` 1.10.0 has `ordersAbove(minAmount,
status?)` and `shippedOrders()`, bound to `SalesRuntime`.
- **Not yet:** enum and class parameters; `[*]` parameters; a dependency's functions; a function returning a TDS;
  data product function access points.

**Tests:** unit tests of the node, its codec and the outline builder (on the engine's JSON of a model with
functions); the picker's and the editor's state and components on the fake engine; an engine test of the Trades
functions with and without a `from()`, with a parameter and an empty optional one, joined with a table, and
through a window's `let`; and the mock depot's functions compile and type on the engine.

### 6.6 Data products (M9, outline)

- **Accessor:** `#P{dpPath.accessPointId}#` with a synthesized `LakehouseRuntime(environment, warehouse)` sent as
  `combination[pointer, runtime]`.
- **Schema:** the depot artifact's cached `lambdaGenericType`, which is precise.
- **Not in the open-source engine:** its parser knows only `[GQL, SQL, >, TDS]` ✅. Develop against mocks (D6).
- There are also native and model-access modes based on mappings. They may run on the open-source engine 💭.

**Unknowns:** the meaning of access-point `parameters` (always sent empty today), environment and entitlement
resolution, and which access modes Cube exposes.

### 6.7 Ingest data sets (M3, settled by the user, 2026-10-09)

Ingest definitions became a source in M3, built before Depot databases (they rebase onto it). Requirements: the local
evidence `ingest-requirements` (four rounds of answers recorded there). Cube copies Data Cube's Lakehouse Producer
source for now; the user plans to improve it later.

- **Picking (an "Ingest" tab of its own, with an "Ingest Dataset" BETA palette item):** the environment, one dropdown
  of every ingest environment the platform lists in both classes, production and production parallel (user,
  2026-10-15), starting on the viewer's own production one. A cube on another environment saves its name beside its class; one left
  on the viewer's own saves none, so each viewer reads their own (user, 2026-10-16), as cubes saved before do. Then a producer deployment (numeric deployment ids only; the viewer's
  own user-id environments are left out for now), labelled with its name from the deployment directory Marketplace's
  producer info reads (`dataProductConfig.producer.deploymentLegendServiceUrl`), else its id; then one of its
  deployed definitions, then a data set. Only definitions deployed from SDLC (`alloy-git:<group>~<artifact>~<path>`
  URNs) are listed; ad hoc ones (`rest-api:` URNs) are not supported. Everyone can add data sets: the lakehouse refuses
  a run the viewer may not make, and Cube shows that error.
- **The definition:** read from the ingest server by URN through its grammar route, then parsed by the engine, as Data
  Cube does (user, 2026-10-09). No project version is looked up now. TODO: read the definition from Depot instead, at
  the project version it was deployed from, which also gives the SDLC pointer (a later improvement).
- **Schema:** each data set's declared columns, with their type parameters and nullability, then the LAKE\_\* columns its
  write mode adds, as legend-graph types them for Legend Query; no engine call. Materialized views, many-valued columns
  and types Cube doesn't know are listed disabled with the reason. The LAKE\_\* columns come in the order the engine
  returns them, `LAKE_DIGEST`, `LAKE_IN_ID`, `LAKE_OUT_ID`, then `LAKE_FROM`, `LAKE_THRU` (2026-10-14: a run failed
  with `LAKE_DIGEST` where Cube, following legend-graph's `LAKE_IN_ID` first, expected `LAKE_IN_ID`; a run's rows are
  read by position).
- **Accessor and run:** `#I{definition.dataSet}#` (`metadata: false`), run on a model of the definitions the cube reads
  plus a LakehouseRuntime at `cube::ingest::Runtime`. Its environment is the one of the ingest server that served the
  definition; the warehouse follows the data product rule (§6.8).
- **Saved:** a Cube-owned model kind `cubeIngest` (the class, the producer deployment, the warehouse); each source
  saves the definition's URN, its path and the data set. Never a server URL. One cube reads one kind of source (tables,
  data products or ingest data sets) and one producer deployment.
- **Config:** Legend Query's optional `lakehouse.platformUrl` (the key Data Cube and Marketplace use); the ingest
  servers are found through it.
- **After Add:** a data set's Source panel shows its definition (the URN on hover), the class, the producer deployment
  and the warehouse, which is edited there as a data product cube's is (§6.8). Import and **Refresh** read the
  definition again and warn when a data set's columns changed. A data set whose definition can't be read again (e.g.
  no longer deployed) keeps its saved columns, with a warning saying why.
- **Tests:** the open-source engine can't parse a definition or type `#I`, so the engine tests stand each data set in
  for with a Pure function over an H2 table, and check a Filter on `LAKE_OUT_ID`, a Join of two data sets and a Group
  downstream (testing.md). The ingest servers, the grammar parse, `#I` itself, the environment name and the warehouse
  are checked in Part B2 (§11.2).

### 6.8 The next sources: settled so far (user, 2026-10-08)

The user put the database entry points and data product access points first, with a local test setup that mocks what
open source lacks (the data product backends, and Depot at the scale of thousands of projects). Research behind this
(local evidence `sources-v2/`): the open-source engine rejects every data product construct (`#P`, data product and
compute elements, a LakehouseRuntime with an environment and a warehouse) ✅; when two model contexts define the same
element path, the engine silently keeps the first ✅; EMIT models served from a mock depot type and execute through
SDLC pointers once their test data becomes LocalH2 setup SQL ✅. Cube copies the original's experience, not its
internals (user, 2026-10-08): questions for the original app's team are about its UI only, in
[QUESTIONS.md](QUESTIONS.md); checks that need an internal deployment are kept outside the repo.

- **Build order:** the direct connection first (closest to the pasted-model path and testable on a laptop), then data
  products, then Depot databases (user, 2026-10-08: data products moved ahead of Depot databases), each once its mocks
  land. The direct connection builds the picker half of the §6.1
  seam (a dialog with one tab per kind); the core half (a new `SourceDefinition`) comes with data products, the first
  source that isn't a relational table.
- **Depot databases, for now:** project → version → Database → table, with a runtime from the same project. A global
  search across projects' Databases comes later as a second mode of the same picker (the open-source Depot only
  matches element paths and doesn't page, so it leans on an internal Depot route; local evidence
  `sources-v2/db-depot.md`).
- **Direct-connection types:** H2 and DuckDB first, the only ones testable on a laptop (the local engine has no vault
  for credentials). Postgres comes right after, to test a real server database.
- **Direct connection:** the cube saves the full connection (protocol JSON) once, as its model
  (`context.model = {_type: <a Cube-owned kind>, connection}`), and each table stays an ordinary relational source
  (schema and table) on a fixed generated Database path. This amends the first wording, which had the connection saved
  on each source as in the original app: a cube has one connection anyway, the layout is internal, and Cube never reads
  the original's saved queries (§1.2). Cube builds the executable model (a Database with the used tables, the
  connection, a mapping-less runtime) only when it calls the engine, by introspecting just those tables with
  `schemaExploration`. A saved connection holds auth references, never a secret. The saved format stays at version 1:
  the change is additive, and an older reader reports the model kind as unsupported.
- **The source dialog (DP-3, user, 2026-10-09):** two palette items, "Relational Database Table" and "Data Product"
  (beta), open one "Add a source" dialog with three tabs, "Model", "Database connection" and "Data product", each item
  on its own tab; a cube's fixed model selects the tab and disables the others. A host without a connection explorer
  or a data product catalog shows neither that tab nor, for data products, the palette item. Each tab implements one
  small interface (`CubeSourcePickerTab`: availability, busy state, Add, open and close, and whether a cube's fixed
  context is its own).
- **The direct-connection tab:** The form starts with a prefilled H2 sample and has no
  box for pasting connection JSON. Setup SQL and DuckDB file paths are always offered, as Paste Pure model already
  allows: the engine accepts them from any client (hosting.md notes the exposure; revisit with Postgres). Once a table
  is added the connection is fixed until every table is removed (no "Edit connection" until QUESTIONS.md U8 is
  answered); reopening the dialog lists the connection's schemas at once.
- **A CSV as a table (user, 2026-10-09):** on DuckDB, the tab takes a pasted CSV or a chosen file and writes it into the
  setup SQL as a table of the `csv` schema (create, then the rows as inserts), which the viewer can edit before
  testing the connection. Each column's type is guessed from its values; names are made safe for DuckDB. Up to 10,000
  rows: the cube saves them with its connection, and every connection inserts them again (about 120 ms for 10,000 rows
  on the local engine). No file reaches the engine's host.
- **Columns the engine can't type** (it reports them as `Other`: on H2 REAL, TIME, BINARY, CLOB, UUID and arrays; on
  DuckDB HUGEINT, TIME, BLOB, UUID and arrays) are hidden, and the picker shows "N columns hidden".
- **One model context per cube:** the first source fixes it; every other source must come from the same context. For a
  depot project that means the same project at the same version, since the engine keeps the first of two definitions.
- **Databases and data products are kept apart:** a cube uses one or the other, never both (a query has one runtime).
- **Data product list:** the marketplace's search when Query names its server (`marketplace.serverUrl`, the same
  server Legend Marketplace's own `marketplace.url` names; user, 2026-10-09), else the lakehouse contract server's lite
  list, which Cube pages itself with guards. A failed search shows its error with Retry and never falls back to the
  lite list.
- **Warehouse (DP-2, user, 2026-10-09):** the cube's saved warehouse wins, else the viewer's remembered one, else the
  default consumer warehouse (`LAKEHOUSE_CONSUMER_DEFAULT_WH`). The first Add saves the warehouse into the cube and
  remembers it for the viewer's next cubes. A saved cube's warehouse is edited in its Source panel, as one undo step,
  and remembered too (#5652).
- **Data products (user, 2026-10-09):**
  - **Saved shape (DP-1):** a Cube-owned model kind, `context.model = {_type: 'cubeDataProduct', groupId, artifactId,
versionId, environmentType, warehouse?}`, and the fixed runtime path `cube::dataProduct::Runtime`. Each source
    saves its data product, access point group, access point, the catalog's product id and the deployment id. The
    engine adapter builds, per run, `combination[SDLC pointer at the saved version, a model holding only a
LakehouseRuntime at the fixed path]` with the viewer's environment and the warehouse.
  - **Selection (DP-5):** as Data Cube's: a Mode select (Production, Production (parallel)), then a deployed data
    product, then one of its access points, then the warehouse. No development deployments in this phase; a saved
    development cube is refused by name.
  - **Access points:** Lakehouse access points without parameters can be added; parameterized, function, model-group
    and undeployed ones are listed disabled with the reason. Columns come from the deployed artifact's
    `lambdaGenericType`, with no engine call.
  - **One project per cube:** once a cube has a data product source, the tab offers only that project's products at
    that version, and reopens on the cube's data product with its access points shown. Joining access points of one
    project, often two of the same data product, is the demo's case; cross-project joins aren't needed.
  - **Redeploys (DP-4):** nothing this round; a cube stays on its saved version.
  - **The environment:** as Legend Query resolves it (`resolveLakehouseEnvAndWarehouse`): the environment Query
    remembers for the viewer, else the viewer's first entitlement environment. Query adds the production-parallel
    realm (`-pp`) for a snapshot version; Cube also adds it for a production-parallel deployment, as Data Cube does.
    Query's runtime dialog can remember an environment with a realm already on it, so Cube drops that realm first
    and lets the cube's class decide: a production deployment never runs in the production-parallel realm.
  - **Shipped first as a thin end-to-end slice** in the direct connection's PR (user, 2026-10-09), so it can be tested
    inside an internal deployment. Follow-ups, each with its tests (#5652): marketplace search with paging guards and
    stale answers (done), warehouse edits and staleness (done), re-checking saved sources (done), error polish (done),
    sample rows and marketplace links (done), access badges (done), an "In this project" / "Search all" view (done),
    the stand-in engine checks against the test-setup mocks, and their verify workflow.
- **Compute elements:** deferred.
- **M2.0 no longer gates the sources:** depot Databases are typed by the engine through the pointer and data products by
  their deployed artifact, so neither needs legend-graph's precise types. M2.0 stays a separate legend-graph PR, needed
  when Cube types tables locally (§11.3).

### 6.9 Sample data and example cubes (user, 2026-10-09)

To make Cube easy to demo and explore, it ships three small datasets and six example cubes.

- **Datasets:** bundled models in the Sample Data tab: Northwind (loaded by the engine's `loadNorthwindData()`),
  **Sports (sample)** (10 sports, 360 events of 2025 with viewers and attendance) and **Trades
  (sample)** (6 desks, 15 instruments, 400 trades of the first half of 2026). The two samples are made up and seeded,
  so their rows are the same on every load (a cube saves the model text). They load through in-memory H2 setup SQL like
  Northwind's CUBETEST tables, which the deployed engine already runs; the SQL also runs on DuckDB (not used: DuckDB in
  a deployment is untested). Hundreds of rows each, one insert per table, since the setup runs on every connection
  checkout.
- **Sample Data tab** (the Model tab, renamed by the user on 2026-10-10): its Dataset step lists the three datasets,
  named Northwind, Sports and Trades. Northwind is the fixture without Cube's test tables (`CUBE_NORTHWIND_SAMPLE_MODEL`;
  the tests keep `CUBE_NORTHWIND_MODEL`), and a cube saved with the fixture still shows as Northwind. Pasting a Pure
  model is no longer offered in the UI; the tab's state keeps it, and a cube on a pasted or other text model still
  opens, as "The cube's model". The tab opens on the cube's model, else the dataset picked last, else Northwind.
- **The dialog's tabs** (user, 2026-10-10): **Sample Data · Project Database · Direct Connection · Data Product ·
  Ingest**, two tabs for the two relational sources, named for where the tables come from.
- **Example cubes:** two per dataset, built in code (`stores/CubeExamples.ts`), not saved JSON, so they hold no schema
  snapshot to keep current: their tables are typed when they open, like an imported cube's. They use only nodes on
  master (no windows yet): top customers by orders and products in stock by category (Northwind), top watched sports
  and the most watched finals in Europe (Sports), dollar notional by desk and asset class and the largest buys (Trades; notionals are converted to dollars at fixed
  rates, column `NOTIONAL_USD`).
- **Examples dialog** (user, 2026-10-10, replacing an Examples tab in the source dialog): an **Examples** button
  beside **Add table**, and "open an example" on the empty canvas, open one dialog with a grid: a row per dataset, its
  card first (icon, one line, its tables), then its two example cards (icon, one line, the example's steps as node
  icons). An example card opens a new copy of the example in place of the cube, as Import does (one undo step), waits
  for its tables to be typed, then runs it, unless the user has moved off it. A dataset card starts a new cube on its
  model (one undo step) and opens the source dialog's Sample Data tab on it, to add a first table. Both work on a read-only
  cube too, since they replace it with an editable one. Icons: a cart (Northwind), a trophy (Sports, `TrophyIcon`, new
  in legend-art), a bar chart (Trades).
- **Tests:** the core tests cover the list, opening an example, Undo, the no-run case, a read-only cube, starting on a
  dataset and each example's spec round trip (under the 1 MB cap) with the fake engine (`CubeExamplesState.test.ts`,
  `CubeExamplesDialog.test.tsx`) and the generated rows (`CubeSampleModels.test.ts`);
  `CubeExamples.engine-roundtrip-test.ts` compiles the sample models, then opens every example on the engine, checks
  every node types as Cube infers it (Sum outputs wider, §5.7) and runs it.

---

## 7. C. Canvas and editors (slice)

### 7.1 Layout (provisional, D7)

The four regions of §17.1 are kept:

- a **sidebar palette**, collapsible, with state in local storage;
- a **graph toolbar**: name (`Unsaved Query`), **Add Items ▾**, Undo, Show Pure, Export / Import (labelled "(dev)" until 2026-10-10);
- the **canvas**: collapsible, at most 60% of the viewport, with `presentation.showGraph` saved;
- a **grid toolbar** + **results grid**.

The node editor **floats below its node**, as the original's popover does (M3b, U1; §7.4). It replaced M1's side
panel. The click-away's rules and `holdOpen()` handle §17.5's nested-dismiss problem (§7.4).

### 7.2 Canvas

- **Layout pipeline**, recomputed on each change as a pure function of `query`:
  1. Build a dagre `Graph({multigraph: true})` with `rankdir: 'LR'`.
  2. Insert nodes **sorted by id** and edges **sorted by `port + source + target`**, keyed by port (§17.3
     determinism, verified for dagre ✅). Invariant 3 means one node never feeds both ports of a Join (a self-join
     is two source nodes of one table), so the multigraph is a safeguard only.
  3. Run layout and convert centres to top-left (fixes §17.3 pitfall 1).
- **Nodes:**
  - Fixed size (200×72), with the icon and the `describe()` text clamped to two lines; the full text is in the
    tooltip. This avoids measuring, which keeps the layout deterministic.
  - React Flow keys nodes by `id`.
  - `nodesDraggable=false`: the user does not position nodes.
  - Zoom, pan, minimap and fit-view come from xyflow (fixes pitfall 5).
- **Binary ports:** one target handle per `node.ports` entry: Join's `leftTds` (upper) and `rightTds` (lower); a
  plain `BinaryNode` has `tds1`/`tds2`. Edges into binary nodes carry
  **visible** port labels from the node's `portLabels` (fixes pitfall 4): "Left"/"Right" for a Join, "First"/"Second"
  for a Concat (M4, §11.5 Q8). Dagre does not guarantee Left sits above Right, so crossing
  edges are acceptable 📄.
- **Node states:**

| State            | When                                       |
| ---------------- | ------------------------------------------ |
| normal           | valid                                      |
| **selected**     | capture node                               |
| **invalid**      | the node's own errors                      |
| **incomplete**   | `ERR_INCOMPLETE` / `isSchemasError`        |
| **resolving**    | a source schema request is in flight (new) |
| **engine error** | a host issue mapped back by node id (new)  |

The tooltip shows deduplicated errors first, then `describe()`, one per line (§17.3); not the node's id, which is
internal (2026-10-11). On the node itself a source shows its name, with its schema, data product or ingest definition
as a muted second line. It is
legend-art's Tooltip, shown above the node after 500 ms (M3b, U1(c)).

### 7.3 Interactions

All gestures from §17.4 are kept. Since M3b, one placement rule (`CubeAddPlacement.ts`, U3 and U4) covers every add,
and every gesture outside the editor applies it first:

| Gesture                         | Effect                                                                                                           |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| click a node                    | apply the open editor and open this node's, in one click                                                         |
| Ctrl/Cmd-click                  | apply and close the open editor, then make the node the capture node, opening no editor                          |
| click or drag a palette item    | a transform goes after the selected node, spliced in (U3; spec §17.4 says unconnected); a source opens its tab   |
| drag a palette item onto a node | a transform is spliced in after that node; a source opens its dialog tab, and the node ignores it (U4)           |
| drag node A onto node B         | `connect`, else `move`                                                                                           |
| right-click                     | legend-art `ContextMenu`: palette, then Select / Remove / Swap Inputs (show-but-disable); none on an empty query |
| **Add Items ▾** (graph toolbar) | the palette's items, by the same rule; a transform opens its editor (2026-10-11)                                 |
| Ctrl+Z, F9                      | apply the open editor, then undo or execute                                                                      |

A transform added after the selected node becomes the capture node (`Query.add`). A transform with settings opens its editor (2026-10-11, the user, reversing U3's "no add opens
the editor"); one with none, a Distinct, doesn't. A source
appears, unconnected, after the dialog's confirm. The empty canvas's link opens the source dialog with no tab chosen.

- **New:** dragging from an output handle to a specific input handle calls `connect(source, target, port)`.
- **Live drop targets:** a node highlights only when `canConnect || canMove`.
- **Drag and drop wiring:** react-dnd. The canvas drop handler ignores drops already handled by a node
  (`monitor.didDrop()`). Draggable node bodies carry xyflow's `nodrag` class.

### 7.4 Editor shell

- **Host** (M3b, U1; details in §11.8): a non-modal MUI Popper (legend-art `BasePopper`) portalled to the body at
  z-index 1250, below its node and centred, 432px wide. It flips above the node when there is no room below, stays
  inside the window, follows the node through pan and zoom, and hides while the node is out of the canvas. It is
  `role=dialog`, named by its title.
- **Frame:** a fixed title bar; a body of 80px to 33vh, the editor's only scroller; under it the problems strip and the
  footer (none on a source).
- **Header:** the label with each word capitalised, the node id, help text (§17.9), and the Select link or
  "(Selected)".
- **Edits are buffered** locally. **Apply** (or any close but Cancel) emits one replacement node, so there is one undo
  entry per edit (§17.5). Cancel discards. Apply closes the editor (2026-10-11; it used to stay open, applied).
- **If an upstream node is invalid,** the editor shows the upstream error instead of its body (§17.5).
- **Column pickers** only offer columns from the actual input schema(s). Each column shows its type label and
  nullable marker.
- **Following the cube** (built in M1.8b S17, narrowing the user's answer to the panel with edits). Since M3b, user
  actions outside the editor apply it first (below), so this covers re-checks, Import and Undo's own effects:
  - an editor with unapplied edits whose node is replaced or removed underneath (Undo, a re-check, Remove) closes
    without applying them and says so in the graph region;
  - an editor without edits shows the replacing node (e.g. after Refresh, or after Undo of its own Apply), and closes
    silently when the node is gone;
  - Import, and Undo back over an Import, always close it without applying, since another cube can hold a node with
    the same id.
- **Closing applies** (spec §17.5, U2). Every close but Cancel goes through `nodeEditor.finish()`, which blurs the
  focused field, applies a data product's typed warehouse text, then applies the edits as one undo step:

  - the ×, Escape (unless a field inside used it), hiding the graph, and emptying the query;
  - a pointerdown outside the editor, except on a node (its click handles it), on the pane, the controls, the minimap
    or the splitter (a pan, zoom or drag keeps the editor; a plain click on the empty pane closes it), inside a MUI
    layer, a right-click, or while a Cube dialog is open or `holdOpen()` is held;
  - Execute and F9, Undo and Ctrl+Z, Show Pure, Export, a node drag or connect, and every context-menu and Add Items
    action, which apply first and then act.

  Cancel drops the edits. A read-only cube never applies. A dropdown, picker or dialog opened from the editor and
  shown elsewhere must be a MUI layer or call `holdOpen()`, or a press in it closes the editor.

**The editor contract, for a new node type** (M1.8b S17). Every file is in `@finos/legend-cube-builder`:

1. `stores/editors/Cube<Type>Draft.ts`: a `CubeNodeDraft<N>` subclass, a MobX class holding the editor's state.
   - Its `build()` returns the node Apply stores, or `original` itself while nothing was edited.
   - The editor compares the codec encodings (`definition.spec.encode` plus `rest`), so a node that saves the same
     counts as no change and adds no undo step.
   - It is made once per open, again after each Apply or Swap Inputs, and again when a clean editor follows a
     replaced node, so keys made in it (e.g. filter rows) stay stable while the user edits.
2. Register its factory in `CUBE_NODE_DRAFT_FACTORIES` (`stores/editors/CubeNodeDraftRegistry.ts`). A type with
   nothing to edit registers an editor but no factory: it gets a read-only draft, and no Apply or Cancel. That is a
   source, and from M2 a transform with nothing to set (Distinct, whose editor is a description only, spec §17.6),
   which is also listed in `CUBE_NODE_TYPES_WITHOUT_SETTINGS` so the registry test (item 6) doesn't ask it for a
   factory.
3. `components/editors/Cube<Type>Editor.tsx`: an observer component taking `CubeNodeEditorProps`, registered in
   `CUBE_NODE_EDITORS` (`components/editors/CubeNodeEditorRegistry.ts`). It gets:

   - `draft`;
   - `inputSchemas`, in port order and all present: while an input is missing or invalid, the editor shows why
     instead of the editor;
   - `readOnly`;
   - `editorState`, for reads such as the model outline.

   Its edits go to its draft. A button that must change the document calls a `CubeNodeEditorState` method that
   applies the draft first and then rebinds to the new node, as `nodeEditor.swapInputs()` and the Join and Concat
   autofixes (`renameDuplicateColumns`, `renameConcatInput`, `restrictConcatInput`) do: calling
   `editorState.applyQuery` (or `editorState.swapInputs`) directly replaces the node under unapplied edits, so the
   editor closes and drops them. A source, which has no draft, may call an `editorState` flow that stays outside the
   undo history (Refresh). The editor lists the edited node's problems (`node.validate`) under it, and owns Apply
   and Cancel. The editor must fit 432px with the body as its one scroller (no inner height caps), and register a
   field that commits late with `nodeEditor.addFlusher` (the builder's `docs/adding-an-operation.md`).

4. The help text, in `CUBE_NODE_HELP_TEXT` (`__lib__/LegendCubeHelpText.ts`).
5. Its icon name, mapped to an icon in `NODE_ICONS` (`components/CubeNodeIcon.tsx`).
6. A test checks that every registered type has all of these (`CubeNodeEditorRegistry.test.ts`).

The palette, the context menu and the canvas need nothing more: they read the core `NodeRegistry` (label, icon
name, beta).

M4's Group and Concat editors follow this contract (§11.5, Builder): Group's has the keys' multi-select and rows of
aggregation column, function and output name; Concat's states the requirement, compares the inputs column by column,
shows the autofix buttons when they apply, and its draft holds Convert types.

### 7.5 Join editor

- Join type: Inner, Left Outer, Right Outer, Full Outer.
- Paired rows of (left column from the left schema, right column from the right schema), with type labels. An
  incompatible pair is marked inline using the domain's compatibility check.
- Add or remove rows. The add button is disabled once every column is used (§17.6).
- A Swap Inputs button. It applies the editor's edits and swaps, as one undo step (M1.8b S18).
- When the duplicate-column error fires, the editor lists the offending names, with the new names the autofix would
  give and a "Rename them" button (M2, §4.7).
- A pair with no column picked on either side is not stored, as a Filter's blank rows aren't (M1.8b S18).
- A key on a column Cube typed as a bare String (OTHER or ARRAY) shows "type unknown" and is not blocked. The editor
  traces the column back to its table and reads the table's `untypedColumns` in the model outline, which it loads
  when it opens.

### 7.6 Filter editor

The §8.5 tree builder:

- Rows of (column, operator, value), And/Or groups, and a Not toggle.
- The operator list comes from §5.5 for the column's family.
- Value widgets per family (§5.6/§17.7), with multi-value entry for In/NotIn. Values are validated inline and stay
  marked as invalid in both display and edit modes.
- Unsupported constructs render "This filter is not supported yet." (§8.5).
- Blank conditions (no column, no value) are dropped wherever they are when the filter is stored, with the groups
  they leave empty. This extends the user's choice for a tree of only blank rows (M1.8b S19): an added row left
  untouched never becomes an error.
- A date-time input that leaves out zero seconds gets `:00` added, since a date-time literal needs its seconds
  (§5.6).

### 7.7 Source panel

Shows the coordinates read-only, the resolved schema as a table (name, type label, nullable), and **Refresh**, which
re-resolves the schema. A refresh that changes the schema shows a warning listing the drift. Refresh reuses the
import re-check (`reresolveSources` with one source): no undo step, the same node when the columns are the same, and
an earlier warning on that table cleared once it re-checks clean (M1.8b S17).

### 7.8 State (builder, MobX)

`CubeEditorState`:

| Field                                             | Contents                                                                                                                                                                                                          |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `document` (`observable.ref`, immutable)          | the `CubeDocument`                                                                                                                                                                                                |
| `history`                                         | undo: `CubeDocument` snapshots, at most 100; Redo since §11.15 (`redoStack`, cleared by a new change)                                                                                                             |
| `analysis` (`computed`)                           | `buildSchemasAndValidity(document.query, registry.queryRules)`: the query-level rules must be passed, the default is none                                                                                         |
| `hostIssues` (`Map<nodeId, {firstLine, detail}>`) | engine errors mapped back to nodes                                                                                                                                                                                |
| `warnings` (`Map<node key, …>`)                   | non-blocking: schema drift and failed re-checks (Settled before M1.8); keyed by node key, so a warning leaves with its node, and comes back with it on Undo                                                       |
| `pendingSources` (private, `observable.ref`)      | sources sent to the engine to be typed again; read through `isResolvingSources` and `isPendingSource(node)`                                                                                                       |
| `modelOutlines` (private, by model)               | outlines loaded on demand by `loadModelOutline`, read through `modelOutline`, e.g. for the Join's 'type unknown' warning                                                                                          |
| `execution` (`CubeExecutionState`)                | result, stale flag, stats, error                                                                                                                                                                                  |
| `gridFetcher` (`CubeGridFetcher`, M7)             | the server-side levels, derived from the run on screen; an LRU cache of 200,000 rows keyed by the run lambda; a generation number drops purged answers                                                            |
| `grid` (`CubeGridState`, M7)                      | the grid's mode (kept per user), `loadRows` for each grid by its token, drill-down, format and width edits, the open dialogs                                                                                      |
| `export` (`CubeExportState`, M7)                  | the CSV export in flight, Stop, its error                                                                                                                                                                         |
| `runStats` (`CubeRunStatsState`, M7)              | the page's 15 latest runs, fetches, exports, Pivot value loads and JSON samples, with their SQL and lambda, failed ones too; Dev Settings in `devSettings` (§11.16)                                               |
| `nodeEditor` (`CubeNodeEditorState`)              | the open node editor: `nodeId`, `draft`, `notice`; `finish()`, `holdOpen()` and `addFlusher()` (M3b); ephemeral, not saved                                                                                        |
| `jsonViewer` (`CubeJsonViewerState`, §11.20)      | Open JSON's dialog: the column and its cell's text, shown as a tree                                                                                                                                               |
| `jsonExplorer` (`CubeJsonExplorerState`, §11.20)  | Explore JSON's dialog: the column, its sample read through Runs (`readCubeJsonSample`), the field tree, the ticked offers; `add()` adds an Extend, then an Explode Array, as one undo step (`addConfiguredNodes`) |
| `tour` (`CubeTourState`, §11.21)                  | the editor's guided tour: `isOpen`, `index`, `openedExample`; `start()`, `move(by, isShown)`, `close()`; counts as a dialog                                                                                       |
| `isPaletteCollapsed`                              | kept per user in user data, never in the cube                                                                                                                                                                     |

Every edit goes through `applyQuery(next)` (or `applyDocument` for a context change), which pushes onto history.
Import goes through `importDocument`, which pushes history and replaces the document itself, not through
`applyDocument`: it resets the run, the picker, the node editor and the engine errors (warnings stay, keyed by
nodes the imported cube doesn't share), then re-checks the sources. Domain objects are immutable but not all frozen (only `Query`'s arrays are), so they are held as
`observable.ref` and MobX never observes them deeply.

**Settled before M1.8** (user, 2026-10-07; requirements `m18-requirements`). The canvas questions are asked at the
start of M1.8b.

- **Before the canvas (M1.8a):** the graph region shows an interim read-only list of the query's nodes (`describe()`,
  errors, host issues, a capture marker) with a **Select** action. The canvas replaces it in M1.8b.
- **Host:** `CubeHost` gives the page the engine, the model catalog and Query's application store (commands, user
  data, clipboard, layout/theme, notifications, alerts, telemetry), as `QueryBuilderState` holds `applicationStore`.
  The engine config and the catalog stay host choices.
- **Picker order:** model → database → runtime filtered to it → schema → table (§6.2.7).
- **Route:** the Cube page loads lazily (`React.lazy` + `Suspense`), so the canvas stack stays out of Query's main
  bundle.
- **Engine errors:** the first line shows in the grid region (detail expandable) and on the failing node's row (later,
  on the canvas node). They clear on the next Execute and on any change to `document.query`. Picker errors stay in
  the picker and Show Pure errors in its dialog; no toasts.
- **Row limit:** default 1,000, kept in user data. A change marks results stale and doesn't re-run. Input commits on
  blur or Enter; a non-integer or a value below 1 is refused inline and the previous value kept. A soft warning shows
  above 100,000, with no hard cap. Truncation text: "Showing the first <limit> rows; the query returned more."
- **Grid display (slice):** headers show the column name, with the type label, nullable marker and full path in the
  header tooltip. Nulls show a muted `(null)`. Integer and Decimal values show their exact text, right-aligned and
  sorted numerically, with no grouping (formatting is M7). Dates and timestamps show as the engine returns them;
  booleans as true/false. Column widths pass through unchanged until M7. _(2026-10-10, the user, for the demo:
  Decimal and Number values show their thousands grouped, every digit kept; Float values are rounded to 2 places and
  grouped; Integer values stay plain, so ids and years get no commas. Copying a cell copies its exact value, which
  also shows on hover where the two differ; §9.)_ _(M7, §11.10: a number column takes the format saved in
  `meta.presentation.columnHints`, else the display above; widths are read from and written to `columnWidths`, as
  presentation: no undo step, and an undo keeps them.)_
- **Running:** while a query runs, Execute becomes **Stop**, which aborts it; leaving the page or importing aborts it
  too. Only the latest run's result is applied.
- **Telemetry:** the host passes telemetry through; Cube sends no events in M1.8. Events are designed with M3's entry
  points, with redaction from day one.
- **Undo:** `CubeDocument` snapshots, at most 100. Import is one undo step. Source re-resolution never pushes. No
  redo in the slice. A restored query is a new object (§4.3); an edit that left the query alone, such as a
  rename, keeps it, with its rows and engine errors (S6, 2026-10-07).
- **Export/Import spec:** always visible, labelled "(dev)" _(dropped by the user on 2026-10-10: Export and Import)_;
  it is the only way to save until M8. Import asks no
  confirmation (it can be undone) and never executes; Part B step 8 reads "import it, press F9". The check is that
  `serializeCubeSpec` gives the same text before and after, since nodes get fresh keys on decode.
- **Re-checking tables on import:** a source that fails to re-resolve keeps its saved snapshot and gets a warning
  ("could not re-check this table: …"). Schema drift shows as a non-blocking warning listing the changed columns. The
  answer applies, by node identity, to the cube shown and to the undo snapshots taken while it was pending, so Undo
  never brings back an unchecked table; "resolving source" shows while the cube shown holds a table being typed
  (`m18a-verify` fix, 2026-10-07). A data product cube's access points are re-checked the same way, through the
  catalog against the deployed artifact at the cube's saved version, with no engine call; their warnings name the
  access point (DP14, 2026-10-09).
- **Newer-version spec:** opens read-only with a banner. View, Execute and Show Pure work; edits, the picker, Undo
  and Export are disabled. Select (choosing the node to run) still works, and Import replaces the cube (S7, 2026-10-07).
- **Spec without a model or runtime:** opens editable with Execute disabled and a tooltip naming what is missing; no
  fix-up UI in M1.8.
- **Show Pure:** its own dialog with Copy, showing the engine's rendering of what Execute runs, row limit and
  literals included (e.g. `->limit(1001)`). _(2026-10-10, the user: the row limit is left out, as Legend Query's own
  Show Pure does. It is how the grid runs the query, not part of it; a Take or Drop the user added still shows.)_ `renderPure` sends the lambda as lossless text (the "numbers as 0" bug,
  fixed in `dcaf0efdb`). The same dialog shows a run from the Runs panel as it ran, its row limit included, titled
  "Pure query of <run>" (§11.16).
- **Technical (decided without asking):** Query tests use a local fake engine (no `./test` export from the builder
  yet); the core gains small host-free helpers the UI needs (re-reading filter values against a schema, a schema
  diff, the display name of a table, the reason a capture subtree can't emit).

**Settled at the start of M1.8b** (user, 2026-10-07; all on the requirements' recommendation):

- **Palette source item:** "Relational Database Table" opens the source picker when clicked or dropped on empty
  canvas. It can't be dropped onto a node: palette drops onto nodes use `canAdd`, and sources have no ports. The
  palette and the context menu stay one list built from the registry. _(M3b, 2026-10-10: U4 changed this. Dropped
  onto a node, or picked from a node's menu, a source now opens its tab too, and the node ignores it. 'Add Items ▾'
  shares the list.)_
- **Removing the last source:** when a Remove leaves the query empty, the same undo entry clears the context (model
  and runtime), so the next pick starts fresh.
- **Filter Apply with only blank rows:** an edited tree of untouched blank rows stores no filter. It counts as no
  change: no undo entry, and the node keeps "Filter cannot be empty."
- **A side-panel edit whose node changed underneath:** the panel records the key of the node it opened from. If that
  node is gone or its key changed (Undo, Remove, Import, re-check), the panel closes, discards its buffer and shows
  a short notice. The question was about a panel with unapplied edits; a panel without edits follows the node
  instead (§7.4). _(M3b, 2026-10-10: the panel is now the floating editor, and the rule holds for it. User actions
  outside it apply it first, so this covers Undo's own effects, re-checks and Import.)_
- **Shortcuts while a Cube dialog is open:** Cube's command triggers return false while any Cube dialog is open
  (picker, Import, Show Pure). Picker and Import results are also re-checked when they are applied.
- **Join "type unknown" warning:** `CubeOutlineTable` gains `untypedColumns`, filled in `V1_CubeModelOutlineBuilder`
  (a port and `v1/` change, no core or saved-format change). The Join editor reads the outline of the document's
  model, loaded through the catalog when needed, once per model.

---

## 8. D. Execution

### 8.1 Decision: relation-function chain (D9)

| Criterion                     | (a) Relation-function chain                     | (b) Legend SQL                                                                                                                         |
| ----------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Spec coverage                 | All 14 transforms (Difference emulated) ✅      | Same in theory; `EXCEPT`/`INTERSECT` unsupported ✅                                                                                    |
| Inline / unpublished model    | ✅                                              | REST `/api/sql/v1/*` requires depot or SDLC coordinates ✅. Only the `#SQL{}#` island takes an inline model, and only **as a leaf** ✅ |
| Precise types                 | Kept; casts keep parameters                     | SQL `CAST` collapses to base types ✅                                                                                                  |
| Errors mapped to canvas nodes | `sourceInformation.sourceId` echoed per node ✅ | No `sourceInformation` ✅                                                                                                              |
| Compile latency               | 15–23 ms                                        | 2.5–3× slower ✅                                                                                                                       |
| Generating from a DAG         | One function call per node, as structured JSON  | Text emitter, quoting, aliasing                                                                                                        |
| Precedent                     | Legend Data Cube                                | Studio SQL playgrounds only                                                                                                            |

Legend SQL stays useful as a future optional "SQL source" leaf node.

### 8.2 Pipeline

1. Take the capture node's upstream subtree. It is a tree, because invariant 3 forbids fan-out.
2. Each node's `emit(node, inputExprs)` builds the **Cube IR**.
3. Wrap the result: `capture->limit(rowLimit + 1)->from(runtime)`. When the rows reach the capture node in an order,
   sort by it first: `capture->sort(<capture order>)->limit(rowLimit + 1)->from(runtime)`, so the rows shown are in it
   (M2, §11.4).
4. The builder's `v1/` serializer turns IR into protocol JSON and stamps `sourceInformation` on every node.
5. Execute: `{clientVersion:'vX_X_X', context:{_type:'BaseExecutionContext'}, function, model}`.

**Per-node schema conformance** (tests, and later engine-typed nodes) sends one prefix lambda per node (no `from()`)
to the batch endpoint.

### 8.3 Cube IR (core, host-free)

```ts
type IR =
  | { k: 'func'; name: string; params: IR[]; origin?: Origin }
  | { k: 'property'; name: string; receiver: IR; origin?: Origin }
  | { k: 'var'; name: string }
  | { k: 'lambda'; params: string[]; body: IR[] }
  | { k: 'literal'; value: LiteralValue; origin?: Origin }
  | { k: 'collection'; values: IR[] }
  | { k: 'colSpec'; name: string; fn1?: IR; fn2?: IR }
  | { k: 'colSpecArray'; specs: IR[] }
  | { k: 'storeAccessor'; path: [string, string, string]; origin?: Origin }
  | { k: 'elementPtr'; path: string } // the runtime only (Settled in M1.5) — never for types
  | { k: 'genericType'; path: string; params?: number[] } // type argument, e.g. of cast: genericTypeInstance
  | { k: 'enumValue'; enumPath: string; value: string; origin?: Origin } // JoinKind and enumeration values
  | { k: 'let'; name: string; value: IR; origin?: Origin } // §8.6; no 'block': a block is a lambda with several statements
  | { k: 'raw'; json: unknown }; // escape hatch (Extend expressions)
type Origin = { nodeId: string; role: string };
```

- The core also has a **debug printer** that renders IR as fully parenthesized Pure-like text, for snapshot tests
  and logs. It is never sent to the engine.
- **Why JSON and not text:** Pure grammar has precedence traps. `a > 1 && b < 3` parses as `(a > 1 && b) < 3`, and
  prefix `!` binds tighter than `>` ✅. Text also mangles dotted names (§6.2.1).

### 8.4 Emission rules (slice)

**Source.** `storeAccessor([database, schema, table])` becomes `{_type:'classInstance', type:'>', value:{path:[…]}}`.

**Filter.** `->filter({row | <expr>})`. Variable names are fixed (`row`; `l`/`r` in join conditions), so they never
collide with column names, which are always accessed as properties.

| Operator                                          | Emitted                                                                                                                                        | SQL behaviour ✅                                                                                                                            |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Equal / GreaterThan / GTE / LT / LTE              | `equal`, `greaterThan`, `greaterThanEqual`, `lessThan`, `lessThanEqual`(`$row.c`, lit)                                                         | positive comparisons exclude NULLs                                                                                                          |
| StartsWith / EndsWith / Contains                  | `startsWith`, `endsWith`, `contains`(`$row.c`, string)                                                                                         | `LIKE`, with `%`, `_` and quotes escaped ✅; **not** `\`, so a value with a backslash is refused (below), nor on DuckDB `%` and `_` (below) |
| In                                                | `in($row.c, [lits])`                                                                                                                           | `IN (…)`                                                                                                                                    |
| IsEmpty                                           | `isEmpty($row.c)`                                                                                                                              | `IS NULL`                                                                                                                                   |
| NotEqual, DoesNotStartWith/EndWith/Contain, NotIn | `not(<positive>)`; on a column Cube infers nullable, `isEmpty($row.c) \|\| not(<positive>)`                                                    | **includes NULLs** (D4): `c IS NULL OR NOT …`                                                                                               |
| IsNotEmpty                                        | `not(isEmpty($row.c))`                                                                                                                         | `IS NOT NULL` (excludes NULLs, by definition)                                                                                               |
| And / Or                                          | `and` / `or` (binary, folded left)                                                                                                             | –                                                                                                                                           |
| Not over a group                                  | **pushed down to the leaves (De Morgan)**: `not(and(a,b))` → `or(not a, not b)`; `not(or(a,b))` → `and(not a, not b)`; double negation cancels | keeps D4 for groups too                                                                                                                     |

- **Not over a group is never emitted as such.** The engine renders `not(<and/or>)` as SQL `NOT (… OR …)`, which
  drops NULL rows, contradicting D4 ✅. On ORDERS, `!(SHIP_REGION == 'BC' || SHIP_COUNTRY == 'France')` returns 306
  rows; pushed to the leaves it returns 736 = 830 − 17 − 77, the D4 answer ✅.
- A leaf negation of an operator without a negation pair (e.g. `!(x > 5)`) stays `not(<leaf>)`, never the opposite
  comparison (`<=` drops NULL rows).
- **Every negation of a column Cube infers nullable is `isEmpty($row.c) || not(<positive>)`** (M1.5 verification). The
  engine makes `not(…)` NULL-inclusive (`not (x is not null and x > 5)`) only for a column it types `[0..1]` itself,
  and an outer join does not widen its multiplicity (Appendix B). So after an outer join, `!($row.c->contains('a'))`
  on a column NOT NULL in its table became `not c like '%a%'` and dropped every NULL-padded row ✅. With the guard:
  ORDERS ⟕ CUSTOMERS[France], `COMPANY_NAME` DoesNotContain `'a'` returns 768 (was 15); CUSTOMERS ⟕ ORDERS,
  `Not(ORDER_ID > 10500)` returns 255 (was 253); a FULL merged key DoesNotStartWith `'W'` returns 843 (was 336); each
  matches an independent computation ✅. On columns the database declares nullable the guard is redundant and
  harmless (same rows) ✅. NotEqual and NotIn get it too, so Cube does not depend on the engine's `IS DISTINCT FROM`
  (D8). IsNotEmpty is the one negation without it: it excludes NULLs by definition.
- The saved filter shape (§10.3) is unchanged; the push-down is an emitter rule.
- `toOne()` is never inserted in filters: it breaks the NULL behaviour of negations ✅.

**Join.** Algorithm for the inputs `L`, `R`, pairs `(lk_i, rk_i)` and join type `T`:

1. `same = { n | lk_i == rk_i == n }`: the positionally identical keys. The duplicate rule guarantees every other
   shared name is already an error.
2. For each `n` in `same`, rename the side whose value is discarded to a temporary name:

   - INNER / LEFT: the right side's `n` becomes `n__cube_r`;
   - RIGHT: the left side's `n` becomes `n__cube_l`;
   - FULL: both.

   Temporary names must be unique against both schemas, adding a numeric suffix if needed. Each rename is one
   chained `->rename(~old, ~'new')`; the array form returns HTTP 500 ✅.

3. Build the condition: `and(equal($l.lk'_1, $r.rk'_1), …)`. **If Cube infers both keys nullable, wrap the left key
   in `->toOne()`.** That makes the SQL a plain `=`, so NULL keys don't match (D4). The engine default would be
   `IS NOT DISTINCT FROM`.
   - Verified for INNER, LEFT, RIGHT and FULL ✅. On `EMPLOYEES.REGION` ⋈ `CUSTOMERS.REGION`, both nullable, the
     result rows exactly equal an independently computed SQL-semantics join: 15 / 19 / 103 / 107 rows. Plain `==`
     adds 240 NULL×NULL pairs.
   - Plan generation for Postgres, Snowflake and SQL Server also emits a plain `=` ✅.
4. `->join(R', meta::pure::functions::relation::JoinKind.<INNER|LEFT|RIGHT|FULL>, {l, r | cond})`. Each node gets
   its join kind ✅. FULL is native everywhere except H2, where the engine emulates it ✅.
5. FULL only: `->extend(~[n: x | $x.'n__cube_l'->coalesce($x.'n__cube_r')])`. **When the two key types differ,
   append `->cast(@<common ancestor>)`** (§4.7) ✅:
   - **Parameterized types differing only in parameters** (`Varchar(15)` vs `Varchar(2)`, `Numeric(10,2)` vs
     `Numeric(12,4)`): without the cast, the extend types the key as a parameterless `Varchar`/`Numeric`. The
     mandatory trailing `select`, or any later operator, then fails to compile ("Wrong type variables count (0) for
     type: Varchar(x:Integer)") ✅. `cast(@String)` / `cast(@Decimal)` compiles, a downstream filter works, and the
     rows match an independent SQL-semantics FULL join (134 rows).
   - The cast's argument is a `genericType` IR node, serialized as `genericTypeInstance`. A `packageableElementPtr`
     compiles but silently types the result as `PrimitiveType` ✅.
   - **Other differing types:** the engine already unifies them (`Int`+`SmallInt` → `Integer`, `SmallInt`+`Double` /
     `SmallInt`+`Numeric` → `Number`), so the cast is harmless.
   - **Equal types:** keep their type (`Varchar(15)`), so no cast is emitted.
6. Always finish with `->select(~[<§7.11 order>])`. This orders the columns and drops the temporary columns ✅.

**Literals.** Typed by the column family (§5.8) and serialized losslessly: integer and decimal values are written as
raw numeric tokens from strings.

Settled in M1.5 (the plan leaves these open; the user confirmed them on 2026-10-06, with the D4 wording correction):

- **IR as built** (`src/ir/`): the §8.3 union, with `origin` on `func`, `property`, `literal`, `enumValue` and
  `storeAccessor` only; the serializer (M1.7) stamps the other kinds from their nearest ancestor. `RelationExpr` is an
  alias of the IR. Join kinds and enumeration values are both `enumValue` (one serializer rule); `elementPtr` is only
  the runtime.
- **Emitters** are on the registry's definitions (`emit(node, inputs, context)`, context = input schemas in port
  order and the node's own schema). `QueryEmitter` runs inference with the registry's query rules, emits a node's
  upstream tree (`emitRelation`), a typing lambda `{| <relation>}` and the execution lambda
  `{| <relation>->limit(rowLimit + 1)->from(runtime)}` (from M2, sorted first by the capture's row order: "Row order"
  below). The limit is one computed integer literal; `rowLimit` must be
  a whole number ≥ 1 and the runtime is required. `canEmit` and `emitRelation` share one check: a node can be emitted
  when it and every node upstream of it are valid and of a registered type; otherwise `emitRelation` throws the
  reason.
- **Join:** temporary names `<n>__cube_l` / `<n>__cube_r`, then `…2`, `…3` until no column of either input (or an
  earlier temporary name) has it in any case (`foldColumnName`, M2.16); renames chain in key order; key pairs fold
  left with `and`; one `extend` merges every same-named key of a FULL join, with the variable `x`; the cast's type is the merged type's `path` and
  `params`. The emitter checks that the renamed inputs share no name and that the `select` equals the join's
  inferred schema, so a mismatch fails in Cube, not as an engine HTTP 500.
- **Filter:** variable `row`; Not over a group is pushed to the leaves by recursion (a negation flips And/Or, takes a
  paired operator, or stays `not(…)` on an unpaired one); every negation but IsNotEmpty on a nullable column is
  guarded with `isEmpty` (above); a group of one rule emits that rule; enumeration values take their path from the
  column type.
- **No backslash in a LIKE pattern** (re-verification, 2026-10-06): the engine escapes `%` and `_` in the patterns of
  `startsWith`, `endsWith` and `contains` but not the escape character `\` (Appendix B), so on H2 `StartsWith 'CORP\'`
  matched 0 of 11 rows and `StartsWith 'Vin\s'` matched `Vins…`, negations included. No LIKE-free form works on the
  engine today (`substring` does not type-check on `[0..1]`, `indexOf` has no SQL), and pre-escaping in Cube would
  double-escape once the engine is fixed (D8). So the filter **refuses** a backslash in a StartsWith, EndsWith or
  Contains value (and their negations): the rule is invalid with "Filter values for "…" cannot contain a backslash
  (\) yet.", and the emitter throws if it ever sees one. Equal and In keep backslashes. Lift this when the engine is
  fixed.
- **No `%` or `_` in a LIKE pattern on DuckDB** (M6b, 2026-10-11): the engine writes no `ESCAPE` clause either, and
  DuckDB has no default escape character, so `Contains '%'` becomes `like '%\%%'`, a backslash then any text: on a DuckDB
  direct connection it returned the row `a\b`, not `a%b` ✅ (ISSUES.md). For a cube whose database is DuckDB, both
  filters refuse `%` and `_` in a StartsWith, EndsWith or Contains value (and their negations) and in a `startsWith`,
  `endsWith` or `contains` literal, saying why:
  `On DuckDB, filter values for "contains" cannot contain % or _ yet: DuckDB would match them as wildcards, …`. The
  database is the type of the runtime's connections to the databases the node reads, from the model's outline
  (`getRunDatabaseType`, §11.4), so the rule is the editor's (`getNodeDatabaseProblems`, a query rule in `analysis` and
  the editor panel's problems), over core's `getDatabaseProblems`. The editor loads the outline once a filter gives a
  pattern `%` or `_`, `needsDatabaseType` makes a run load it, and the emitter throws for DuckDB as a backstop, so a
  run started before the outline arrived is refused too. Every other database, and a cube whose outline doesn't load
  or doesn't name one type, keeps them. A plan-only engine test pins the missing `ESCAPE` clause on H2 and DuckDB, so
  it fails when the engine is fixed.
- **Origin roles:** `accessor`; for a join `rename`, `join` (also its join kind), `condition`, `key`, `toOne`,
  `merge`, `mergeKey`, `coalesce`, `cast`, `select`; for a filter `filter`, `predicate` (including the `isEmpty` and
  `or` of a guard), `column`, `value` (literals and enumeration values); `limit` and `from` carry the capture node's
  id.
- **Debug printer:** valid Pure text, checked against the local engine (2026-10-06): the slice text parsed and returned
  the 19 rows; RIGHT, FULL (with a Not pushed down: 736 orders + the 2 customers without orders) and two-key LEFT
  ran too. Operators are infix with operator operands in parentheses, `!(…)` always parenthesized, everything else
  an arrow call; a body of several statements ends each with `;` (`{| 1; 2;}`: Pure rejects `{| 1; 2}`); names and
  enumeration values are single-quoted when not identifiers, and so are `true`, `false` and the constraint keywords
  `owner`, `externalId`, `function`, `message` and `enforcementLevel` (Pure lexes `~owner:` as one token), which
  Pure does not read as names; strings and quoted names escape `\`, `'`, newline, carriage return and tab (Pure rejects a
  raw line break in a string); floats always have a decimal point (Pure reads `1e3` as an element name), decimals end
  in `D`, dates start with `%`. Three known differences from the JSON, for M1.7's golden comparison: a negative number
  prints as `-3`, which Pure parses as `minus(3)`; a quoted dotted table name (`"a.b"`) prints as stored, which
  Pure splits into two segments; and `-9223372036854775808` (the smallest long) does not parse at all, as with the
  engine's own composer. A redact mode prints every literal, and every enumeration value with the `value`
  role, as `?`.
- **Not reachable from relational tables:** the FULL merged-key casts to `Date` (StrictDate with Date) and to
  `DateTime` (Timestamp with DateTime). Relational columns type as `StrictDate` and `Timestamp` (§5.1), and §5.4
  rejects StrictDate ⋈ Timestamp. M1.7 checks their shape on the engine with hand-built IR; the end-to-end check
  moves to M6, when Extend can produce these types (user, 2026-10-06). Planning verified the Varchar, Numeric and
  numeric ones.

**Row order (M2, §11.4).** A Sort emits nothing where it stands. Cube tracks the row order through the graph
(`computeRowOrders`; keys follow Renames) and writes `->sort(<keys>)` where the order is used: just before a Limit, Drop
or Slice that takes rows by it (role `sort` on that node), and, when the rows reach the capture node in an order,
before the run's limit (role `captureSort` on the capture node):
`{| <relation>->sort(<capture order>)->limit(rowLimit + 1)->from(runtime)}`. Each key is `~c->ascending()` or
`~c->descending()`, stamped `sortKey` on the Sort that declared it; one key is written bare, several as a list. Typing
lambdas carry no sort. Some databases get Drop, Slice, Limit and Distinct written another way (§8.8, database
workarounds).

### 8.5 Verified Northwind example (the slice's shape)

This lambda was run on the live engine just now. The Cube emitter must produce the equivalent protocol JSON, with
`row` instead of `x` and `sourceInformation` stamps:

```
|#>{showcase::northwind::store::NorthwindDatabase.NORTHWIND.ORDERS}#
  ->join(
      #>{showcase::northwind::store::NorthwindDatabase.NORTHWIND.CUSTOMERS}#->rename(~CUSTOMER_ID, ~'CUSTOMER_ID__cube_r'),
      meta::pure::functions::relation::JoinKind.INNER,
      {l, r | $l.CUSTOMER_ID == $r.'CUSTOMER_ID__cube_r'})
  ->select(~[CUSTOMER_ID, ORDER_ID, EMPLOYEE_ID, ORDER_DATE, REQUIRED_DATE, SHIPPED_DATE, SHIP_VIA, FREIGHT, SHIP_NAME,
             SHIP_ADDRESS, SHIP_CITY, SHIP_REGION, SHIP_POSTAL_CODE, SHIP_COUNTRY,
             COMPANY_NAME, CONTACT_NAME, CONTACT_TITLE, ADDRESS, CITY, REGION, POSTAL_CODE, COUNTRY, PHONE, FAX])
  ->filter(x | ($x.SHIP_COUNTRY == 'France') && ($x.ORDER_DATE >= %1997-01-01) && ($x.EMPLOYEE_ID->in([1, 4])))
  ->from(showcase::northwind::mapping::StoreRuntime)
```

- **Result:** 19 rows, as a single SQL statement:
  `… inner join … on ("orders_1"."CUSTOMER_ID" = "customers_0"."CUSTOMER_ID__cube_r") … where "SHIP_COUNTRY" = 'France'
and "ORDER_DATE" is not null and "ORDER_DATE" >= DATE'1997-01-01' and "EMPLOYEE_ID" in (1, 4)`.
  The 19 `ORDER_ID`s are {10454, 10459, 10470, 10493, 10511, 10525, 10546, 10584, 10628, 10634, 10671, 10755, 10789,
  10827, 10843, 10850, 10927, 10972, 11076} ✅.
  - Their **order depends on H2's plan**: INNER starts at 10584, while the same query with LEFT starts at 10454 ✅.
  - So tests compare row sets until Sort lands (M2).
- **Relation type** (with the shared model): 24 columns in exact §7.11 order ✅:

  - from ORDERS: `CUSTOMER_ID Varchar(5)[0..1]`, `ORDER_ID SmallInt[1]`, `EMPLOYEE_ID SmallInt[0..1]`,
    `ORDER_DATE StrictDate[0..1]`, `REQUIRED_DATE StrictDate[0..1]`, `SHIPPED_DATE StrictDate[0..1]`,
    `SHIP_VIA SmallInt[0..1]`, `FREIGHT String[0..1]`, `SHIP_NAME Varchar(40)[0..1]`, `SHIP_ADDRESS Varchar(60)[0..1]`,
    `SHIP_CITY Varchar(15)[0..1]`, `SHIP_REGION Varchar(15)[0..1]`, `SHIP_POSTAL_CODE Varchar(10)[0..1]`,
    `SHIP_COUNTRY Varchar(15)[0..1]`;
  - from CUSTOMERS: `COMPANY_NAME Varchar(40)[1]`, `CONTACT_NAME Varchar(30)[0..1]`,
    `CONTACT_TITLE Varchar(30)[0..1]`, `ADDRESS Varchar(60)[0..1]`, `CITY Varchar(15)[0..1]`,
    `REGION Varchar(15)[0..1]`, `POSTAL_CODE Varchar(10)[0..1]`, `COUNTRY Varchar(15)[0..1]`,
    `PHONE Varchar(24)[0..1]`, `FAX Varchar(24)[0..1]`.

  With the Cube fixture, `FREIGHT` becomes `Double[0..1]`.

- **Protocol shapes the serializer must produce:**

  - accessor: `{"_type":"classInstance","type":">","value":{"path":["showcase::northwind::store::NorthwindDatabase",
"NORTHWIND","ORDERS"]}}`
  - column name: `{"_type":"classInstance","type":"colSpec","value":{"name":"CUSTOMER_ID"}}`
  - column list: `{"_type":"classInstance","type":"colSpecArray","value":{"colSpecs":[{"name":…},…]}}`
  - join kind: `{"_type":"property","property":"INNER","parameters":[{"_type":"packageableElementPtr","fullPath":
"meta::pure::functions::relation::JoinKind"}]}`
  - lambdas: `{"_type":"lambda","parameters":[{"_type":"var","name":"l"},…],"body":[…]}`
  - literals: `{"_type":"strictDate","value":"1997-01-01"}`, `{"_type":"integer","value":1}`
  - collection (the In list): `{"_type":"collection","multiplicity":{"lowerBound":n,"upperBound":n},"values":[…]}`.
    The engine's parser always writes `multiplicity`; execution works without it ✅. The serializer emits it, so
    golden comparisons are exact.
  - cast type argument: `{"_type":"genericTypeInstance","genericType":{"rawType":{"_type":"packageableType",
"fullPath":"String"},"typeArguments":[],"multiplicityArguments":[],"typeVariableValues":[]}}`. Precise targets carry
    `typeVariableValues`, e.g. `[{"_type":"integer","value":30}]` for `Varchar(30)` ✅.
  - runtime: a `packageableElementPtr` as the second parameter of `from`.

  M1.7 locks these in with golden tests against the engine's own parse of the Pure text. The comparison strips
  `sourceInformation`, and the golden text includes the capture wrapper (`->limit(rowLimit + 1)`).

- **Slice acceptance variants** (all ✅ with the shared model):
  - France only → 77 rows.
  - LEFT (`CUSTOMERS ⟕ ORDERS`) shows unmatched FISSA and PARIS with null order columns. Cube marks those columns
    nullable; the engine's relation type wrongly says `[1]`.
  - RIGHT (`ORDERS ⟖ CUSTOMERS`) returns 832 rows. FISSA and PARIS have `CUSTOMER_ID` from the right side and a
    NULL `ORDER_ID`.
  - FULL (`CUSTOMERS[COUNTRY == 'France'] ⟗ ORDERS[SHIP_COUNTRY == 'Germany']`, coalesced key) returns 133 rows: 11
    customer-only, 122 order-only, and no NULL merged key.

### 8.6 Window isolation (built in M5)

A single-form window extend (`~c: …`) followed by a filter on an input column puts the predicate into the window's own
`WHERE`, **before** the window, on all 17 database types with windows ✅ (plans). The result is wrong rows: France's
count came back as 2 instead of 77 ✅. A filter that names the window column is written as QUALIFY on 4 types (H2,
Snowflake, Databricks, DuckDB), refused on 6 (Postgres, MemSQL, Redshift, Hive, and Spanner and Composite, which refuse
any window anyway) and **silently dropped**, every row returned, on 9: SQL Server, Sybase, Sybase IQ, DB2, Oracle,
Trino, BigQuery, Athena and ClickHouse ✅ (plans, M5.10). The array form plans both filters after the window on all
17 ✅. The rule (M5, §11.6):

- Emit window functions in array form (`~[…]`).
- Ranking columns and aggregate columns go in separate `extend`s, aggregates first; a `select(~[…])` restores the
  listed order when it differs (§8.8). One shared extend throws a ClassCastException ✅.
- Bind every isolation boundary that isn't the capture with a `let`, after the lets it reads, and read it by name. A
  boundary is a node whose `TransformDefinition` sets `isolationBoundary`: only Partition today. A node feeds at most
  one other (`Query.ts`), so each is bound once. A let is named `n_<id>`, lowercased, when the lowercased id matches
  `[a-z0-9_]{1,28}` and no other let has that name, else `n_<k>` (`nameLet`); an id like `a-b` types but breaks H2's
  SQL ✅. No lambda parameter Cube writes starts with `n_`.
- With lets, the run lambda is `{| {| <lets>; <relation>}->from(runtime)->sort(<capture order>)->limit(rowLimit + 1)}`:
  one `from()`, after the block, so **one runtime per query** in this form. A `from()` inside a `let` fails ✅. The
  capture's sort and limit follow `from()`: inside the block, the ORDER BY ends in a subselect (Sybase IQ numbers the
  rows itself, and SQL Server's outer select has no order ✅); after it, every window database writes a `WITH` and puts
  the ORDER BY at the root ✅. Without lets the run lambda is unchanged (§8.2).
- M2's row numbers (`RowNumberEmitter.ts`, §8.8) are array-form windows, not boundaries, so they are never bound. The
  plan-only test checks that every Drop-workaround database keeps the `cube_rn` predicate before and after a
  Partition, with no QUALIFY ✅ (plans).
- Never a `let` after a Sort, by construction: a Sort writes nothing where it stands (§8.8), and lets bind only
  boundaries. An `ORDER BY` inside a CTE is lost, and SQL Server rejects it ✅📄. A sorted Limit still writes its
  ORDER BY with its TOP: inside the let's CTE for Sort → Limit → Partition, after it for Sort → Partition → Limit,
  pinned on SQL Server and Sybase ✅ (plans).

A 156-pair / 92-triple regression matrix with a JS reference gave 0 failures with this rule ✅. M5's composition suite
(`CubeWindowComposition.engine-roundtrip-test.ts`) runs 29 Cube graphs, pairs and triples with a Partition, on H2
against a JS reference; its negative control writes the France case in the single form, unbound, and gets 2, not 77 ✅.
Typing lambdas stay plain chains: the engine types a node the same in both forms (8 of 8 💭, and ✅ in M5.3's adapter
test). Only the run lambda and Show Pure carry lets. That test (`CubeWindowIsolation.engine-roundtrip-test.ts`, through
`V1_LegendCubeEngine`) types, runs and renders the let form, and puts an error inside a let on the window it binds ✅.

### 8.7 Engine port and Legend adapter (builder)

```ts
// `model` is the document's saved model context (§6.2.2), sent as it is
interface CubeEngine {
  loadModel(model: ModelContext): Promise<CubeModelOutline>; // parsed once: databases and runtimes as plain data
  resolveSchemas(
    model: ModelContext,
    accessors: ReadonlyMap<NodeId, AccessorPath>,
  ): Promise<Map<NodeId, Schema | CubeEngineError>>;
  typeLambdas(
    model: ModelContext,
    lambdas: ReadonlyMap<NodeId, IR>,
  ): Promise<Map<NodeId, Schema | CubeEngineError>>;
  execute(
    model: ModelContext,
    lambda: IR,
    options?: { abortController?: AbortController }, // the client takes an AbortController, not a signal
  ): Promise<CubeResult>; // {columns, rows, sql[], durationMs}
  renderPure(lambda: IR): Promise<string>; // JSONToGrammar PRETTY, display only
}
```

`V1_LegendCubeEngine` (under `v1/`) implements it with
[V1_EngineServerClient](../../../packages/legend-graph/src/graph-manager/protocol/pure/v1/engine/V1_EngineServerClient.ts):

| Port method  | Client call(s)                                                                                                                                                                             | Line   |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
| `loadModel`  | `grammarToJSON_model`                                                                                                                                                                      | `:405` |
| typing       | `batchLambdasRelationType`, reading `result ?? results` and deserializing each with `V1_relationTypeModelSchema`                                                                           | `:820` |
| `execute`    | `runQuery(body, { returnAsResponse: true, abortController })`, then `parseLosslessJSON(await response.text())`. Without `returnAsResponse` the client ends in a lossy `response.json()` 📄 | `:856` |
| `renderPure` | `JSONToGrammar_lambda`                                                                                                                                                                     | `:565` |

- **Execution results** are parsed **losslessly** (raw tokens keep their text, e.g. `12.30` ✅). Each cell's JS type
  is decided **per column** from the type the result builder gives (`builder.columns[].type` ✅): Integer and
  Decimal family values are their exact text (`"9007199254740993"`, `"12.30"`, `"5"`), Float family values are
  numbers, dates are strings and booleans are booleans (Settled before M1.7). SQL comes from `activities[]` where `_type == 'relational'`. A 200 response with an unparseable body is reported as
  an execution error, because the engine can stream a stack trace into a 200 ✅.
- **Request bodies** that carry integer or decimal literals are produced with `stringifyLosslessJSON` and passed as
  a pre-stringified string, cast to the client's `PlainObject` parameter type. The client sends string bodies
  verbatim 📄.
- **Error mapping:** every emitted node gets `sourceInformation.sourceId = "cube:<nodeId>:<role>"`, including the
  accessor's `value` object. The engine echoes the **innermost** failing node's stamp in compile errors, including
  per-key in batch ✅. The adapter turns these into `CubeEngineError`s by node id (Settled before M1.7), which M1.8a
  turns into host issues; the first line goes on the node and the full text in the panel. Plan-time and database errors carry no location, so they attach to the capture node.
- **Graph-manager wrappers are bypassed** for typing on purpose: they drop parameters (and the batch wrapper threw
  until #5593) (D8). A tracer service must be set on the client, or every call throws 📄.

**Settled before M1.7** (user, 2026-10-06; requirements `m17-requirements`). Engine facts probed the same day (with probe
scripts kept outside the repo): a table with a `BINARY` column fails **alone** in the batch call, the other keys still type ✅;
`compilation/compile` accepts a `text` context ✅; `execute` returns each column's type in `builder.columns` ✅.

- **Fixture:** the extra `CUBETEST` tables of §6.2.4.
- **Date/DateTime casts:** a shape check now, the end-to-end check in M6 (§8.4).
- **Numbers in results:** by type family, per column (above).
- **Row limit:** the adapter returns every row it receives (up to limit + 1); M1.8a's editor state shows `limit`
  rows and the truncation warning. The port never sees the limit.
- **Picker flags** (§6.2.6: BINARY unavailable, views hidden, CHAR length unknown, OTHER type unknown) are read from
  the `Database` definition in `loadModel`'s `CubeModelOutline`, in `v1/`. No extra engine calls, no core or saved
  format change. The parity test still records the engine's answer for those tables.
- **`CubeEngineError`** is a class: `nodeId?`, `role?` (the stamp's emit role), `firstLine`, `detail`, and a `kind` (`compile`, `execution`,
  `unsupportedModel`, `network`). Typing returns one per key; `execute` rejects with one, on the stamped node or
  else the capture node. A whole-call failure gives one for every key.
- **An unsupported model kind** gives _This cube's model kind "<\_type>" isn't supported yet._, from the builder
  (the core never reads the model). `loadModel`, typing and `execute` all give it without calling the engine.
- **Parity test format:** a checked-in JSON file under the builder's tests with the engine's raw terms per fixture
  table (column name, type path, parameters, multiplicity; a failing table stores its error's first line),
  compared exactly, plus a check through the adapter. Not a Jest snapshot, so `-u` can't accept an engine change;
  the failure message says the engine's typing changed.
- **Lambda tests:** offline tests against checked-in expected JSON, plus engine tests (A.11) that compare with the
  engine's own parse of the same Pure text.
- **Engine tests** follow the repo convention: `*.engine-roundtrip-test.ts` runs in a plain `yarn test` and fails
  without an engine; `yarn test:group core` skips it. Nothing skips automatically.

### 8.8 Full mapping: spec constructs → lambda

Engine support codes: **H2** = run live ✅. **PCT** = passes the engine's portability tests on Snowflake, Postgres,
DB2, SQL Server, Databricks, DuckDB, Oracle and Trino unless noted.

**Transforms (§7)**

| Spec                 | Lambda construct                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | Engine support                                                                                                                                                                                                                                                                                                   | Fallback / notes                                                                                                                                                                                                                                                                                                                                                                                                                               |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Source (table)       | `#>{db.schema.table}#` + one `->from(rt)`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | H2, PCT                                                                                                                                                                                                                                                                                                          | Problem tables flagged (§6.2.6)                                                                                                                                                                                                                                                                                                                                                                                                                |
| **Filter**           | `->filter({row \| …})`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | H2, PCT                                                                                                                                                                                                                                                                                                          | §8.4 table                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| **Join**             | rename temps → `->join(R, JoinKind.X, {l,r \| …})` → (FULL: coalesce extend) → `->select(~[…])`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | H2 all 4 kinds; FULL plans as a native `full outer join` on 19 database types, emulated on H2 (M6.4)                                                                                                                                                                                                             | Engine rejects any duplicate name ✅ (hence the algorithm); `->toOne()` for SQL NULL semantics                                                                                                                                                                                                                                                                                                                                                 |
| Sort                 | Nothing where it stands; `->sort(~a->ascending())`, or `->sort([~a->ascending(), ~b->descending()])` for several keys, just before each Limit, Drop or Slice that takes rows by the order and before the capture's limit (§8.4)                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | H2, PCT                                                                                                                                                                                                                                                                                                          | Written where the order is used, as an ORDER BY in a subquery is lost (SQL Server rejects one without TOP). A Join, a Group, a Concat, a Restrict that drops sort keys or a later Sort on all the same columns loses the order: a derived warning (`findLostSortOrders`), never an error                                                                                                                                                       |
| Group                | `->groupBy(~[k…], ~[n: x \| $x.c : y \| $y->agg()])`, keys as listed; Count rows `n: x \| 1 : y \| $y->count()`; no keys → `->aggregate(~[…])` (M4, §11.5)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | H2, PCT                                                                                                                                                                                                                                                                                                          | `groupBy(~[], …)` throws NPE ✅; no aggregations → NPE (spec requires ≥ 1 anyway)                                                                                                                                                                                                                                                                                                                                                              |
| Restrict             | `->select(~[…])` listed **in input-schema order**                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | H2, PCT                                                                                                                                                                                                                                                                                                          | `select` keeps the order you list ✅                                                                                                                                                                                                                                                                                                                                                                                                           |
| Rename               | one `->rename(~old, ~'new')` per mapping                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | H2, PCT                                                                                                                                                                                                                                                                                                          | Array form returns 500 ✅                                                                                                                                                                                                                                                                                                                                                                                                                      |
| Distinct             | `->distinct()`, over every column (never `distinct(~[…])`, which also projects)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | H2, PCT                                                                                                                                                                                                                                                                                                          | Padded on SQL Server and Sybase IQ (database workarounds below)                                                                                                                                                                                                                                                                                                                                                                                |
| Drop                 | `->drop(n)`, after `->sort(<input order>)` when the input has a row order                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | H2; **fails PCT on SQL Server and DB2** (`limit m,-1`); Sybase emits the same SQL (no PCT module); Sybase IQ and MemSQL number the rows by the first sort key only                                                                                                                                               | Row numbers on SQL Server, Sybase, Sybase IQ, DB2 and MemSQL (database workarounds below)                                                                                                                                                                                                                                                                                                                                                      |
| Limit                | `->limit(n)`, after `->sort(<input order>)` when the input has a row order                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | H2, PCT; Sybase IQ numbers the rows by the first sort key only                                                                                                                                                                                                                                                   | Row numbers on Sybase IQ after a Sort on several columns (database workarounds below)                                                                                                                                                                                                                                                                                                                                                          |
| Slice                | `->slice(start, stop)`, range `[start, stop)` (D5), after `->sort(<input order>)` when the input has a row order                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | H2; **fails PCT on SQL Server** (`limit m,n`); DB2 passes; Sybase emits `limit m,n` (no PCT module); Sybase IQ numbers the rows by the first sort key only                                                                                                                                                       | Row numbers on SQL Server, Sybase and Sybase IQ (database workarounds below)                                                                                                                                                                                                                                                                                                                                                                   |
| Concat               | `<first>->concatenate(<second>)`; with Convert types, an input first casts its differing columns: `->extend(~[cube_cast: x \| $x.c->cast(@T)])->select(~[…])->rename(~cube_cast, ~c)`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | H2, PCT (`UNION ALL`)                                                                                                                                                                                                                                                                                            | Names and order must match ✅; precise types too (D5), though the engine takes a type next to its ancestor ✅. A count mismatch compiles, typed as the shorter relation, and fails at execution (NPE) ✅ → Cube validates. Convert types: §11.5 Q5                                                                                                                                                                                             |
| Difference           | **No relation function.** Rename `x→x_1`/`x_2`, keys → temps; `join(FULL)`; `extend` (keys `coalesce`); `extend(~[x_valueDifference: x \| $x.x_1->coalesce(0) - $x.x_2->coalesce(0)])`, the zero `0.0` for floats; `select` in §7.12 order                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | H2 and DuckDB run it ✅; plans on all 20 types ✅                                                                                                                                                                                                                                                                | Native types (§11.7 Q5): Integer, Float or Number by family; `toFloat()` is refused when planning on 7 types ✅. The engine's `columnValueDifference` is TDS-only                                                                                                                                                                                                                                                                              |
| Pivot                | `->select(~[<rows>, <column>, <aggregated>])->pivot(~<column>, [<values>], ~cube_pivot: x \| $x.c : y \| $y->agg())->cast(@Relation<(<rows>, '<v>__\|__cube_pivot': T, …)>)`, then one `->rename(~'<v>__\|__cube_pivot', ~'<name>')` per value; the cast gives Cube's precise types, `[1]` where never empty; refused where the database can't run it (M6c, §11.12)                                                                                                                                                                                                                                                                                                                                        |
| Partition            | Array form always: `->extend(<over>, ~[n: {p,w,r \| $r.c} : y \| $y->agg()])`, then the ranks in a second `extend`, `~[n: {p,w,r \| $p->rank($w,$r)}]` (or `$p->denseRank($w,$r)`, `$p->rowNumber($r)`, and from M5b `$p->ntile($r,n)`, `$p->percentRank($w,$r)`, `$p->cumulativeDistribution($w,$r)`, `$p->lag($r,n).c`, `$p->lead($r,n).c`, `$p->first($w,$r).c`), then Last in a third, as `$p->first($w,$r).c` over the window with every sort reversed (M5b, §11.9), each left out when empty, then `->select(~[…])` only when the listed order differs. `<over>`: `over(~[p…], [~s->ascending()])`, `over(~[p…])`, `over([sorts])` or `over([])`. Bound with a `let` unless it is the capture (§8.6) | H2 and DuckDB run it ✅; 17 database types plan it ✅, M5b's functions included; Spanner, Presto and Composite refuse any window (`Window Columns not supported for Database Type: <T>`) ✅; PCT for ranking with ORDER BY and `size()`; on ClickHouse, Lag, Lead, First and Last can give wrong rows 💭 (§11.9) | M5, §11.6; M5b, §11.9. **Gaps:** window `count()` loses its OVER clause → `size()` ✅; one extend holding a rank and an aggregate fails (a 500 ClassCastException) ✅; a Rank with no sort plans, then fails on the database ✅ → validation, which requires a sort for Rank, Dense Rank and Row Number; one nested subselect per window column (§11.6). Never `over(~[], …)` (an NPE) or `over([], [sorts])` (a 400 💭). No frame clause (D5) |
| Extend               | One `->extend(~[n: <lambda>])` per column, in listed order, the lambda the engine's JSON for the user's text (`lambdaJson` IR, `EmitRole.EXPRESSION`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | H2 and DuckDB run it ✅; the chain plans on all 20 types ✅                                                                                                                                                                                                                                                      | Typed by the engine over the cube's model, as a chain (§5.7); Validate plans it for the cube's database (§11.7)                                                                                                                                                                                                                                                                                                                                |
| Filter by Expression | `->filter(<lambda>)`, the lambda the engine's JSON for the user's condition (`lambdaJson` IR, `EmitRole.EXPRESSION`); after a Partition it reads the Partition's `let`, so a condition on a window column runs after the window                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | Checked by the engine over the cube's model (`filter` after the input, and the condition typed alone for its type); plans on the 20 types, the condition after the window on the 17 window types ✅; run on H2 and DuckDB ✅                                                                                     | M6b, §11.11. A comparison of a column that can be empty keeps Pure's meaning (`is not null and …`, `is distinct from`); after `->toOne()`, SQL's. `<`, `>`, `<=`, `>=` bind no tighter than `&&`, `\|\|`, `!`: the editor hints at parentheses. `LIKE` functions: a backslash refused (§8.2), `%` and `_` refused on DuckDB (§8.2, ISSUES.md)                                                                                                  |
| Unknown              | –                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | –                                                                                                                                                                                                                                                                                                                | Not executable (§7.16)                                                                                                                                                                                                                                                                                                                                                                                                                         |

**Database workarounds (M2, §11.4).** For a Drop, Slice or Limit inside a query (Cube's always are, under the run's
own limit) and a Distinct before a limit, some databases reject the engine's SQL or get the wrong rows.
`CUBE_DIALECT_WORKAROUNDS` (`src/ir/CubeDialects.ts`) lists them by the engine's database type name, in a `Map` read
through `getDialectWorkarounds`, never a plain object keyed by a model-supplied name. Execute and Show Pure pass the
type of the runtime's connections to the databases the query reads, when each of them has a connection there and all
share one type (`getDatabaseType`), and wait for the model outline, loaded once per model, only when a node at or
upstream of the capture depends on the type (`needsDatabaseType`, over `dependsOnDatabaseType`: a Drop, Slice, Limit,
Distinct or Pivot, or a filter giving a pattern `%` or `_`). From M6c the outline loads as a cube with a table opens,
kept by the model's content, so the type is there before any run. A data product or ingest cube runs on the
lakehouse runtime Cube writes, whose database its kind gives (`CubeLakehouseRuntime.ts`: `LakehouseRuntime` →
`Snowflake`, every lakehouse runtime routing there for now; `LakehouseSingleStoreRuntime` → `MemSQL`; §11.12). Typing
passes no type. Every other database, an unknown type and a model whose outline fails to load keep the native forms.

**Pivot's databases (M6c, §11.12).** The engine's static `pivot()` runs on Snowflake, Databricks, MemSQL
(SingleStore) and DuckDB. Core's `PIVOT_UNSUPPORTED_DATABASE_TYPES` lists the 17 types that can't run it: H2, which
can't translate it; 13 that assert "pivot is not supported", and Aurora, whose SQL the engine writes as Postgres's;
Trino and Athena, which plan it with no `PIVOT` (Appendix B). There, `getDatabaseProblems` refuses a Pivot (`Pivot is not supported on H2.`), the editor shows it on
the node, the palette and the Add menus grey `Pivot by Column` with the reason (`getCubeAddDatabaseProblem`), and the
emitter throws as a backstop. A type in neither list, or none known, allows it, for the engine to judge.

**Patterns (M6b, M6d, §11.11, §11.14).** `startsWith`, `endsWith` and `contains` on a text, in a Filter by Expression's
condition or an Extend's column (`LikeFunctions.ts`; `contains` on a list is an `IN`, left alone), are written as a
`LIKE`: a backslash in a literal is refused everywhere, `%` or `_` in a literal on DuckDB, and a pattern that isn't a
literal (a column, texts joined) everywhere but Snowflake, where none is known too, since the engine writes it right
only there ✅ (plans on the 20 types; a canary in `LegendCubeDialects`). `getEmitPatternProblem` is the emitters'
backstop once the type is known.

| Database type | Drop        | Slice       | Limit       | Distinct | Why                                                                                                                                                                                                                                                                                             |
| ------------- | ----------- | ----------- | ----------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SqlServer`   | row numbers | row numbers | native      | padded   | Rejects the engine's `limit m,-1` and `limit m,n`; also its `select top N distinct` for a Distinct before a limit (💭, plans only)                                                                                                                                                              |
| `Sybase`      | row numbers | row numbers | native      | native   | Rejects the engine's `limit m,-1` and `limit m,n`                                                                                                                                                                                                                                               |
| `SybaseIQ`    | row numbers | row numbers | row numbers | padded   | The engine numbers the rows by the first sort key only, which takes any of the tied rows, in a column it always names `row_number`, which clashes with an input column of that name, and numbers a later Limit's rows inside the `select distinct`, which then removes nothing (💭, plans only) |
| `DB2`         | row numbers | native      | native      | native   | Rejects the engine's `limit m,-1`                                                                                                                                                                                                                                                               |
| `MemSQL`      | row numbers | native      | native      | native   | The engine numbers a Drop's rows by the first sort key only                                                                                                                                                                                                                                     |
| `ClickHouse`  | row numbers | native      | native      | native   | After a descending sort key, the engine writes `nulls firstoffset m` as one word (💭, plans only)                                                                                                                                                                                               |

- **Row numbers:**
  `->extend([<keys>]->over(), ~[cube_rn: {p, w, r | $p->rowNumber($r)}])->filter({row | <range>})->select(~[<input columns>])`,
  numbered from 1 in the input's row order, else by the first column that sorts (`isSortableType`), ascending. No
  sort is written before it (SQL Server rejects an ORDER BY in a derived table). Drop keeps `$row.cube_rn > <size>`,
  Slice `($row.cube_rn > <start>) && ($row.cube_rn <= <stop>)` (its range counts from 0) and Limit
  `$row.cube_rn <= <size>`. When no column sorts, the native form is written, for the engine to report.
- **Padded Distinct:** `->distinct()->extend(~cube_d: x | 1)->select(~[<input columns>])`, so the distinct keeps its
  own query.
- `cube_rn` and `cube_d` take a number (`cube_rn2`, `cube_rn3`, …) until no input column has the name in any case
  (`getTemporaryColumnName`), as SQL Server, MemSQL and DuckDB see one column.
- Spanner never gets row numbers (no window columns). A plan-only test (`LegendCubeDialects.engine-roundtrip-test.ts`,
  `generatePlan` with one static connection per database type) checks the SQL of 17 database types.

**Filters (§8):** §8.4 above. Every operator works on nullable columns directly ✅.

- **Gaps:**
  - enum equality after a class projection compares against the stored source value and returns wrong rows ✅.
    Dormant for tables; workaround `->toOne()->toString() == 'X'` when enum sources arrive.
  - Boolean columns need `== true` as a bare predicate (a nullable bare `$r.B` does not compile) ✅.

**Aggregations (§10):**

| Spec                                      | In `groupBy` / `aggregate`             | In a window                                                   | Notes                                                                                                                                                                                                                                                                                                                                |
| ----------------------------------------- | -------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Count                                     | `$y->count()`                          | **`$y->size()`**                                              | Non-null count (D4)                                                                                                                                                                                                                                                                                                                  |
| DistinctCount                             | `$y->distinct()->count()`              | `$y->distinct()->size()`                                      | `count(distinct x) over (…)`: plans on the 17 window types, runs on H2 and DuckDB ✅; Postgres, SQL Server, Databricks and Trino (so Athena) are expected to refuse it, and Oracle and BigQuery with a sort 💭: an error, never wrong rows. Offered natively, with an editor note and an ISSUES draft (§11.6 Q3); not covered by PCT |
| DistinctValue                             | `$y->uniqueValueOnly()`                | same                                                          | SQL `case when count(distinct x)=1 then max(x) end`; in a window `case when count(distinct x) over (…) = 1 then max(x) over (…) end`, as portable as Distinct Count's (§11.6 Q3)                                                                                                                                                     |
| Sum, Average, Min, Max                    | `sum()`, `average()`, `min()`, `max()` | same                                                          | Never emit `1.0*`: the engine already does for `average` ✅                                                                                                                                                                                                                                                                          |
| Count rows (M4)                           | `x \| 1 : y \| $y->count()`            | `{p,w,r \| 1} : y \| $y->size()` (M5)                         | Every row, null or not (`count(1)` ✅, `count(1) over (…)` in a window ✅); answers §12.2 item 3 (§11.5 Q1)                                                                                                                                                                                                                          |
| Rank, DenseRank                           | –                                      | `$p->rank($w,$r)`, `$p->denseRank($w,$r)`                     | Need ≥ 1 sort ✅; Cube validates it. In their own `extend` (§8.6); never emit a frame with ranking ✅                                                                                                                                                                                                                                |
| RowNumber (M5)                            | –                                      | `$p->rowNumber($r)`                                           | Beyond the spec (§11.6 Q2); needs a sort, as Rank; a tie's rows numbered in either order ✅                                                                                                                                                                                                                                          |
| NTile (M5b)                               | –                                      | `$p->ntile($r,n)`                                             | Beyond the spec (§11.9); `n` buckets, 4 by default; needs a sort; in the ranks' `extend`                                                                                                                                                                                                                                             |
| PercentRank, CumulativeDistribution (M5b) | –                                      | `$p->percentRank($w,$r)`, `$p->cumulativeDistribution($w,$r)` | Beyond the spec (§11.9); need a sort; in the ranks' `extend`                                                                                                                                                                                                                                                                         |
| Lag, Lead (M5b)                           | –                                      | `$p->lag($r,n).c`, `$p->lead($r,n).c`                         | Beyond the spec (§11.9); an offset `n`, 1 by default; need a sort; empty at a partition's edges ✅; in the ranks' `extend`                                                                                                                                                                                                           |
| First, Last (M5b)                         | –                                      | `$p->first($w,$r).c`                                          | Beyond the spec (§11.9); the partition's first or last row, the same on every row; need a sort. Last is First in a third `extend` over the reversed sort, with no frame ✅                                                                                                                                                           |

**Window functions beyond the spec:** Row Number is offered from M5 (above, §11.6 Q2); NTile, Percent Rank,
Cumulative Distribution, Lag, Lead, First and Last from M5b (above, §11.9). `nth` also works on H2 ✅, and isn't
offered: the partition's nth row needs a whole-partition frame, which the engine writes only with a partition column.

**Extend expressions (§9):**

- Every §9.2 function maps to a Pure function, except six TDS names that **do not exist in Pure** ✅: `stringContains`
  → `contains`, `stringIndexOf` → `indexOf` (on `toOne()`), `stringConcatenate` → `+`/`joinStrings`, `absolute` →
  `abs`, `mostRecentDayOfThisWeek`/`previousDayOfThisWeek` → the one-argument `mostRecentDayOfWeek`/`previousDayOfWeek`.
- `dayOfWeek()` returns an enum and fails at plan time; use `dayOfWeekNumber()` ✅.
- Row-level `mean`/`stdDev*` over lists compile as aggregates and fail on H2 ✅; they move to §10 only.
- `parseDate` accepts only `yyyy-MM-dd HH:mm:ss` on H2 ✅.
- `toDecimal` truncates the scale to 0 on H2 ✅.
- Validate plans the query up to the Extend for the cube's database (`generatePlan`), to catch plan-time failures,
  and plans each prefix of its columns to name the one that fails (M6).
- Each text is parsed under the source id `<node id>:<row key>`, so parse and typing errors carry positions in the
  user's text (M6); a stored lambda has none, so its errors land on the node.

### 8.9 Gaps summary

| Gap                                                                                                                                                                           | Handling                                              |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| No Difference relation function                                                                                                                                               | Emulation                                             |
| Window `count()`                                                                                                                                                              | `size()`                                              |
| A filter after a single-form window: before the window on an input column; on the window column, QUALIFY on 4 types, refused on 6 and silently dropped on 9 ✅ (plans, M5.10) | Array form and `let` isolation (§8.6)                 |
| Drop and Slice on SQL Server, Sybase and Sybase IQ; Drop on DB2, MemSQL and ClickHouse; every Limit on Sybase IQ                                                              | Row numbers (sorted `over()`, §8.8)                   |
| A Distinct before a limit: `select top N distinct` on SQL Server, numbered inside the `select distinct` on Sybase IQ                                                          | Padded Distinct (§8.8)                                |
| Exact comparison on 32-bit REAL columns                                                                                                                                       | Inline hint (§5.5); tests avoid it                    |
| Windowed Distinct Count and Distinct Value, refused by some databases at run time 💭                                                                                          | Native; an editor note and an ISSUES draft (§11.6 Q3) |
| Engine ignores join/filter value types                                                                                                                                        | Cube validation                                       |
| Outer-join multiplicity; Sum and Average typed `[1]` ✅ (M4)                                                                                                                  | Cube infers nullability                               |
| `concatenate` of different column counts typed as the shorter relation ✅ (M4)                                                                                                | Cube validation (§8.8)                                |
| `CHAR`/`BINARY`/view typing                                                                                                                                                   | Picker flags                                          |
| `#P`/`#I` not in the open-source engine                                                                                                                                       | Mocks (D6)                                            |

Every engine defect is listed in Appendix B.

---

## 9. Results grid (§12–13 adapted)

**Slice:**

- `@finos/legend-lego/data-grid` `DataGrid` (enterprise, D3), client-side row model. _(M7: now local mode; server-side
  mode is the default, below.)_
- Columns come from Cube's **inferred** schema, not from the result builder, which is lossy.
- Column ids are positional (`c0…cN`) with `headerName = column name`. ag-grid treats dots in `field` as nested
  paths, and column names may contain dots or spaces.
- Alignment, sorting and formatting follow the type family. Integer and Decimal values arrive as exact text (§8.7).
  Shown (2026-10-10): Decimal and Number values with their thousands grouped and every digit; Float values rounded
  to 2 places and grouped; Integer values plain. Sorting and copying use the exact value.
- **Execution is explicit:** Execute or F9. Edits mark results **stale** (§12.1).
- **Row limit:** user-settable, kept in local storage, default 1,000. Cube emits `->limit(limit + 1)` to detect
  truncation and warn. The original fetched everything and truncated client-side (§12.7).
- **Stats:** duration and SQL, with a copy button. _(M7: the Runs panel, below.)_
- **Errors** render inside the grid region, with the first line of the message and expandable detail (§17.13).

**M2 (§11.4):**

- **Quick actions** (spec §12.4) on a cell's context menu: "Sort by "X"" and "Filter by "X"", then a separator and
  ag-grid's own items (`getCubeGridContextMenuItems`); outside a cell, only ag-grid's. Sort by adds an ascending Sort
  on the column, Filter by an Equal on the clicked value (IsEmpty on a null cell; a timestamp's trailing `+0000`
  dropped; from M6d, on a float, In the value and its 32-bit value when they differ, so a `REAL` value is found, as a
  drill-down key is: §11.14). The node goes after the selected node, as one undo step that selects it. Nothing runs: the rows turn
  stale. Group by joins them in M4 (below). _(M7: the curated menu, below.)_
- **When they can be used** (`getCubeGridQuickActions`, read when the menu opens): only while the rows shown are from
  the current query, no run is in progress and the cube can be changed (not read-only). Sort by is also disabled on a
  type that can't be sorted (`isSortableType`); Filter by on a non-null value whose column type has no Equal, or that
  doesn't read as one of that type. A disabled item gives its reason as its tooltip; Filter by's Equal on a Float
  column carries the floating-point hint.

**M4 (§11.5):**

- **"Group by "X""**, between Sort by and Filter by: a Group with the key `[X]` and Count rows (spec §7.2's default is
  Count of X), named `Count Rows`, or `Count Rows 2`, `Count Rows 3`… when that folds to an input column's name. It is
  added and enabled as the others are, and also disabled on a type that can't be grouped (VARIANT, OPAQUE). It changes
  the schema, so later nodes may turn invalid (visible, undoable).

**M7 (§11.10):**

- **Two modes,** server-side by default, the mode kept per user. Execute stays the flat run of the capture node in
  both. **Server-side mode** is ag-grid's server-side row model: a header sort or a drill-down re-derives the query
  from the run on screen (its query, context and row limit, never the live cube) and runs it on the engine.
  **Local mode** is the slice's grid: ag-grid's grouping, pivot, filters, side panels and status bar on the fetched
  rows, its totals read as numbers. Switching runs nothing again. A Pivot node (M6c, §11.12) is a node like any
  other: server-side mode runs it, and drills down by its row columns; local mode's ag-grid pivot stays, for quick
  looks at the fetched rows.
- **Drill-down** (spec §12.2), saved as `meta.drilldown`: each level is derived in the core (`DrilldownQuery.ts`) as
  Cube's own nodes after the capture node: a Filter of the expanded keys, a Group by the levels down to the new one
  with each other column's aggregation (a number's Sum by default, settable, or Nothing) and Count rows, a Sort that
  ends with the level's own column; the leaf is the Filter and the grid's sort. Each level is fetched whole, cut at
  the row limit (not paged), and cached in a bounded LRU keyed by the derived lambda. Keys are typed, read from the
  parent rows (an empty key is IsEmpty, a float key `In [v, fround(v)]`); a level's type needs Equal as well as
  group. The groups column is pinned on the left.
- **Entering it:** 'Drilldown by "X"' heads a cell's menu (switching local mode to server-side); a bar above the grid
  lists the levels, each removable, what can't be used and why, 'Group rows…' (a dialog of each column's
  aggregation) and Clear. Each change is an undo step that runs nothing.
- **The menu, curated** (spec §12.4): on a cell, 'Drilldown by', 'Sort by', 'Group by', 'Filter by' (Filter by
  only on a group row's key, never an aggregate or an empty cell, and not on a local-mode group row), 'Format
  Column…'; anywhere, 'Autosize All Columns', 'Reset Columns', 'Copy', 'Copy with Headers' and Export. Cut, paste and
  ag-grid's own export are gone.
- **Export** (spec §12.5): 'CSV (whole query)' runs the run's query, sorted as the grid is, without the grid's row
  limit, through the engine (`CubeEngine.exportCsv`, the client's `CSV_TRANSFORMED`), saved as `<cube name>.csv`;
  Stop aborts it. 'Excel (rows shown)' is ag-grid's export of the loaded rows, expanded groups included, number
  columns as numbers but unformatted, each group by its key.
- **Runs** (spec §12.6): 'Runs (n)' opens a panel under the toolbar with the page's 15 latest runs, fetches,
  exports, Pivot value loads and JSON samples (§11.20), each with its time, duration, SQL and Copy SQL, and the lambda it sent: Show Pure
  (as it ran, its row limit included) and Copy JSON (the lambda as the engine receives it). A failed run is listed
  with the engine's first line, and a fetch the grid drops for a newer run too; a stopped one isn't. No trace link.
- **Dev Settings** (§11.16): the menu (☰) at the top left, in the palette's header, opens Dev Settings, as Legend
  Query's editor does: the engine URL Cube's engine clients use, with a reset to the deployment's, and their request
  compression (kept per user; off, the browser's Network tab shows each request's JSON).
- **Formats** (spec §13.1, with the §21 fixes): 'Format Column…' sets a number, currency (ISO 4217 codes from
  `Intl.supportedValuesOf('currency')`) or percent format, its places, separators, red and parentheses for
  negatives, for the column or every number column, saved as `meta.presentation.columnHints`. The exact text is
  formatted, every digit kept; a group row's count never is.
- **Group rows' numbers** (§11.15): grouped thousands, an integer's too, a fraction rounded to the column's scale or 2
  places; never a group's key, and the exact value on hover and copy.
- **Late answers** for an earlier run, mode, cube or grid are dropped (the run id, the fetcher's generation, a token
  per grid). A fetch's error shows in the grid region and fails only its level.

**Variant (JSON) columns (§11.20):**

- **One line:** a Variant cell shows in legend-lite's notation, `{name: ann, tier: gold}`, `[a, b]`: numbers as
  written, a key or string bare unless it could be read as something else. Copy, CSV and Excel keep the JSON text,
  which the cell's title shows too.
- **The cell's menu:** on a Variant cell, "Open JSON" (the document as a tree, open to two levels, with Copy JSON)
  and "Explore JSON…"; on a text cell holding a JSON object or array, "Parse as JSON", which adds an Extend
  `<name>_json: $x.<name>->toOne()->fromJson()`. A Variant column's header menu starts with Explore JSON…. Each is
  disabled as the quick actions are, and on a database without the Variant functions (Snowflake, Databricks,
  Postgres and DuckDB have them).
- **Explore JSON:** the column's first 1,000 values, or every row, read through Runs: the query up to the node that
  ran, the column alone, its non-empty values. A searchable field tree, each field with its kind and its share; the
  picked field's offers, ticked, are added after the node that ran as an Extend, then an Explode Array for one row
  per element of an array (one array at a time), one undo step. Nothing runs: the rows turn stale.

---

## 10. E. Artifacts and save/load

### 10.1 Query graph model

| Aspect     | Answer                                                                                                                      |
| ---------- | --------------------------------------------------------------------------------------------------------------------------- |
| Shape      | `CubeDocument { context: {model: ModelContext; runtime?}; query: Query (nodes, connections, selected); meta: Meta }`, as §4 |
| Owner      | `@finos/legend-cube`. Immutable classes; every edit makes a new `Query`                                                     |
| Round trip | ↔ CubeSpec via the core codec (§10.3). The UI never holds a second copy of the truth                                       |
| Versioning | None of its own; versioned through the spec's `formatVersion`                                                               |
| Storage    | In memory (MobX `observable.ref`) while editing                                                                             |

### 10.2 Executed lambda / value specification

| Aspect     | Answer                                                                                                                                                                                                                                                                                           |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Shape      | Cube IR (core) → protocol JSON (builder `v1/`)                                                                                                                                                                                                                                                   |
| Owner      | Core emits it; builder serializes it                                                                                                                                                                                                                                                             |
| Round trip | **Read back by Import Pure (§11.18).** There is no unique inverse, so a lambda reads into one canonical cube, checked by writing it again; text with a quoted dotted name is refused, as the grammar reads it as another table. Shown read-only via "Show Pure" (`JSONToGrammar_lambda`, PRETTY) |
| Versioning | N/A. Regenerated from the graph every time; deterministic for a given graph and emitter version                                                                                                                                                                                                  |
| Storage    | **Not persisted.** The future Cube store may keep a generated copy for search or impact analysis, but never as the source of truth                                                                                                                                                               |

### 10.3 Saved spec: `CubeSpec` format v1 (the persisted artifact; D1: defined now, stored later)

```jsonc
{
  "formatVersion": 1,
  "name": "French orders 1997",
  "context": {
    "model": {
      "_type": "text",
      "code": "###Relational\nDatabase showcase::northwind::store::NorthwindDatabase\n(…)…",
    }, // M3: { "_type": "pointer", "sdlcInfo": {…} }
    "runtime": "showcase::northwind::mapping::StoreRuntime",
  },
  "query": {
    "selected": "filter101",
    "nodes": [
      {
        "kind": "relational",
        "id": "relational101",
        "database": "showcase::northwind::store::NorthwindDatabase",
        "schema": "NORTHWIND",
        "table": "ORDERS",
        "schemaSnapshot": [
          {
            "name": "ORDER_ID",
            "type": { "path": "meta::pure::precisePrimitives::SmallInt" },
            "nullable": false,
          },
          {
            "name": "CUSTOMER_ID",
            "type": {
              "path": "meta::pure::precisePrimitives::Varchar",
              "params": [5],
            },
            "nullable": true,
          },
          /* … */
        ],
      },
      {
        "kind": "relational",
        "id": "relational102",
        "database": "…",
        "schema": "NORTHWIND",
        "table": "CUSTOMERS",
        "schemaSnapshot": [
          /* … */
        ],
      },
      {
        "kind": "join",
        "id": "join101",
        "inputs": ["relational101", "relational102"],
        "joinType": "INNER",
        "leftColumns": ["CUSTOMER_ID"],
        "rightColumns": ["CUSTOMER_ID"],
      },
      {
        "kind": "filter",
        "id": "filter101",
        "inputs": ["join101"],
        "filter": {
          "op": "and",
          "rules": [
            {
              "column": "SHIP_COUNTRY",
              "operator": "Equal",
              "value": { "kind": "string", "value": "France" },
            },
            {
              "column": "ORDER_DATE",
              "operator": "GreaterThanOrEqual",
              "value": { "kind": "strictDate", "value": "1997-01-01" },
            },
            {
              "column": "EMPLOYEE_ID",
              "operator": "In",
              "value": [
                { "kind": "integer", "value": "1" },
                { "kind": "integer", "value": "4" },
              ],
            },
          ],
        },
      },
    ],
  },
  "meta": {
    "presentation": {
      "showGraph": false,
      "columnWidths": [{ "column": "COMPANY_NAME", "width": 180 }],
    },
  },
}
```

Rules:

- **Edges are inlined as `inputs`.** Each node lists its port-ordered `inputs: (nodeId | null)[]`, with `null` for an
  unconnected port. There is no connections array; connections are rebuilt as `Connection(inputs[i] → node,
ports[i])`. This is the shape of §14.1's suggestion without the TDS wire types.
- **`kind` is the node type** from the registries. `id` follows the per-type generator.
- **Filters are stored as a tree with explicit `op`** (`and` / `or` / `not`) and comparisons `{column, operator,
value}`. **Negations are stored as negated operators** (`NotEqual`, `NotIn`, …); `not` wraps groups, and comparisons whose operator has no negated one (`{op: 'not', rule}`, Settled in M1.6). This
  replaces the TDS encoding of §8.4.
- **Values are typed** `{kind, value}`, with numbers as strings (lossless, §5.6).
- **M2's transforms** (§11.4; sample `operations.cube.json`): `sort` stores `{sorts: [{column, direction}]}`, most
  significant first, with `ASC` or `DESC`; `restrict` `{columns: […]}`; `rename` `{mappings: [{from, to}]}`, so no
  column name becomes a key; `distinct` nothing of its own; `limit` and `drop` `{size}`; `slice` `{start, stop}`,
  counting rows from 0 with `stop` excluded. Names are kept exactly (untrimmed, blanks and repeats included) for
  validation to judge. Sizes and indexes are JSON numbers, not strings: they are settings, not values.
- **M4's transforms** (§11.5; sample `operations.cube.json`): `group` stores
  `{columns, aggregations: [{column, function, name}]}`, both lists always written, with no `column` for Count rows
  (`{function: 'CountRows', name}`). Every output name is stored: one left out is read as its auto-name and written
  back with it (Q3). An unknown, empty or window-only function is kept as text, invalid, and saved again unchanged
  (Q4). `concat` stores `{widenTypes}` (Convert types), always written, `false` included, so the key ships with the
  kind: a missing one is a decode error.
- **M5's Partition** (§11.6; samples in `operations.cube.json`): `partition` stores `{columns, sorts, aggregations}`:
  the partition columns, Sort's `{column, direction}` entries and Group's `{column, function, name}` entries, all
  three always written, empty lists too. It is Cube's own shape, never the spec's V1 `operations` (`olapRank`,
  `olapAggregation`), and saves no frame (D5). Entries are read by Sort's and Group's readers, and written with no
  `column` for Count rows, Rank, Dense Rank and Row Number. An unknown direction, or an unknown key on an entry, makes
  an Unknown node; an unknown or empty function is kept, invalid, and so is a Rank saved with a column; a missing name
  gets the auto-name (`FREIGHT Sum`, `Row Number`), or `''`.
  From M5b (§11.9), an entry also takes `offset` (Lag and Lead) or `buckets` (NTile), a JSON number, written whenever
  the entry has one; a missing one stays missing and is reported, and one on a function that takes none, or one that
  isn't a whole number of at least 1, is kept and reported. The new functions are saved as `Lag`, `Lead`, `NTile`,
  `PercentRank`, `CumulativeDistribution`, `First` and `Last`, with no `column` for NTile, Percent Rank and Cumulative
  Distribution (sample `partition103`).
- **M6's Difference and Extend** (§11.7; samples in `operations.cube.json`): `difference` stores
  `{leftColumns, rightColumns, differenceColumns}`, Join's key names, all three always written. `extend` stores
  `{columns: [{name, code, lambda}], typed?}`: the text as typed and the engine's JSON for it, without source
  information and with number literals as their digits, the lambda left out until the text is checked; and the last
  typing `{signature, types}`, never a failure. A typing without a type per column reads as nothing typed, and an
  unknown key on a column, the typing or a typed column's type makes an Unknown node. A loaded Extend is typed again.
- **M6b's Filter by Expression** (§11.11; sample in `operations.cube.json`): `expressionFilter` stores
  `{code, lambda?, checked?}`: the text as typed (empty when new), the engine's JSON for it as an Extend column's, left
  out until checked, and the last check `{signature}` (never a failure, nor what the engine was given). An unknown key
  in the check makes an Unknown node; one on the node goes to its `rest`. A loaded filter is checked again.
- **M6c's Pivot** (§11.12; sample in `operations.cube.json`): `pivot` stores `{rows, column, values, aggregation}`:
  the row columns, the pivot column (`''` until picked), the values as typed literals `{kind, value}` in order, and
  the aggregation `{column, function}`, the column left out for Count rows. A value this version doesn't read, or a
  key it doesn't know in a value or the aggregation, makes an Unknown node; an unknown key on the node goes to its
  `rest`.
- **Explode Array** (§11.20; tests in `CubeSpecExplode.test.ts`, no corpus sample: Northwind has no Variant column):
  `explode` stores `{column, path, name}`, the Variant column, its keys to the array (empty when the column is the
  array) and the new column's name, always written, `''` and empty keys kept for validation. An unknown key goes to
  its `rest`; an older Cube keeps the node as an Unknown node.
- **M7's grid settings** (§11.10), in `meta`: `drilldown: {columns, aggregations: [{column, function}]}`, an
  aggregation only where it differs from the default (a number's Sum, nothing for other columns), `null` for Nothing,
  none written for an empty drill-down; `presentation.columnHints: [{column?, format: {style, decimalPlaces?,
currency?, grouping, redIfNegative, parenthesesIfNegative}}]`, a hint with no column being the format of every
  number column, defaults left out; `presentation.columnWidths`, now applied. Unknown keys are kept at each level, and
  an unknown style is kept and shown as no format.
- **Schema snapshots** on sources make offline inference possible on load (§1: no server round trip for editing).
  On load the host re-resolves the schema and diffs it. Drift becomes a node warning, and the fresh schema wins.
- **Forward compatibility:**
  - unknown top-level keys are kept in `spec.rest`;
  - unknown keys on known nodes are kept in that node's `rest`;
  - unknown `meta` keys are kept in `meta.rest` (§13);
  - unknown node kinds become `UnknownNode`, keep their raw JSON and inputs, and are re-saved verbatim.
- **No object keys come from user data:** column names live only in values, arrays use `{name, …}`. The JSON is
  always produced with `JSON.stringify`, and a client-side size cap applies (1 MiB). Each choice prevents a verified
  hazard in the engine's Mongo stores: nulls dropped, keys re-sorted, extended-JSON reinterpreted, and documents that
  can no longer be read ✅.
- **Versioning:**
  - `formatVersion` is an integer.
  - Additive changes don't bump it (unknown fields survive).
  - A breaking change bumps it and adds a `migrate_vN_to_vN+1(json)` step; older documents migrate on load.
  - A document newer than the reader opens **read-only** with a banner.
  - Until the Cube store exists (M8) the format is marked **draft**, so changes stay cheap, but every change still
    goes through the codec's tests. The marks (user, 2026-10-08): `@finos/legend-cube`'s README says so, and in the UI
    the "(dev)" on Export and Import is the marker; the spec itself carries none. _(2026-10-10: the user dropped the
    "(dev)" for the demo; the README stays the marker.)_
- **Slice behaviour:**
  - The codec and round-trip tests ship in M1.6.
  - The UI has a dev-only **Export spec** (copy to clipboard or download `.cube.json`) and **Import spec** (paste or
    upload). This lets the POC's state be saved locally and exercises the round trip.
  - There is **no store, no Load dialog and no server save** (D1).
- **What needs a valid query.** Spec §14.4 disables Save while the query is invalid, and §17.11 makes Execute
  require a valid query, i.e. every node valid (§4.4). Taken literally, three things break: `null` inputs could never
  be saved, an Unknown node could never be re-saved, and a stray unconnected node would block Execute. So:
  - **Export spec is allowed for invalid queries.** The format carries `null` inputs and Unknown nodes for exactly
    this reason.
  - **Execute (F9) is enabled iff every node in the capture node's upstream subtree is valid**, including query-level
    rule errors on its sources. Invalid nodes outside that subtree don't block it, since only the subtree is executed
    (§8.2).
  - Server-side **Save** gating is decided in M8.

Settled in M1.6 (the plan leaves these open; the user chose the first five on 2026-10-06, the fifth, the model
context, after reviewing the samples; the rest are defaults shown with the sample specs):

- **Not around a comparison** without a negated operator (e.g. Not(GreaterThan)) is `{op: 'not', rule: {…}}`, the
  same wrapper as around a group. The codec never normalizes the tree: it writes rules as the node holds them.
- **Invalid values** are stored as `{kind: 'invalid', text}` and decoded unchanged. Re-reading them against the
  table types is a separate step after the host re-resolves sources (wired in M1.8a), so load round-trips exactly.
- **What a reader can't understand degrades to the smallest part**, so an older Cube can open a newer document:
  - an unreadable filter rule (unknown `op` or operator, unknown value kind, malformed value, an unknown key on a
    rule or value) becomes an **unsupported rule** that keeps its JSON, is re-saved verbatim and is invalid with
    "This filter is not supported yet."; the rest of the filter stays editable;
  - a known node whose settings can't be read (e.g. `joinType: 'CROSS'`; from M2 an unknown sort direction, or an
    unknown key on a sort entry or rename mapping; from M4 an unknown key on an aggregation, or a `widenTypes` that
    isn't true or false) becomes an **Unknown node** that keeps its JSON, its kind and its inputs. An
    empty direction is still a decode error, as an empty `joinType` is;
  - a missing or wrongly typed required field (no `database`, a non-array `leftColumns`, a non-boolean `nullable`,
    a known field set to `null`; unknown keys and an Unknown node's JSON keep their nulls) and a model that is not an
    object with a non-empty string `_type` are still **decode errors**. A model of a `_type` the builder can't run is
    not (§6.2.2).
- **Unknown keys are kept on every object**: top level, `context`, `query`, nodes, snapshot columns and
  types, `meta`, `presentation`, width items. Rules and values are the exception above (unsupported), since ignoring
  a key such as `caseInsensitive` would change the rows; so are sort entries and rename mappings (an Unknown node,
  M2), and aggregations (M4). A future setting that would change the rows outside a list's entries, on any node (e.g.
  Distinct gaining `columns`, or a Partition's "whole partition" setting), needs a new kind, or a key on each entry,
  since an older reader would keep a node key in `rest` and ignore it (M2, §11.4); a format version alone isn't enough,
  as a newer document still runs read-only (M5.12). A snapshot column's unknown keys (on the column or its
  type) live on the source node by column name; when the host re-resolves the source they stay with the columns that
  still exist.
- **The model is the engine's model context** as plain JSON (§6.2.2): `{_type: 'text', code}` in the slice,
  `{_type: 'pointer', sdlcInfo}` from M3. It is kept whole and re-saved exactly as read. The sample specs hold a
  short stub `code`, since the codec never parses Pure; M1.7 gives them the Cube Northwind text and checks on the
  engine that they compile.
- **Shape:** keys in the order of the §10.3 example, known keys first and unknown ones after them in the order read;
  anything absent or at its default is left out (`name`, `context` before the first source, `runtime`,
  `selected` on an empty query, `schemaSnapshot` when not resolved, `filter`, `value`, `params`, `meta`,
  `presentation`, `showGraph` when true, empty `columnWidths`); `inputs` (with `null`), `joinType` and the key
  lists are always written, as are M2's `sorts`, `columns` and `mappings` and M4's `columns`, `aggregations` and
  `widenTypes`, and M5's `columns`, `sorts` and `aggregations`, empty lists and `false` too. A clearable setting (a
  Limit's or Drop's `size`, a Slice's `start` and `stop`) is the exception to "at its default is left out" (M2,
  §11.4): it is written whenever set, its default included, and left out only when cleared, never as `null` (a decode
  error), so it reads back cleared and the panel counts clearing a default as an edit. An enumeration column's type
  is `{path, values: [...]}`. Spec JSON is always produced
  with `JSON.stringify`; fixtures are Prettier-formatted and tests compare key order, not whitespace.
- **Decoding** returns `{document, formatVersion, readOnly}`. A broken document throws one error with a JSON path
  (e.g. `query.nodes[2].database`). `formatVersion` is a whole number ≥ 1; an older one migrates step by step; a
  newer one decodes with these rules and is **read-only**. Ids are kept as saved. `name` is top level and
  optional. The 1 MiB cap counts UTF-8 bytes, on import and export.

### 10.4 UI state

| State                                                       | Saved?      | Where                                        |
| ----------------------------------------------------------- | ----------- | -------------------------------------------- |
| Canvas node positions                                       | No: derived | Pure function of the query (§7.2)            |
| Capture node                                                | Yes         | `query.selected`                             |
| Graph panel collapsed (`showGraph`)                         | Yes         | `meta.presentation` (§17.1 [why])            |
| Grid column widths, formats and drill-down (M7)             | Yes         | `meta.presentation` / `meta.drilldown` (§13) |
| Open editor, panel sizes, zoom and pan, selection highlight | No          | ephemeral                                    |
| Sidebar collapsed, row limit                                | Per user    | local storage (§17.1, §12.7)                 |
| Undo history                                                | No          | session                                      |

### 10.5 How Legend Query persists today, and why Cube doesn't use it

Findings: engine `legend-engine-application-query`, Studio
[Query.ts](../../../packages/legend-graph/src/graph-manager/action/query/Query.ts),
[QueryEditorStore.ts:556-585](../../../packages/legend-application-query/src/stores/QueryEditorStore.ts#L556).

- **`content` must be Pure-lambda text.** Legend Query re-parses it on load
  ([QueryEditorStore.ts:2604](../../../packages/legend-application-query/src/stores/QueryEditorStore.ts#L2604)).
- **Project coordinates and an execution context are mandatory.** An explicit context needs a mapping, which a
  `#>{}#` lambda does not have.
- **One owner.** **No optimistic concurrency:** the client's version is ignored 📄✅.
- **`GET` rewrites the whole document** to bump `lastOpenAt`, and can lose a concurrent save ✅.
- **Free-form slots are lossy or overwritten:** `gridConfig` drops nulls, re-sorts keys, and is overwritten on every
  Legend Query save.
- A JSON-string taggedValue **would** round-trip losslessly ✅. But Cube queries would then appear in Legend Query's
  loader and Data Cube's picker, and opening one in Legend Query **crashes to a blank page** if it has no execution
  context 📄.
- `PersistentDataCube` is unversioned, hard-deletes, and pollutes Data Cube's lists.
- **Locally, every store endpoint fails** with "MongoDB … not configured" ✅.

**Conclusion (D1):** a dedicated Cube store.

### 10.6 Future Cube store (M8, outline)

- **Where:** a `CubeStoreManager` constructed inside `ApplicationQuery`, next to the Data Cube store, so `Server.java`
  needs no change. It has its own Mongo collection and event collection, configured through Vault keys.
- **Document:** `{id, name, owners[], tags[], formatVersion, content: <spec JSON as a string>, sourceRefs[], audit}`.
  `sourceRefs[]` is denormalized for searches such as "cubes using table X".
- **Semantics (§14.3):**
  - **atomic expected-version check → 409**, with a unique `{id, version}` index;
  - owner-only mutation (403);
  - a validated PATCH;
  - soft delete by default, plus permanent delete;
  - search by name, owner, creator and tags (`$all`);
  - `lastOpenAt` updated with `$set`.
- **Avoid known defects** of `BaseStoredVersionedAssetDao`: reusing an id after delete corrupts its history, and
  updates are not atomic ✅.
- **Studio side:** a serializr class plus client methods, behind a `CubeStore` port. Then add Load and Save dialogs,
  `/cube/:cubeId`, the modified-state asterisk and `beforeunload` (§14.4, §17.10, §17.14).

---

## 11. F. Milestones

### 11.1 M1: the slice

Each step ends green on `GITHUB_BASE_REF=master yarn check:ci` (after `git fetch origin`; the changeset check needs the
base ref), `yarn lint:ci` and the tests. M1.1–M1.6 are **headless and test-driven**; nothing in them needs the engine.

| Step      | Deliverable                                                                                                                                                                                                                                                                                                                                        | Done when                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **M1.0**  | Scaffolding: both packages (§3.1–3.4); the purity lint guard and import-scan test; the `/cube` route rendering a placeholder; changesets                                                                                                                                                                                                           | `yarn build`, all CI checks green; `localhost:9001/query/cube` renders the placeholder                                                                                                                                                                                                                                                                                                                                                                                                           |
| **M1.1**  | Types and values: the registry (§5.3), `CubeType` interning and equality, families, comparison classes (§5.4), `LiteralValue` coercion and validation (§5.6), type display                                                                                                                                                                         | Table-driven tests: every engine type path parses (including short names and `Varchar(0)`); the compatibility matrix; integer width boundaries including Java long ±1 and unsigned; date and datetime formats; Boolean strictness; `parseValue` keeps invalid text as `{kind:'invalid'}`, keeps STRING untrimmed, and canonicalizes numerics (`+5`, `007`, `.5`, `5.`, `-0`, `1e3` accepted and normalized; `0x10`, `Infinity`, `NaN` rejected); kind-mismatch rejection                         |
| **M1.2**  | Graph and inference: `QueryNode`, `Connection`, `Query` with the five invariants **plus acyclicity**; every §4.4 operation, including `connect(…, port)`; per-type `generateId`; `buildSchemasAndValidity` with the sentinels; the query-level rule pass; the node registries; `RelationalTableSource` (with a given schema); `UnknownNode`        | Every invariant violation throws; every `canX` is total; the connect cycle case is rejected, while moving a node after a node further down its own chain is allowed and stays acyclic; propagation is proven with **test-only stub nodes** (a two-port binary stub and a pass-through unary stub): disconnecting a binary input gives `ERR_INCOMPLETE` there and `ERR_SCHEMAS` on every downstream node, with no duplicates; an Unknown node's synthetic ports carry its edges                   |
| **M1.3**  | Join: §7.11 validation steps 1–5 with verbatim messages; comparison-class compatibility; the duplicate rule; output order; nullability and merged-key rules per join type (§4.7); `swapInputs`; `describe`                                                                                                                                         | Appendix C.3's join row, retyped with precise types (`bookId` first; right duplicate dropped); C.5(b) message verbatim; multi-key and partially same-named keys; `Varchar(5)`⋈`Varchar(40)` OK; `Varchar`⋈`SmallInt` and `StrictDate`⋈`Timestamp` rejected; LEFT/RIGHT/FULL nullability matrix, including FULL with exactly one nullable key → merged key nullable                                                                                                                               |
| **M1.4**  | Filter: tree, operator availability by family (§5.5), shape and type validation, §8.5 helpers, `describe` and its redacted form                                                                                                                                                                                                                    | Operator matrix for every registry type; validation messages verbatim plus the new ones; normalize/unwrap round trips; the column- and operator-change reset rules; Appendix C.5(a) literally (`join101` `ERR_INCOMPLETE`, `filter101` `ERR_SCHEMAS`) now that Join and Filter both exist                                                                                                                                                                                                        |
| **M1.5**  | IR and emitter: the IR (§8.3); emit for source, join (§8.4 algorithm: temps, `toOne` for both-nullable keys, FULL coalesce + cast) and filter (operator table, negations, Not-over-group pushed to the leaves, typed literals); the `genericType` node for casts; the capture wrapper (`limit`, `from`); `origin` on every node; the debug printer | Golden debug-printed output for INNER/LEFT/RIGHT/FULL × {same-named key, different names, multi-key} (FULL with differing key types emits the cast), every filter operator × family, and Not over And/Or/nested groups (De Morgan; double negation cancels)                                                                                                                                                                                                                                      |
| **M1.6**  | Spec v1 codec (§10.3): encode and decode, typed values, schema snapshots, `rest` preservation (top level, per node, meta), Unknown passthrough, migration skeleton, the newer-version read-only flag                                                                                                                                               | Round-trip tests over a fixture corpus (no property-testing library is in the lockfile): `decode(encode(doc))` is deep-equal; re-encoding is byte-identical; an unknown node kind and unknown fields survive the round trip; decode a spec with an Unknown node → delete its upstream node → encode → decode succeeds, and rewiring the Unknown's input elsewhere is refused                                                                                                                     |
| **M1.7**  | **Thin end-to-end, headless** (builder): the `v1/` serializer (IR → protocol JSON with `sourceInformation`), the relation-type adapter, the lossless result reader, `V1_LegendCubeEngine`, the Cube Northwind + ALLTYPES fixture, `LocalModelCatalog`. Engine-backed tests (§11.2 part A)                                                          | Acceptance part A passes against `localhost:6300`, and in CI against the docker engine, wired as §3.4 describes (spied client methods → Cube-local axios helpers, so the lossless reader is exercised). The FULL-join cast, the `toOne()` rule and the FULL merged-key nullability are covered by tests. A v1-seam unit test resolves a quoted, dotted table name                                                                                                                                |
| **M1.8a** | **Editor state and page (no canvas yet):** `CubeEditorState`; the `/query/cube` page; source picker (§6.2.7); grid with execute, stale and limit; Show Pure; Export/Import spec (dev); undo                                                                                                                                                        | jsdom tests against a mocked `CubeEngine` port: the runtime list holds only runtimes whose store keys exactly include the chosen database; flagged tables show their flag and a picked table lands with its schema; Execute renders the port's rows, an edit marks results stale, and a `limit + 1` result shows the truncation warning; Show Pure shows `renderPure`'s text; Export then Import gives the same `serializeCubeSpec` text; Execute is disabled iff the capture subtree is invalid |
| **M1.8b** | **Canvas and editors:** canvas (§7.2–7.3), palette, drag and drop, context menu; editor shell + Join, Filter and Source panels; keyboard shortcuts (§3.5)                                                                                                                                                                                          | jsdom tests: one undo entry per Apply; drop-target legality matches `canConnect`/`canMove`; the editor shows the upstream-invalid warning; F9 triggers execute; Join Apply turns the node valid; edges into binary nodes carry visible Left/Right labels                                                                                                                                                                                                                                         |
| **M1.9**  | Slice acceptance and hardening: manual script (§11.2 part B) on `yarn dev:query` + engine; package READMEs; optional Playwright e2e against the real engine (the `legend-application-studio-e2e` pattern, not query-e2e's mocked engine; none, as settled below)                                                                                   | Part B passes; M1 review sign-off                                                                                                                                                                                                                                                                                                                                                                                                                                                                |

Ordering note: M1.7 runs before M1.8 on purpose, as §20 says ("prove the engine round trip before building the
canvas"). If the canvas misbehaves, it is the canvas. Layout determinism needs no separate test: inputs are sorted
before layout, so it holds by construction.

**Settled at the start of M1.9** (user, 2026-10-08). PR #5591 was approved on `b1c73b640` and waits only on an
incident on legend-studio's side.

- **Where M1.9 lands:** a follow-up PR. M1.9 is built on `cubeV1` and nothing more is pushed to #5591. After #5591 is
  squash-merged, the M1.9 commits move onto master (`git rebase --onto origin/master b1c73b640 cubeV1`, `b1c73b640` being #5591's head as
  merged, not the squash commit; done 2026-10-08 onto `fbde4379f`) and the
  follow-up PR adds a patch changeset for each library package it touches. #5591's description is edited only with
  wording the user approves.
- **Acceptance and sign-off:** first a rehearsal of Part B in a real browser on the final head (with the canvas-fit
  and console checks), fixing what it finds; then the user runs Part B by hand, including the physical keys (Cmd-click,
  F9, Cmd+Z). "M1 review sign-off" is the finos approval of #5591 plus the user's dated OK, both recorded in
  PROGRESS.
- **E2E:** none for now, and none planned as a follow-up. Part B by hand stays the only browser check, and canvas fit
  stays a manual check (§11.2 Part B).
- **READMEs:** short, in the repo's library shape. The how-to guides (adding an operation, testing, hosting) live in
  each package's `docs/` folder, linked from the README, as `legend-query-builder` does. M1.9 writes them; the
  operations work keeps them current.
- **Test gaps:** the two small ones (an empty schema seen from the page, unexpected rejections in the picker) are
  closed, and the two that existing tests already cover leave ISSUES. Independent checking of the grid tests and
  Part A's extras stay in ISSUES.
- **`V1_*` in engine-backed tests:** allowed (user: "we can use V1 outside"). The engine-roundtrip tests in
  `legend-cube-builder/src/__tests__/` need both the `v1/` adapter and `stores/`, which the import-hierarchy lint rule
  keeps apart, so they import `V1_*` from there. Product code keeps the rule (§3.7).
- **Public API:** the builder drops exports nothing outside it uses. The core keeps its `export *`, with a README note
  that the API is unstable before 1.0. **Reversed** (user, 2026-10-08): `@finos/legend-cube-builder` 0.0.2 was
  released with those exports before the follow-up PR, so removing them would break its published API; they stay.
  - **Technical (decided without asking):** the engine port (`CubeEngine.ts`) stays `export *` from the builder,
    though Legend Query names only `CubeEngine` and `CubeModelOutline` today: a host implements or fakes the port, and
    needs its error, result and outline types to do so.
- **Docs trim:** session-only content leaves PLAN and PROGRESS before merge (the resume prompt, local paths, workflow
  run ids), and `path:line` links become links GitHub can follow, after the READMEs take over the setup.
- **Browsers:** Chrome only, with its version recorded. Firefox and Safari are listed in ISSUES as untested.
- **Docker CORS:** waived. There is no docker locally; the acceptance record says it is unchecked.
- **Draft format marker:** the core README says the saved format is a draft until M8, and the "(dev)" labels on
  Export and Import are its marker in the UI (§10.3). _(The labels were dropped on 2026-10-10.)_
- **Undo after a run:** the rows stay marked stale, as §7.8 settles: a restored query is a new object.

### 11.2 Slice acceptance test

**Part A: automated** (`legend-cube-builder/src/__tests__/LegendCubeNorthwind.engine-roundtrip-test.ts`, which
imports `V1_*` under the test exception in §3.7). Items 7 and 8 need no engine: they are core unit tests in
`@finos/legend-cube` (`Join.test.ts`, `CubeMessages.test.ts`, `SchemaInference.test.ts`, `Filter.test.ts`), on
synthetic schemas rather than the resolved fixture.

- **Setup:** one file, so the tests run serially. Northwind's setup drops and recreates its schema on every connection.
- **Assertions:** check **semantics, not SQL text**, because the CI engine image tag moves with every engine merge ✅.
- **Logging:** log `GET /api/server/v1/info` `git.commit.id` for provenance.

1. **Resolve.** Add sources `ORDERS` and `CUSTOMERS` from the Cube fixture and resolve them with one batch call.
   - `ORDERS` has 14 columns, including `ORDER_ID SmallInt` not nullable, `CUSTOMER_ID Varchar(5)` nullable,
     `ORDER_DATE StrictDate` nullable, and `FREIGHT Double` nullable.
   - `CUSTOMERS` has 11 columns.
   - `ALLTYPES` resolves all 12 columns of §6.2.4: `ID Int` (not nullable), then `TI TinyInt`, `SI SmallInt`,
     `BI BigInt`, `F Float4`, `D Double`, `DEC Numeric(10,2)`, `NUM Numeric(12,4)`, `DT StrictDate`, `TS Timestamp`,
     `B Boolean`, `VC Varchar(20)`, all nullable. The parity test (§6.2.6) records the engine's exact answer.
2. **Join schema.** `join101` (INNER, `CUSTOMER_ID = CUSTOMER_ID`).
   - The Cube-inferred schema has **24 columns in §7.11 order**.
   - Name, order, type path and parameters equal `lambdaRelationType` of the emitted lambda prefix.
   - Nullability: Cube ⊇ engine, so Cube may only widen.
3. **Execute.** `filter101` = `SHIP_COUNTRY Equal "France" AND ORDER_DATE GreaterThanOrEqual 1997-01-01 AND EMPLOYEE_ID
In [1, 4]`, captured and executed.
   - **19 rows**, compared **order-insensitively** as the `ORDER_ID` set in §8.5. The slice has no Sort, so row order
     depends on H2's plan, and it differs between join kinds ✅.
   - France alone gives **77 rows**.
4. **Join kinds.** Each case is chosen so that a wrong merged-key side or a missing coalesce fails it.
   - **LEFT:** `CUSTOMERS LEFT_OUTER ORDERS` returns FISSA and PARIS with null `ORDER_ID`, and Cube marks the order
     columns nullable.
   - **RIGHT:** `ORDERS RIGHT_OUTER CUSTOMERS` on `CUSTOMER_ID` returns **832** rows. FISSA and PARIS have a non-null
     `CUSTOMER_ID` taken from the right side and a null `ORDER_ID` ✅.
   - **FULL:** `CUSTOMERS[COUNTRY == 'France'] FULL_OUTER ORDERS[SHIP_COUNTRY == 'Germany']` on `CUSTOMER_ID`, with
     upstream Filter nodes, returns **133** rows: 11 customer-only and 122 order-only, and the merged `CUSTOMER_ID` is
     never null ✅.
   - **FULL with exactly one nullable key** that contains NULLs: `ORDERS` FULL_OUTER the fixture's
     `CUBETEST.CATEGORY_REGION` on `SHIP_REGION` (NOT NULL there, §6.2.4). The 507 NULL-key orders survive with a
     NULL merged key. Cube marks the key nullable; the engine says `[1]` ✅.
5. **Precise literals** on `ALLTYPES` (rows in §6.2.4):

   - `BI > 15000000000` → [2], with `BI` read back losslessly as `"9007199254740993"`;
   - `DEC > 1.5` → [1];
   - `TS >= 2024-01-02T12:00:00` → [2];
   - `B == true` → [1];
   - `B NotEqual true` → [2, 3] (D4: the NULL row is included);
   - `D == 2.5` → [1] (exact equality on a real `DOUBLE`).

   These expectations follow from the new rows by construction and get verified in M1.7.

6. **D4 NULL semantics:**
   - on ORDERS, `NOT (SHIP_REGION Equal 'BC' OR SHIP_COUNTRY Equal 'France')` returns **736** rows (Not pushed to the
     leaves);
   - `SHIP_REGION NotEqual 'BC'` returns **813** rows (NULL rows included);
   - `CUBETEST.EMP_REGION ⋈ CUBETEST.CUST_REGION` on `REGION` (copies of the `EMPLOYEES` and `CUSTOMERS` columns,
     §6.2.4), both keys nullable, returns **15 / 19 / 103 / 107** rows for INNER / LEFT / RIGHT / FULL, with no
     NULL×NULL pairs ✅.
7. **Negatives, all local (no engine call needed):**
   - `ORDER_DETAILS ⋈ PRODUCTS` on `PRODUCT_ID` gives
     `Duplicate column names between inputs are not supported if they are not part of the join columns: "UNIT_PRICE"`.
   - `CUSTOMERS.CUSTOMER_ID ⋈ ORDERS.EMPLOYEE_ID` gives `Join columns "CUSTOMER_ID" and "EMPLOYEE_ID" must be of compatible types.`
   - `EMPLOYEE_ID Equal "abc"` gives `Filter value "abc" is not a valid SmallInt.` (the raw text is kept, §4.9)
   - `EMPLOYEE_ID Equal 100000` gives the out-of-range error.
8. **Propagation.** Disconnect the right input: `ERR_INCOMPLETE` on the join, `ERR_SCHEMAS` on the filter.
9. **Error mapping.** Resolve with a stale schema snapshot that names a removed column. The engine compile error comes
   back with `sourceId` `cube:filter101:…` and lands on `filter101`.
10. **Spec round trip.** `encode` → `JSON.stringify` → parse → `decode` → re-emit gives deep-equal protocol JSON, and
    executes to the same 19 rows.
11. **Golden shape.** The emitted protocol JSON, with `sourceInformation` stripped, equals the engine's own
    `grammarToJson` of the golden Pure text in §8.5. The golden text is adapted to the fixture and includes the
    capture wrapper (`->limit(rowLimit + 1)`). Collections carry `multiplicity`, as the engine's parser writes it.

**Part B: manual, in the UI**

Prerequisites:

- **An engine on :6300** that allows LocalH2 (the bundled model sets up Northwind in H2 through `testDataSetupSqls`).
  Either IntelliJ (`org.finos.legend.engine.server.Server`, no arguments) or the repo's docker compose:
  `cd fixtures/legend-docker-setup/grammar-test-setup && docker compose --file=grammar-test-setup-docker-compose.yml up --detach`.
  Check it with `curl -s localhost:6300/api/server/v1/info`, and record its `git.commit.id`.
- **CORS** from `localhost:9001` is verified for the IntelliJ engine only. For docker it is waived (user, 2026-10-08):
  the record says it is unchecked.
- **The dev server:** run `yarn build` once (`yarn dev:ts` doesn't build the stylesheets, `lib/index.css`), then
  `yarn dev:ts` and `yarn dev:query`.
- **Chrome**, with its version recorded. Other browsers are untested (ISSUES.md).

The script avoids exact comparisons on the fixture's 32-bit `REAL` columns (§6.2.4).

1. Open `http://localhost:9001/query/cube` and open the picker: **Add Items ▾ → Relational Database Table**, the
   palette's **Relational Database Table**, or the canvas's "Connect to a source" link, then its **Sample Data** tab
   (M3b). On the **Sample Data** tab, Dataset ("Northwind"), Database, Runtime
   (`showcase::northwind::mapping::StoreRuntime`) and Schema **NORTHWIND** fill themselves (Cube's test tables aren't
   offered: they are in the test fixture only).
2. Add **ORDERS** (14 columns). The picker closes after each **Add**, so open it again and add **CUSTOMERS** (11
   columns). Both land on the canvas with their
   schemas; ORDERS, the first table, has the accent ring of the node Execute runs. Click ORDERS: its editor's
   Columns table shows `ORDER_ID SmallInt` and `CUSTOMER_ID Varchar(5)?` (CUSTOMERS' own `CUSTOMER_ID` is its key,
   with no `?`).
3. Drag **Join Another Input** from the palette onto empty canvas. It goes after ORDERS, the selected node (M3b,
   U3): ORDERS feeds its upper input (Left), and it becomes the node Execute runs. It shows incomplete (a dashed amber
   border). Drag from CUSTOMERS' output handle (its right side) to the Join's lower input handle (Right). The Join
   now shows invalid (a red border); its tooltip and, once you click it, the red text at the
   bottom of its editor say "Left join columns cannot be empty."
4. Click the Join. Set **Join type** to **Inner** (a new Join is Left Outer, which gives the same 19 rows here, so the
   row count can't catch it), click **Add join columns**, pick `CUSTOMER_ID` on both sides and **Apply**. The Join
   turns valid, and its two input edges are labelled Left and Right.
5. Drag **Filter by Column** onto the Join: it splices in after it. Click the Filter; it has one blank condition. Build
   three rules with **Add condition**, each a column, an operator and a value: `SHIP_COUNTRY` **is** `France`,
   `ORDER_DATE` **is greater than or equal** `1997-01-01`, and `EMPLOYEE_ID` **is in list of** `1`, `4`.
   - `SHIP_COUNTRY`'s operator list (a string: is, starts with, contains, …) differs from the list `ORDER_DATE` and
     `EMPLOYEE_ID` share (is, is greater than, is less than, …).
   - A value is "(blank)" until clicked; type it, then Enter or click away. The date is the browser's date field. An
     In list adds a value each time you fill the "(blank)" box below its values; each value has an × to remove it.
   - Typing `abc` for `EMPLOYEE_ID` is flagged inline: a red border, and the tooltip says
     `Filter value "abc" is not a valid SmallInt.` Remove it.
   - **Apply** stores the three rules as one step.
6. Make the Filter the node Execute runs: Cmd-click it on macOS (Ctrl-click opens the context menu there), Ctrl-click
   elsewhere, or **Select** in its context menu or editor header (which then reads "(Selected)"). Press **F9** (Fn+F9
   on some Mac keyboards) or click **Execute**. The grid toolbar shows "19 rows in …", the `ORDER_ID` set of §8.5.
   Record how the node was selected and how F9 was pressed.
7. Edit the filter (e.g. remove a rule) and **Apply**: the toolbar shows "Stale: execute again to refresh". Click
   **Undo** (or Cmd/Ctrl+Z with the focus outside a text field and no Cube dialog open): the three rules come back,
   and the rows stay marked stale, by design (§7.8: a restored query is a new object). **Show Pure** opens "Pure
   query" with the lambda. It ends in `->from(showcase::northwind::mapping::StoreRuntime)`, with no `->limit(…)`:
   Execute fetches one more than the row limit to see if there are more rows, and Show Pure leaves that out.
8. **Export**, then **Download** (`cube.cube.json`, since the cube has no name), and close. Reload the page:
   the cube is gone. **Import**, click **Choose File** and pick the downloaded file (or paste its text into the
   "Cube spec" box), then **Import**. Import never executes:
   the same graph comes back (`relational101`, `relational102`, `join101`, `filter101`, with the Filter still
   selected), and **F9** gives the same 19 rows. A second Export gives the same text as the downloaded file.

Also check and record:

- **Canvas fit:** every node stays inside the canvas and the minimap covers none, after each node is added, after the
  Join is connected and the Filter spliced in, after the editor opens and closes, after Import, and after
  height-only changes: dragging the splitter between the graph and the grid, and changing the window's height.
- **No watermark** on the grid (D3: `localhost` shows none).
- **The console, through the whole run**, at the Default and Verbose levels: outside production builds,
  `legend-lego`'s DataGrid sends `console.error` to `console.debug` from each render until the grid is ready
  (Appendix B), so an error logged while a run's grid loads shows only as Verbose. Expected: React 19's "Accessing element.ref was removed"
  from `react-reflex` when a splitter moves (Appendix B), and the Query ServiceWorker's
  "fetching the script" errors. Anything else is recorded.

**Part B2: sources, manual, in the UI** (the direct connection, data products and ingest data sets, §6.7, §6.8)

Prerequisites: as Part B. Data products also need a Query configured with a lakehouse and a depot that serve deployed
data products (an internal deployment); without a lakehouse the page shows no Data Product item. Two optional Query
keys turn on the rest (hosting.md):

- `marketplace.serverUrl`, the same value as Legend Marketplace's own `marketplace.url`: search runs on the
  marketplace server. Without it, the list is the lakehouse's lite list and search filters it in the page.
- `extensions.core.dataProductConfig.publicStereotype`, the same stereotype Studio and Marketplace use: groups open
  to everyone show **Enterprise access**, and groups with no contract show **No access**. Without it, only groups
  with a contract show a badge.

Direct connection:

1. Open the dialog from the palette's **Relational Database Table**, and click the **Direct Connection** tab. The
   form starts on H2 with a sample setup SQL. Click **Test connection**: schema `CUBE_SAMPLE` is chosen, and its
   tables list CUSTOMERS (3 columns) and ORDERS (4 columns).
2. Add **ORDERS**; open the dialog again (it reopens on the Direct Connection tab, with the Sample Data and Data Product
   tabs disabled) and add **CUSTOMERS**. Click ORDERS: the Source panel shows the connection's summary ("H2: an H2
   database in the engine's H2 server, 6 setup statements, authentication h2Default"), never its setup SQL.
3. Join them on `CUSTOMER_ID` (Inner), make the Join the node Execute runs (Cmd/Ctrl-click it, or **Select**), and
   press **F9**: 4 rows, with CUSTOMERS' `COMPANY_NAME` and `COUNTRY` columns. Add a Filter `COUNTRY` **is** `Germany`
   after the Join, select it and press **F9**: 2 rows, `ORDER_ID` 10248 and 10251.
4. On a new cube (reload), choose **DuckDB**, leave the file empty (in memory), give setup SQL such as
   `drop schema if exists s cascade; create schema s; create table s.t (a INTEGER); insert into s.t values (1);` (one statement per line, each ending
   with `;`), **Test connection**, add `t` and press **F9**: 1 row.
5. **Export** and **Import** the H2 cube: the same graph comes back, and **F9** gives the same rows.
6. On a new cube, choose **DuckDB**, open **Load a CSV**, paste `id,city` / `1,Paris` / `2,Lima` (three lines), name
   the table `cities` and click **Add to setup SQL**: the setup SQL now creates `csv.cities`, and the tab says "Added
   table csv.cities: 2 rows, 2 columns". **Test connection**, pick schema `csv`, add `cities` and press **F9**: 2 rows.
   Choosing a `.csv` file fills the box and the table name the same way.

Examples (§6.9):

1. On a new cube, the canvas reads "No tables yet: add a table to start, or open an example." Click **open an
   example** (or the header's **Examples** button): the Examples dialog shows a grid, a row per dataset (Northwind,
   Sports, Trades), each with its card and two example cards, each example with its steps as icons.
2. Click **Top watched sports**: the dialog closes, the cube is named after the example, its tables are typed, and it
   runs: 10 rows, one per sport, by total viewers. **Undo** brings back the empty cube.
3. Open each other example the same way: Top customers by orders (10 rows), Products in stock by category (8), Most
   watched finals in Europe (up to 10), Notional by desk and asset class (9), Largest buys (20). Each replaces the
   cube before it.
4. Click the **Trades** dataset card: a new cube on the Trades model, with the source dialog's Sample Data tab open on it
   (DESKS, INSTRUMENTS, TRADES). Add **TRADES** and press **F9**: 400 rows. **Undo** brings back the cube before.
5. **Export** an example and **Import** it: the same graph comes back, and **F9** gives the same rows.

Data products:

1. The palette shows **Data Product** with a BETA badge. Click it: the dialog opens on the **Data Product** tab, Mode
   **Production**. The deployed products list; search narrows it (with `marketplace.serverUrl`, on the marketplace
   server: a search with more than 100 matches says "Too many matching items; list truncated.").
2. Pick a product: its access points show by group, each group with your access as the marketplace shows it
   (**Entitled**, a pending state, **Enterprise access** or **No access**). A parameterized access point is disabled
   and says why. Pick an access point: the preview shows its description, its columns with their types and sample
   rows; **Open in Marketplace** opens the product's page in the marketplace of its class (production or production
   parallel). The warehouse reads `LAKEHOUSE_CONSUMER_DEFAULT_WH` (or the one you last used); change it if needed.
3. Add an access point. Open the dialog again: it opens on the same product with its access points shown, the Mode
   and warehouse are fixed, and **In this project** lists only that project's products at that version. **Search
   all** lists the others too, greyed, each saying why ("Belongs to project …", "Deployed from version …"). Add a
   second access point of the same product.
4. Join the two on a shared key and press **F9**: rows come back. **Show Pure** shows two `#P{…}#` accessors and
   `->from(cube::dataProduct::Runtime)`.
5. Click an access point's node: the Source panel shows the environment, the data product with **Open in
   Marketplace**, the group, the project and its version (a `-SNAPSHOT` version is labelled as one that may change).
   Type another warehouse and click **Apply**: the grid's rows are marked stale, and **F9** runs on it. **Undo**
   (Ctrl/Cmd+Z) brings the old warehouse back in one step. A new cube then starts on the warehouse last applied.
6. Apply a warehouse that doesn't exist and press **F9**: the error says the run couldn't use that warehouse and
   points to the Source panel. If you have an access point you aren't entitled to, run it: the error offers **Request
   access to <group> in <data product>**, linking to the marketplace.
7. Click **Refresh** in the Source panel: the access point's columns are read again from the deployed artifact (no
   warning when nothing changed).
8. On a new cube, choose Mode **Production (parallel)**, add an access point and press **F9**.
9. **Export** and **Import** the first cube: the same graph comes back, its access points are re-checked
   against their deployed artifacts, and **F9** gives the same rows.

Ingest data sets (Query's `lakehouse.platformUrl` set; without it the page shows no Ingest Dataset item):

1. The palette shows **Ingest Dataset** with a BETA badge. Click it: the dialog opens on the **Ingest** tab, Mode
   **Production**, and shows your environment, read-only. Pick a producer deployment: its deployed definitions list
   with their project (`group:artifact`); search narrows them. Definitions deployed ad hoc, or to another environment,
   are counted below the list, not shown.
2. Pick a definition: its data sets list with their column counts; a materialized view is disabled and says why. Pick
   a data set: its columns show, the declared ones then `LAKE_DIGEST`, `LAKE_IN_ID` and `LAKE_OUT_ID` (and
   `LAKE_FROM`, `LAKE_THRU` for a business-temporal one). The warehouse reads `LAKEHOUSE_CONSUMER_DEFAULT_WH` (or the
   one you last used); change it if needed. Tick another definition's checkbox (or Cmd-click it): it opens beside the
   first, each one's data sets under its name and its only data set picked, so one Add adds data sets of both; a plain
   click opens one alone again (user, 2026-10-15; Studio's project screen too).
3. Add the data set. Open the dialog again: the Mode, producer deployment and warehouse are fixed, and the Model,
   Direct Connection and Data Product tabs are disabled. Add a second data set of the same producer.
4. Join the two on a shared key, add a Filter `LAKE_OUT_ID` **is** `999999999` (or the current-rows marker your
   deployment uses) before the Join if they are batch-milestoned, and press **F9**: rows come back. **Show Pure**
   shows two `#I{…}#` accessors and `->from(cube::ingest::Runtime)`.
5. Click a data set's node: the Source panel shows the definition (its URN on hover), the data set, the environment,
   the producer deployment and the warehouse. Apply another warehouse: the rows are marked stale; **Undo** brings the
   old one back. Apply one that doesn't exist and press **F9**: the error says the run couldn't use it, beside the
   warehouse and in the run's error. Click **Refresh**: the columns are read again (no warning when nothing changed).
6. On a new cube, choose Mode **Production (parallel)**, add a data set and press **F9**.
7. **Export** and **Import** the first cube: the same graph comes back, its data sets are re-checked
   against their definitions, and **F9** gives the same rows.

Record the deployment, the products, access points and data sets used, whether the optional keys were set, and any
console errors.

**Part B3: Depot databases, manual, in the UI** (§6.3)

Prerequisites: the engine on :6300 and `yarn dev:mock-depot-server` (the mock depot on :6200, where the local engine
fetches projects), and Query on its dev server (its `depot.url` points at the mock). In a deployment, use a published
project with a Database and a runtime.

1. Open the dialog (**Add table**) and click the **Project Database** tab: the projects list. Pick
   `org.finos.legend.cube.samples:cube-sales`: its versions read `1.10.0 (latest)`, `1.9.0`, `1.0.0` (no SNAPSHOT), the
   newest picked. Its Databases are `SalesDb`, `ArchiveDb` and `PlannedDb`, not `cube-reference`'s `CurrencyDb`.
2. Pick `SalesDb`: its one runtime is picked for you. Pick `PlannedDb`: the tab says no runtime connects to it, and
   **Add** stays disabled. Pick `ArchiveDb`: two runtimes; choose one.
3. Pick `SalesDb`, schema `SALES`, table `ORDERS`, and **Add**. Press **F9**: 6 rows, with a `STATUS` column. Open the
   dialog again: it reopens on the Project Database tab with the project and version fixed, the other tabs disabled.
4. Click the table's node: the Source panel shows the project and version above the Database.
5. On a new cube, pick version `1.0.0` and add `ORDERS`: no `STATUS` column; `RETURNS` is listed only from 1.10.0.
6. **Export** the cube and **Import** it: the same graph comes back, and **F9** gives the same rows.
7. Stop the mock depot, open a new page and pick the Project Database tab: it says it can't list the depot's projects, with
   **Retry**. Start the mock again and click **Retry**.

### 11.3 After the slice (recommended order, outline)

| #    | Milestone                                  | Contents                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| ---- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| M2.0 | legend-graph types (D12)                   | Fix legend-graph's precise primitives as their own PR to master: resolve by full path as well as short name, fix the `Timestamp` path (now a relational class), deprecate the phantom `Decimal`/`Date`/`Time` precise constants, keep parameters through `getLambdaRelationType`, fix its batch variant. Then rebase `CubeType` on legend-graph's `GenericType`; the core may depend on legend-graph's metamodel (never `V1_*`); update §3.3 and Appendix A (§2.2). Needed when Cube types tables locally; no longer gates M3's sources (user, 2026-10-08, §6.8)     |
| M2   | Simple unary transforms + Join autofix     | Rename (§7.5 + collision fix; regex replaced, see Appendix A), the **Join rename autofix** (collision-free names), Restrict (input order), Sort (+ "Sort only affects output at the sink" warning), Distinct, Limit, Drop, Slice (`[start, stop)`); the database workarounds of §11.4 (row numbers for Drop and Slice on SQL Server, Sybase and Sybase IQ, for Drop on DB2, MemSQL and ClickHouse and for every Limit on Sybase IQ; a padded Distinct on SQL Server and Sybase IQ); grid quick actions (Sort by / Filter by X)                                       |
| M3   | Entry points, sources modal, depot catalog | The direct connection first, then data products (§6.8, moved up from M9). D7 follow-up: entry links (setup action, editor menu, deep links `/cube/new?…`), source-modal redesign, final look; the depot catalog (§6.3) with an SDLC-pointer model context and exact-store runtime filter; SNAPSHOT handling                                                                                                                                                                                                                                                          |
| M3b  | Canvas and layout                          | The node editor floats below its node (§12.2 item 1, moved here); finishing an edit; where an added step lands; palette click and 'Add Items ▾'; the source dialog's entry points; the node tooltip; entry links for data product access points (§11.8)                                                                                                                                                                                                                                                                                                              |
| M4   | Group and Concat                           | Aggregations (§10 with the §5.7 result-type rules, availability per family) and Count rows, `aggregate()` for global groups, the grid's Group by; Concat with precise-strict schema equality, Convert types (a type-only cast within numbers, strings or dates) and the Rename and Restrict autofixes; a conformance suite comparing local inference with `lambdaRelationType` for every node type, exact on nullability but for each case's declared wider columns (§11.5)                                                                                          |
| M5   | Partition (windows)                        | Partition (`Apply Window Functions`): the functions a Group offers, Count rows included, plus Rank, Dense Rank and Row Number, which need a sort; D5's default frame, with no frame setting; windowed Distinct Count and Distinct Value natively; the array form, aggregates and ranks in separate extends, and `size()` counts; §8.6 `let` isolation of every Partition that isn't the capture; the **dialect harness**: plans pinned on the 17 window database types (`WINDOW_SHAPES`); runs on H2 and DuckDB, and a composition suite against a reference (§11.6) |
| M5b  | More window functions                      | Lag and Lead (an offset), NTile (buckets), Percent Rank, Cumulative Distribution, First and Last (the partition's, Last over the reversed sort), all needing a sort; planned on the 17 window types (§11.9)                                                                                                                                                                                                                                                                                                                                                          |
| M6   | Difference and Extend                      | Difference (`Compare Column Values`) with the spec's rows and native types, a FULL join; Extend (`Extend Columns`) edited inline, a row per column with a Monaco editor, its text a full lambda (`x \| …`), each column able to use the ones above; JSON-canonical storage with the text; typing by the engine over the cube's model, in the background, again when the input the engine sees changes; Validate (F10) with plan-time checks (§11.7)                                                                                                                  |
| M6b  | Filter by expression                       | A node, `Filter by Expression`, keeping the rows for which one Pure condition (a full lambda) is true; checked by the engine over the cube's model as an Extend is typed, and planned for the cube's database on Validate (§11.11)                                                                                                                                                                                                                                                                                                                                   |
| M6c  | Pivot                                      | A node, `Pivot by Column`, through the engine's static `pivot()`: one pivot column, values loaded from the engine and picked, one aggregation, columns named by the value; refused, and greyed in the palette, on databases that can't run it (H2 and most others; Snowflake, Databricks, SingleStore and DuckDB can); data product cubes report Snowflake. PLAN §11.12                                                                                                                                                                                              |
| M6d  | Operations cleanup                         | The small bugs (row limit text, a Join's shared columns in any case, the grid's Filter by on a 32-bit float), no backtick in names, Min and Max on strings and Booleans, Distinct Count and Distinct Value on enumerations, `startsWith`/`endsWith`/`contains` given a non-literal pattern refused unless on Snowflake (§11.14)                                                                                                                                                                                                                                      |
| M7   | Grid and presentation                      | Server-side mode by default (enterprise SSRM) with a drill-down derived as Cube's own nodes, whole levels cut at the row limit, local mode as a toggle; CSV of the whole query through the engine, XLSX of the rows shown; the curated menu, the Runs panel, §13 column formatting with the §21 fixes, widths (§11.10)                                                                                                                                                                                                                                               |
| M8   | Persistence                                | Engine Cube store PR (§10.6), Studio client, `CubeStore` port, Save/Load/Copy/Paste, `/cube/:cubeId`, modified state, `beforeunload`                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| M9   | More sources                               | Services → Pure functions, with parameter forms (§17.6); data products and ingest moved to M3 (§6.7–6.8)                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| AN   | Legend Analytics                           | Cube as its own application (§11.19): `legend-application-analytics`, its bootstrap and deployment at `/analytics/`, one `LegendCubeHost` shared with Legend Query; later, saved cubes and deep links after M8, Data Cube's grid and the Legend Query node                                                                                                                                                                                                                                                                                                           |
| —    | Out of scope                               | Publishing and service registration (§15); V0 import                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |

M2 comes before M3 because it is cheap, testable headlessly, and gives the POC real breadth while the entry points
and sources modal are designed. M3 can run in parallel if desired.

**Every milestone that changes the UI ends with a demo video** of its new features (user, 2026-10-09), made as M1's
and M2's were: a Playwright script in the evidence folder's `demo/` (e.g. `demo-m2.mjs`) drives the dev server against
a local engine, with a caption per step and a screenshot per key moment; reviewers check each frame against its
caption, and every claim in a caption must be visible on screen; the video goes to the user and onto the milestone's
PR. Its step comes after the milestone's verification and rehearsal, before the rebase and PR.

### 11.4 M2: simple unary operations

M2 was built on the branch `cube-ops`, on master since #5634 (M1.9) merged as `3260216a6`, and merged on 2026-10-09 as #5644 (`0335b3f5f`). Its status is in [PROGRESS-M2.md](PROGRESS-M2.md), not in PROGRESS.md. Requirements: `m2-requirements` (5 readers, a merge, a
critic and a finalize step), 140 checklist items, a 17-step build order and 5 questions; the full result is kept in the
local evidence folder. The engine facts below were probed on the local engine (`93d92b4`) at compile and plan time ✅.

This subsection overrides the sections it names until they are updated (see "Supersessions" at its end).

**Settled at the start of M2** (user, 2026-10-08, all on the requirements' recommendation):

- **Base.** `cubeV1` had been rebased onto master `6041ba413` (#5631, just after the 0.0.2 release `43b06778a`,
  #5628) since `cube-ops` was created, and
  `cube-ops` had no commits of its own, so it was re-pointed at `cubeV1` (`b9923ed28`): the signed-off M1.9 code, its
  guides and the exports 0.0.2 published. #5634 was squash-merged the same day (`3260216a6`, the tree of
  `0807adb12`), and M2 was rebased onto master with `git rebase --onto origin/master b9923ed28 cube-ops`. The base's
  one commit #5634 didn't carry (`b9923ed28`, the DuckDB WASM note in §12.2) stays on `cubeV1`, where a later rebase
  made it `81cf0d80c` (the same patch).
- **First operation:** Limit, end to end (core, codec, emitter, editor, engine test, browser), before the others. It
  has a setting, a draft, a codec number, a message, an integer literal and an editor input.
- **Where Sort's ORDER BY goes (answers §12.2 question 2): where the order is used.** Probes of
  `<table>->sort(…)->X->limit(1001)` on H2, Postgres, SQL Server, Snowflake, DB2 and Oracle:

  - the ORDER BY stays in the outer query through Filter, Distinct, a Restrict that keeps the sort keys, and another
    Sort (sorts merge, the later keys first);
  - it moves into a derived table through any Rename, a Restrict that drops a key, and a Join on either side. SQL
    Server rejects an ORDER BY in a derived table without TOP;
  - after a Limit, Drop or Slice, the capture's own `limit(rowLimit + 1)` hides the display order.

  So a Sort node emits nothing where it stands. Cube tracks the row order through the graph (a host-free row-order
  module; keys follow Renames) and emits `sort(<keys>)` just before a Limit, Drop or Slice that takes rows by that
  order, and before the capture's limit: `{| <relation>->sort(<order>)->limit(rowLimit + 1)->from(runtime)}`. That
  SQL is correct on every database probed at the start of M2; Sybase IQ, probed in M2.16, numbers a Limit's rows by the
  first sort key only, which the database workarounds below cover. Show Pure shows the sort near the end. Typing
  lambdas carry no sort.
  The warning fires only when the order is lost: a Join, a Restrict that drops sort keys (partial loss names the
  columns), or a later Sort on all the same columns. It is derived, never stored and never a validation error, so
  Execute stays enabled.

- **Databases whose engine SQL is rejected or takes the wrong rows: detect them from the runtime.** For a Drop or
  Slice inside a query (Cube's always is, under the run's own limit), the engine writes `limit m,-1` / `limit m,n`,
  which SQL Server and Sybase reject (Drop also DB2), or numbers the rows itself by the first sort key only, in a column
  it always names `row_number` (Sybase IQ Drop, Slice and Limit; MemSQL Drop: `rewriteSliceAsWindowFunction`), which
  takes any of the tied rows and clashes with an input column of that name; after a descending key, ClickHouse writes
  a Drop's `nulls firstoffset m` as one word. It writes a Distinct before a limit as `select top N distinct` on SQL Server, which T-SQL
  rejects, and on Sybase IQ numbers a later Limit's rows inside the `select distinct`, which then removes nothing
  (💭, plans only). The model outline gains each runtime's connection database types; Execute and Show Pure pass the
  capture's type to the emitter, waiting for the cached outline only when the capture subtree has a Drop, Slice, Limit
  or Distinct (a Limit only needs it on Sybase IQ, but the wait is short and the outline is cached). Those databases get:

  - Drop and Slice through row numbers (SQL Server, Sybase, Sybase IQ; Drop also DB2, MemSQL and ClickHouse), and every
    Limit on Sybase IQ (M2.16):
    `->extend(over(<order>), ~[cube_rn: {p, w, r | $p->rowNumber($r)}])->filter(<range>)->select(<input columns>)`,
    `over()` sorted by the input order, else by the first sortable column; row numbers count from 1;
  - Distinct padded on SQL Server and Sybase IQ: `->distinct()->extend(~cube_d: x | 1)->select(<input columns>)`.

  Every other database keeps the native form, and so does an unknown type. The workarounds are data
  (`CubeDialects.ts`, read through a `Map`, never a plain object keyed by a model-supplied name); BigQuery joins the
  table only after a plan probe, and Spanner never gets the row-number form (no window columns). The outline's
  runtimes gain an optional `connections` list of `{storePath, databaseType}` (a list, never a record keyed by store
  path). Execute and Show Pure set their run or request state before they wait for the outline, and check it after,
  so a second F9 is ignored and Stop can abort while the outline loads. The engine defects are drafted in ISSUES.md
  for the user to file. Temporary columns (`cube_rn`, `cube_d`) and a Rename's new names count a name taken in any
  case, as SQL Server, MemSQL and DuckDB see one column (M2.16).

- **Slice's wording:** the canvas reads `Take rows 10 to 20 (20 excluded)` (`Take rows 10 to (blank)` when the stop
  is missing); the help text reads "Reduces the number of rows in the previous data set, keeping only the rows from
  position "start" up to, but not including, position "stop", counting from 0."; the editor hints "Rows count from
  0: the start row is kept, the stop row is not." The palette label stays the spec's `Take rows <x> to <y>`.

**Decided without asking** (technical choices from the requirements, listed for review):

- **Nodes.** One immutable `UnaryNode` class per operation in `src/nodes/transforms/`: `Sort`, `Restrict`,
  `Rename`, `Distinct`, `Drop`, `Limit`, `Slice` (types `sort` … `slice`, spec §7.0). Registered in spec menu order
  (Sort, Filter, Restrict, Rename, Distinct, Drop, Limit, Slice, Join), with the spec's palette labels, `<x>`
  included. A type is registered in the step that adds its editor, help text and icon. Constructors refuse only
  wrong shapes (an unknown direction, NaN, Infinity); every other invalid state is constructible and reported by
  validation. No M2 node redacts anything: names and sizes are not values.
- **Clearable settings.** Limit and Drop take `(id, size: number | undefined)` and Slice `(id, start, stop)`, with no
  JS defaults (a default parameter also replaces an explicit `undefined`); the defaults (10; 10 and 20) live only in
  `create(id)`. A cleared field is `undefined` and invalid ("Size must be a positive whole number."), never the
  default. Size 0, Drop 0 and an empty Slice stay invalid, as the spec says, though the engine accepts them. Slice
  checks each bound, then `start < stop` (the engine plans a negative fetch otherwise); it has one edit method,
  `withRange(start, stop)`.
- **Sort.** Directions `ASC`/`DESC`, labelled Asc/Desc. "Sorts cannot be empty."; each column must exist; Cube
  adds "Sort column "X" of type Variant cannot be sorted." (VARIANT and OPAQUE, through one exported
  `isSortableType`, also used by the editor's picker, the Sort by quick action and the row-number fallback's default
  key) and "Sort columns cannot have duplicates.". The two Sort direction messages stay in the catalogue, unreachable.
  _(§11.20: a Variant column stays unsortable, ungroupable and with no comparison; Explode Array is the one
  operation that takes one: "Explode column "X" of type T is not a Variant (JSON) column." for any other, and its
  element is a nullable Variant.)_
- **Restrict.** Its messages use the labels `Columns` and `Column`. The output keeps the input's order; the editor
  stores picks in input order.
- **Rename.** The column-name rule (`src/schema/ColumnName.ts`, shared with the autofix, Group and Extend):
  non-empty, trimmed, no `"`, no `\`, no control characters, at most 128 code points. A backslash compiles in the
  rename but breaks every later reference to the column ✅; `"` and control characters compile but are banned by
  Appendix A's rule. Failures use the catalogue's "New column name is not valid column name.", and the editor shows
  the rule under the field. A mapping may not share its old or new name with another mapping (no swaps or chains),
  and its new name may not be an untouched input column: "New column name "X" is already present in the input
  schema." (the collision fix). Names are never trimmed. Postgres truncates identifiers at 63 bytes (💭); recorded in
  ISSUES.
- **Join autofix.** For each duplicate column `c`, a Rename before each input gives `c_1` (Left) and `c_2` (Right),
  then `c_<side>_2`, `_3`… when a name is taken in either input or already generated; names are cut to 128 code
  points. Key lists are rewritten through the same renames, so a duplicate that is a key at another position, or
  crossed keys, still give a valid join. New Renames are always added, never merged into existing ones. One query
  change, one undo step, the selection kept; the panel's edits are applied first. Core API:
  `planJoinDuplicateFix`, `canFixJoinDuplicates`, `fixJoinDuplicates` (`JoinAutofix.ts`, not on `Query`).
- **Row order.** `outputOrder` and `consumesInputOrder` on nodes, `computeRowOrders(query)`: a source and a Join give
  no order, Filter, Distinct, Limit, Drop and Slice pass it on, Rename renames its keys, Restrict keeps the longest
  prefix whose columns it keeps, Sort puts its keys first, and only an Unknown node leaves it unknown. The warning
  (`findLostSortOrders`) and the emitter both use it.
- **Saved spec.** `{sorts: [{column, direction}]}`, `{columns: […]}`, `{mappings: [{from, to}]}`, nothing for
  Distinct, `{size}` and `{start, stop}`. Lists are always written, empty ones too. Sizes and indexes are JSON numbers
  (strings are for literal values only), written whenever set, defaults included; a cleared one is left out, never
  `null`, so it reads back as cleared and the panel counts clearing a default as an edit. An unknown non-empty
  direction, or an unknown key on a sort entry or rename mapping, makes the node an Unknown node (it could change the
  rows); an empty direction is a decode error, as an empty `joinType` is. A future key that changes rows on a node
  with no list (e.g. Distinct gaining `columns`) needs a new kind or a format version. Samples: one shared
  `operations.cube.json` extended by each step, plus `join-autofix.cube.json`.
- **Emission.** New roles: `take` (a Limit node, distinct from the capture's `limit`), `drop`, `slice`, `distinct`,
  `sort`, `sortKey` (stamped with the declaring Sort), `captureSort`, `rowNumber`, `rowRange`; Restrict reuses
  `select`, Rename `rename`. `EmitContext` gains optional `inputOrder` and `databaseType`, `ExecutionOptions` an
  optional `databaseType`, the builder's `CubeOutlineRuntime` an optional `connections`, and `emitRelation` an
  options bag that keeps typing (no sort, no dialect) apart from execution. Size literals are plain digits. Temporary columns (`cube_rn`, `cube_d`) get a numeric suffix until no
  input column has the name. The debug printer braces colSpec lambdas with several parameters (the bare form doesn't
  parse). The `v1/` serializer needs no change.
- **Editors.** Distinct registers an editor and no draft (PLAN §7.4 item 2 covers a type with nothing to edit) and
  is listed in `CUBE_NODE_TYPES_WITHOUT_SETTINGS`. Integer fields keep the typed text and read only `^[+-]?\d+$`
  (empty is cleared, never a default); Enter and Escape get no special handling, and Ctrl+Z is the field's own undo.
  Sort and Rename start with one blank row. Controls are found by stable labels: "Rows to keep", "Rows to drop",
  "Start row index", "Stop row index", "Sort column <n>", "Sort direction <n>", "Old column <n>", "New column name
  <n>", "Columns to keep". New editors work in either host
  (§12.2 question 1): their lists scroll on their own, pickers stay native selects, and nothing measures the panel.
  Icons from legend-art: Sort `SortIcon`, Restrict `DataCubeIcon.TableColumns`, Rename `PencilIcon`, Distinct
  `CompressIcon`, Limit `AlignTopIcon`, Drop `AlignBottomIcon`, Slice `AlignMiddleIcon`. Column tracing for the
  Join's "type unknown" warning learns Rename (`findColumnOrigins`).
- **Grid quick actions** (spec §12.4, in the client-side grid from M2, before ag-grid's own menu items, which stay
  until M7 curates the menu; no icons; Group by waits for M4). "Sort by "X"" adds an ascending Sort and "Filter by
  "X"" an Equal on the clicked value (IsEmpty on a null cell; a trailing `+0000` dropped; the floating-point hint on
  Float columns), after the selected node, as one undo step that selects the new node. They never execute: the rows
  turn stale. They are enabled only while the rows shown are from the current query and no run is in progress; Sort
  by is disabled on unsortable types, Filter by when no value can be built.
- **Tests.** M2's engine tests are a new `LegendCubeOperations.engine-roundtrip-test.ts`; Part A's file gains
  nothing but the outline's `connections` in its runtimes expectation (M2.13). A plan-only dialect test (`generatePlan`) runs on a test-only copy of the fixture model with one
  connection per database type. Rows are compared as sets unless a Sort reaches the capture node. The M2 browser
  rehearsal is a new evidence-folder script on :9002; still no e2e in the repo.
- **Process.** Steps are numbered from M2.1 (M2.0 is legend-graph types). One changeset,
  `legend-cube-unary-operations` (both Cube packages, patch). Both packages are published at 0.0.2, so M2 only adds
  exports and makes new interface fields optional.

**Steps:**

| Step  | Deliverable                                                                                                                                                                            |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| M2.1  | This subsection and PROGRESS-M2.md (docs only)                                                                                                                                         |
| M2.2  | Limit in the core: node, codec, emitter                                                                                                                                                |
| M2.3  | Limit in the builder (draft, integer field, editor, help text, icon) and registered                                                                                                    |
| M2.4  | Limit on the engine and in the browser: the contract proven                                                                                                                            |
| M2.5  | Drop (native)                                                                                                                                                                          |
| M2.6  | Slice (native)                                                                                                                                                                         |
| M2.7  | Distinct (an editor without settings)                                                                                                                                                  |
| M2.8  | Restrict                                                                                                                                                                               |
| M2.9  | Rename, with the column-name rule and the collision fix                                                                                                                                |
| M2.10 | Join rename autofix                                                                                                                                                                    |
| M2.11 | Sort, the row-order module, and the ORDER BY where the order is used                                                                                                                   |
| M2.12 | The Sort warning                                                                                                                                                                       |
| M2.13 | Database workarounds: row numbers for Drop and Slice, padded Distinct on SQL Server, the runtime's database types                                                                      |
| M2.14 | Grid quick actions: Sort by and Filter by                                                                                                                                              |
| M2.15 | Docs, sample typing on the engine, changeset text                                                                                                                                      |
| M2.16 | Verification (reviewers and a skeptic per finding) and the browser rehearsal                                                                                                           |
| M2.17 | Rebase on the latest master, fold the supersessions below into the plan, re-apply the DuckDB WASM note (§12.2 item 9; `b9923ed28`, now `81cf0d80c` on `cubeV1`), PR when the user asks |

**Supersessions** (applied in M2.17 to the sections they change; kept here as the record of what M2 changed):

- §4: an entry per M2 node, and the row-order hooks; §4.7: the autofix's names and key rewrite.
- §7.4 item 2: a transform with nothing to edit (Distinct) has an editor and no draft.
- §8.2 step 3 and §8.4: the capture re-sorts by its order; §8.8: the Sort, Distinct, Drop and Slice rows and the
  database table above; §8.9: the SQL Server distinct.
- §9: Sort by and Filter by come in M2.
- §10.3: the M2 shapes and the rule for clearable settings (an exception to "absent or at its default is left out").
- §12.2 question 2: answered (above).
- Appendix A: §7.1 sortable types and duplicates, §7.4 Restrict's labels, §7.5 no backslash, §7.7/§7.9 defaults in
  `create`, §7.9 wording, §7.11 key rewrite, §12.4 client-side grid. Appendix B: rename and select duplicates fail with
  HTTP 500 and no source location, SQL Server's `top N distinct`, `rewriteSliceAsWindowFunction` numbering a subquery's
  Drop, Slice or Limit over the first sort key only (Sybase IQ, MemSQL) and inside a `select distinct` (Sybase IQ).
- §11.3's M2 row: the database workarounds are those of §11.4 (Sybase IQ's Limit and Distinct included).
- PROGRESS.md's "In parallel" note: M1.9 merged (#5634, `3260216a6`); M2 runs on `cube-ops` from master, with its
  status in PROGRESS-M2.md. (`cubeV1` now holds the DuckDB WASM note, `81cf0d80c`, and two later docs commits of its
  own session.)

### 11.5 M4: Group and Concat

M4 is built on the branch `cube-m4`, from master `d1c3f3ae6` after M2 merged as #5644 (`0335b3f5f`); its first
commit, `8c1d3f74e`, records that merge. Its status is in [PROGRESS-M4.md](PROGRESS-M4.md). Requirements:
`m4-requirements` (three readers, for Group, Concat and the cross-cutting work, and a synthesizer that merged them and
checked their claims): 52 checklist items, 16 steps and 8 questions, the full result kept in the local evidence folder
(`m4-requirements-result.json`). Engine facts were probed on the local engine (`93d92b4`): ✅ only where the
synthesizer re-ran the probe, 💭 where only a plan was made or a reader reported it.

This subsection overrides the sections it names until they are updated (see "Supersessions" at its end).

**Settled at the start of M4** (user, 2026-10-09, all on the requirements' recommendation):

1. **Count rows** (answers §12.2 item 3): an aggregation with no column, `x|1 : y|$y->count()` (`count(1)` ✅), saved
   as `{function: 'CountRows', name}`; the grid's `Group by "X"` adds it by default. Count of a column keeps D4's
   meaning (non-empty values, not nullable).
2. **Group keys:** a multi-select (spec §17.6) that stores new picks in the input's order, as Restrict does; a loaded
   order is kept until the user changes the picks.
3. **Output names are always stored.** The editor fills in the auto-name and follows column and function changes until
   the user edits it; a saved aggregation with no name gets the auto-name on read and is written back with it.
4. **An unknown, empty or window-only function** keeps the Group, holding the text: invalid with the spec's
   `… is unknown.` or `… cannot be empty.`, saved again unchanged, editable; Rank and DenseRank count as unknown in
   M4. (M2's empty sort direction is a decode error, but an invalid Group can't run, so no rows change.)
5. **Concat types: strict, as D5 says** (a type next to its own ancestor too, though the engine accepts it ✅), plus a
   saved setting that widens differing types within a family (numbers, strings or dates, broader than `TypeFamily`) by a
   type-only cast to their least common ancestor. The type message offers **Convert types**, which turns it on. Across
   families (`toOne()->toString()`) is later.
6. **Concat autofixes, as separate buttons that say what they do:** a Rename before the second input (a different or
   case-only name at a position, never a permutation), and a Restrict before the wider input (its extra columns, when
   the other's names are an in-order subsequence), naming the columns it drops.
7. **Conformance nullability is exact,** but each case declares the columns where Cube may be wider (outer-join
   padding, the FULL merged key, Sum and Average) and asserts only that Cube says nullable there. Replaces §12.1's
   one-way rule.
8. **Concat wording:** ports `First` and `Second`; help text "Combines the rows of the two previous data sets, keeping
   duplicates, in no particular order. Both must have the same columns: the same names, in the same order, with the
   same types."

9. **The alias shadow** (user, 2026-10-09, after M4.8): after renames that reuse a column's old name, nine database
   types are written `GROUP BY` the alias that a column of the subquery shadows; Cube doesn't work around it. The
   engine issue is drafted in ISSUES.md and `LegendCubeDialects.engine-roundtrip-test.ts` pins each database's form.

**Decided without asking** (from the requirements, for review):

- Output names: the spec's rules (§10.3), compared folded (`foldColumnName`), plus `isValidColumnName` (a `"` fails at
  execution 💭).
- VARIANT and OPAQUE are refused as Group keys (`isSortableType`, `TypeCompatibility.ts:77`) and offered Count only.
- DistinctValue stays offered on Boolean (spec §10.1); its `max(bit)` on SQL Server, Sybase and Postgres (💭) goes to
  ISSUES.
- No keys emits `aggregate()` and reads `Aggregate all rows`; no aggregations is refused, never emitted (both NPEs ✅).
- 'Add aggregation' is never disabled (spec §17.6 disables it once every column is used; a column can be aggregated
  several ways). 'Group by "X"' splices after the selected node (spec §12.4).
- Concat matches columns by position; the spec's generic message comes first, then Cube's precise ones; the editor is
  a column-by-column comparison table; swapping inputs stays allowed, with no button.
- No `CubeDialects` change: the plan-only facts are pinned in `LegendCubeDialects.engine-roundtrip-test.ts`.
  `CubeSpecCorpus` types every node of every sample (still one-way). One patch changeset for both packages.
- Corrections to the readers, kept as build rules: `CUBETEST.ALLTYPES` has three rows, ID 3 empty but for its key, and
  no `I` or `VCNN` column (`CubeNorthwindModel.ts:43-46`), so expected values are worked out on it, not taken from
  `test::TypesDb` (only BI's Sum, 9007199254740997, carries over). `ORDERS.FREIGHT` is `Double` in the Cube fixture
  (`CubeNorthwindRelationTypes.json:289-291`; REAL in the DDL, `CubeNorthwindModel.ts:156`). Count rows has Q1's saved
  shape, never `{aggregation: 'CountRows'}`. `findColumnOrigins` (`CubeJoinDraft.ts:156`) maps DistinctValue, Min and
  Max outputs to their column, so the Join's "type unknown" warning still sees them. Keys keep the stored order, which
  the engine follows ✅. The Concat setting's key ships with the kind, written with its default.

**Aggregations** (`src/nodes/transforms/Aggregation.ts`, reused by M5's Partition). `ColumnAggregation` is
`{column, function, name}`; `getAvailableAggregations(type)` serves validation, the editor and the quick action.

| Function (saved) | Shown as       | Offered on                                                                      | Result type                                                                                           | Nullable | Reduce                |
| ---------------- | -------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------- | --------------------- |
| `Count`          | Count          | every type                                                                      | Integer                                                                                               | no       | `count()`             |
| `DistinctCount`  | Distinct Count | every type but VARIANT and OPAQUE (enumerations from M6d)                       | Integer                                                                                               | no       | `distinct()->count()` |
| `DistinctValue`  | Distinct Value | as Distinct Count                                                               | the input's precise type                                                                              | yes      | `uniqueValueOnly()`   |
| `Sum`            | Sum            | INTEGER, FLOAT, DECIMAL, NUMBER                                                 | Integer, Float, else Number                                                                           | yes      | `sum()`               |
| `Average`        | Average        | INTEGER, FLOAT, DECIMAL, NUMBER                                                 | Float                                                                                                 | yes      | `average()`           |
| `Min`, `Max`     | Min, Max       | those, and DATE, STRICT_DATE, DATETIME, STRING, BOOLEAN (the last two from M6d) | as Sum; StrictDate; DateTime for Timestamp, DateTime; the input's precise type on a STRING or BOOLEAN | yes      | `min()`, `max()`      |
| `CountRows`      | Count Rows     | no column                                                                       | Integer                                                                                               | no       | `count()` on `x\|1`   |

DECIMAL counts as numeric (spec §10.1 doesn't name it); `1.0 *` is never emitted. The auto-name is
`<column> <Shown as>` (`ORDER_ID Count`), or `Count Rows`; one over 128 code points (from a column of 114 for Distinct
Count and Distinct Value, 121 for Average, 123 for Count, 125 for Sum, Min and Max) is shown invalid, never cut, the
editor's note naming the limit (settled in M4.5). Nullability decides rows: `FilterEmitter` guards a negation
with `isEmpty` only on a nullable column (`FilterEmitter.ts:211`), and the engine types Sum and Average `[1]` ✅.

**Group** (`group`, label `Group by Column`), a `UnaryNode`:

- Holds `columns` (the keys, in stored order) and `aggregations`; the constructor refuses only wrong shapes. Schema:
  `undefined` unless valid, else the keys as the input has them, in stored order, then one column per aggregation.
- Validation, in order: `Group columns cannot have duplicates.`, `Group column does not have a name.`,
  `Group column "X" is not present in the input schema.`, Cube's `Group column "X" of type <T> cannot be grouped.`;
  then `Aggregations cannot be empty.`; then every row (`validateColumnAggregation`, exported): function empty or
  unknown, a column on Count rows (`… does not allow column.`), the column present (label `Aggregation column`),
  `… is incompatible with column "<c>".`, name empty, Cube's `Aggregation output name is not valid column name.`,
  `… cannot be the same as input column name.` (folded), and two names equal when folded
  (`… is already present in the output schema.`). The engine fails on duplicates with a 500 and no location (two
  outputs ✅, a key twice 💭), so Cube refuses them first.
- Saved: `{columns, aggregations: [{column, function, name}]}`, both lists always written; Count rows has no `column`;
  a non-string field is a decode error; an unknown key on an entry makes an Unknown node (it could change the rows);
  functions per Q4, names per Q3.
- Emitted: `->groupBy(~[k…], ~[n: x|$x.c : y|$y-><reduce>])`, or `->aggregate(~[…])` with no keys, through a new
  aggregation colSpec constructor (`colSpec(name, fn1)` can't set `fn2`, `CubeIR.ts:202-203`; the serializer already
  writes `function2`, `V1_CubeLambdaSerializer.ts:158`), with new roles `group` and `aggregation`.
- Row order: none, so no sort is written before it. A Sort before it gets M2's full-loss warning naming the Group by
  its id ("This sort has no effect: group101 does not keep the row order. …", `MESSAGE_SORT_ORDER_LOST`,
  `CubeMessages.ts:220`); after Sort → Restrict → Group the full loss replaces the partial one.
  `describe()`: `Group by "a", "b"`.

**The grid's 'Group by "X"'**, between Sort by and Filter by: keys `[X]` and Count rows (its name suffixed if it folds
to an input column's), added after the node that ran as one undo step that selects it, never run; disabled for M2's
shared reasons and on a type that can't be grouped (a new `LegendCubeLabels` reason). It changes the schema, so later
nodes may turn invalid (visible, undoable).

**Concat** (`concat`, label `Concatenate Another Input`), a plain `BinaryNode` on the default ports
(`QueryNode.ts:156-172`), with `portLabels` First and Second:

- Validation, in order: a missing input (ERR_INCOMPLETE); the same column count (the engine doesn't check it ✅); the
  same name at each position, case-sensitive; equal types at each position, or with Convert types a least common
  ancestor (`getLeastCommonAncestor`, `TypeCompatibility.ts:128`; the registry's roots Number, String, Date and
  Boolean have no parent, so there is none across them). Nullability is never compared.
- Messages: `Both input schemas must be identical.` (`CubeMessages.ts:123`), then Cube's, for every differing position
  (settled in M4.9): `The first input has <n1> columns and the second <n2>.` (`1 column` when n1 is 1),
  `Column <i> is "<a>" in the first input and "<b>" in the second: columns are matched by position.`,
  `The inputs have the same columns in a different order: columns are matched by position.`,
  `Column "<c>" is <T1> in the first input and <T2> in the second.` (short names, or paths when both share one, as
  two enumerations `a::Region` and `b::Region` can); with Convert types, for types it can't convert (settled in M4.13):
  `Column "<c>" is <T1> in the first input and <T2> in the second, which can't be converted to one type.`
- Schema: the first input's names and types (the ancestor where widened), `nullable1 || nullable2` ✅.
- Convert types (built in M4.13, `getConcatConvertedType`): Varchar lengths give String, SmallInt and Int Integer, Int
  and Float4 Number, two Numeric precisions Decimal, StrictDate and Timestamp Date; never across numbers, strings and
  dates, nor VARIANT, OPAQUE or two enumerations. Emitted on each input that needs it as
  `->extend(~[cube_cast: x|$x.<c>->cast(@<T>), cube_cast2: …])->select(~[…])->rename(~cube_cast, ~<c>)…`, the
  temporaries avoiding the input's names in any case, with no SQL cast ✅ (`@String`, `@Integer`, `@Number`,
  `@Float`, `@Decimal`, `@Date` and `@DateTime` type and run on H2 ✅); never a relation-level cast, which the engine
  doesn't check 💭. H2 shows a StrictDate unioned with a Timestamp as midnight timestamps ✅ (Join refuses that pair
  as keys), and the converted Date then compares with a StrictDate or a Timestamp downstream (the older rule for an
  abstract Date), so a Join or Filter on it matches only midnight ✅: the Join and Filter editors warn (user,
  2026-10-09; `isDateOrTimestampType`, `DATE_OR_TIMESTAMP_WARNING`). The
  editor never offers Convert types for a column whose real type Cube doesn't know (the database may fail to
  convert it ✅).
- Autofixes in a core `ConcatAutofix.ts` (as `JoinAutofix.ts`): one query change and one undo step each, the selection
  kept, the panel's edits applied first; the Rename is refused when a new name folds to an untouched column.
- Saved: `{kind: 'concat', id, inputs, widenTypes}` (settled in M4.9), always written, `false` included; a missing key
  is a decode error, and a value that isn't true or false makes an Unknown node.
- Emitted: `<first>->concatenate(<second>)`, role `concat`, asserting both inputs' column count and names. Row order:
  none (a Sort on either input gets the full-loss warning). `describe()`: `Concatenate additional input`, plus
  `, converting types` with the setting on.

**Conformance suite** (`CubeInferenceConformance.engine-roundtrip-test.ts`): every node of every case that
`QueryEmitter.canEmit` accepts, Cube's schema against the engine's type of `emitTypingLambda(nodeId)`, in one
`typeLambdas` batch: names in position, `type.fullName`, nullability exact but for each case's `widerNullable` columns
(Q7). A guard fails when a type `createNodeRegistry()` registers has no case, but a data product's access point, which
the open-source engine doesn't read (§6.8; its stand-in test checks its types).
`TEST__expectEngineTyping` stays one-way for M2's tests; both use `TEST__typingDifferences` (`CubeOperationsTestUtils.ts`). The engine is created
inside the test, never in `beforeAll`; the suite runs against the moving CI engine image, so its failure message says
the engine's typing may have changed. Cases: the ALLTYPES families and FREIGHT, Filter, the four Joins, the Join
autofix, every M2 operation, every aggregation × family (ALLTYPES' families, and the ones Convert types gives: Number,
Date and the abstract Integer, Float, Decimal and String, added in M4.15), keys listed in a non-input order, a global
aggregate, a Group of a Group, a Group after a LEFT join, Concats of equal, nullable-mixed and widened schemas, the
Concat autofixes, never a count mismatch (typed as the shorter relation ✅).

**Databases.** Every Group and Concat shape plans on 11 database types 💭, so no `CUBE_DIALECT_WORKAROUNDS` entry and
`WORKAROUND_TYPES` unchanged (`CubeDialects.ts:110-139`); `needsDatabaseType` already walks every input. The plan-only
test pins `count(distinct …)`, `avg(1.0 * …)`, `count(1)`, HAVING after a Group, Cube's row numbers for Sybase IQ's
Limit after a Group, the GROUP BY target after two Renames, no ORDER BY in a derived table under a GROUP BY on SQL
Server and Sybase, a Distinct before a Group kept as a `select distinct` subquery, one UNION ALL per Concat with each
input's ORDER BY in its own derived table with its TOP, LIMIT or FETCH, and a Limit after a Concat wrapping the whole
union. ISSUES drafts: that alias shadow, `max(bit)` for a Boolean DistinctValue, SQL Server's `SUM(int)` overflow.

**Builder.** Menu order Sort, Group, Filter, Restrict, Rename, Distinct, Drop, Limit, Slice, Concat, Join (spec §7.0);
a type is registered, sampled in `operations.cube.json` and given conformance cases in the step that adds its editor,
help text and icon. Both editors work in either host (§12.2 item 1).

- Group: "Group columns" (Q2; VARIANT and OPAQUE disabled), then rows "Aggregation column <n>", "Aggregation function
  <n>" (a native select) and "Aggregation output name <n>", each with its messages; one blank row to start, a row
  with no column left out unless it is Count rows, Count set when a column is picked first. Notes: Count counts
  non-empty values; Distinct Value is the one distinct non-empty value, else empty; no keys gives one row, even over
  no rows. Help text spec §17.9's; icon `DataCubeIcon.TableGroupBy` (`DataCubeIcon.tsx:152`).
- Concat: the requirement in words, a comparison table (each position's names, types and nullable markers, differences
  marked), the "type unknown" warning, the autofix buttons when they apply, and from M4.13 a `CubeConcatDraft` with
  the Convert types checkbox and the target types (until then in `CUBE_NODE_TYPES_WITHOUT_SETTINGS`,
  `CubeNodeDraftRegistry.ts:73`). Help text per Q8; icon `LayerGroupIcon` (`Icon.ts:579`).

**Engine facts** (probes under `m4-requirements/`, on the shared Northwind model that the evidence folder's `nw.mjs`
loads (`nw_pmcd_resp.json`: FREIGHT is OTHER and there is no CUBETEST schema), unless `test::TypesDb` is named; none ran
on the Cube fixture, which copies that model with corrections that don't touch these columns):

| Fact                                                                                                                                                                                                                                                               |     | Probe                                                             |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --- | ----------------------------------------------------------------- |
| Count, DistinctCount, Sum, Average and Count rows are typed `[1]`; Min, Max and DistinctValue `[0..1]`; SmallInt's Sum is Integer, Average Float, Varchar(15)'s Max stays Varchar(15); keys come out in the order listed; a key filter after a Group is HAVING     | ✅  | `synth/r1-group-types.out`, `r2.out`                              |
| The null SHIP_REGION group: Count 0, Count rows 507; an aggregate over no rows gives one row (0, 0, null, 0)                                                                                                                                                       | ✅  | `synth/r5.out`, `r3.out`                                          |
| No keys: an NPE (500, no location). On `test::TypesDb`: no aggregations is an NPE too; two outputs with one name give a 500 "at ??"; an all-null group's Sum is null though typed `[1]`, and a negated filter on it drops the group without Cube's `isEmpty` guard | ✅  | `synth/r4.out`, `s1-types.out`                                    |
| A Concat count mismatch compiles, typed as the shorter relation, and fails at execution                                                                                                                                                                            | ✅  | `synth/c1.out`                                                    |
| Concat ORs nullability, accepts a type next to its ancestor (SmallInt with Integer gives Integer), refuses siblings and another order (a located 400)                                                                                                              | ✅  | `synth/c2.out`, `c3.out`, `c5.out`, `c6.out`                      |
| A type-only `cast(@String)` lets two Varchar lengths concatenate, with no SQL cast, on H2                                                                                                                                                                          | ✅  | `synth/w1.out`                                                    |
| Result types for every family and a Group of a Group; Sum and Average refused on dates, strings, Booleans and enums                                                                                                                                                | 💭  | `group/t1-types.out`, `t4-second-level.out`                       |
| Every shape plans on H2, Postgres, SqlServer, Sybase, SybaseIQ, DB2, MemSQL, Snowflake, Databricks, ClickHouse and Oracle                                                                                                                                          | 💭  | `group/p1`–`p5`, `cross-cutting/s1`–`s8`, `concat/d1`–`d9`        |
| After two Renames, MemSQL, SybaseIQ and ClickHouse group by the real column the alias shadows; a Boolean DistinctValue is `max(B)`                                                                                                                                 | 💭  | `group/n1-alias.out`, `p3-boolean.out`                            |
| `cast(@String)` on a number fails on H2; a relation-level cast is unchecked; a LEFT-join padded key is typed `[1]`, through a Group too                                                                                                                            | 💭  | `concat/widen1.out`, `relcast2.out`, `cross-cutting/nw_types.out` |

**Steps:**

| Step  | Deliverable                                                                                                                                                                                                            | Done when                                                                       |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| M4.1  | This subsection and PROGRESS-M4.md (docs only)                                                                                                                                                                         | Committed                                                                       |
| M4.2  | The conformance suite on M2's node types; `CubeSpecCorpus` types every node                                                                                                                                            | Passes with only the declared exceptions; a removed case fails the guard        |
| M4.3  | The aggregation model (core) and Cube's messages                                                                                                                                                                       | Every family × function cell and message tested                                 |
| M4.4  | Group in the core: node, emitter, codec (Q3, Q4), row order; not registered                                                                                                                                            | `printIR` shows `groupBy`, `aggregate` and Count rows                           |
| M4.5  | Group in the builder, registered after Sort; `findColumnOrigins`; a sample; Group conformance cases                                                                                                                    | Builder, registry and conformance tests pass                                    |
| M4.6  | Group on the engine (the first Cube-emitted `function2`, ALLTYPES, 21 countries, 830 rows, the 507, BI's Sum, a zero-row aggregate giving one row, a negated filter on a Sum keeping ALLTYPES ID 3) and in the browser | Engine tests pass; a Group built, run and saved in the browser                  |
| M4.7  | The grid's 'Group by "X"'                                                                                                                                                                                              | Enabled, disabled and splice cases tested                                       |
| M4.8  | Group around the databases: plan-only facts, ISSUES drafts                                                                                                                                                             | The plan-only test passes on every database type                                |
| M4.9  | Concat in the core: validation, messages, emitter, codec with the setting's key, row order; not registered                                                                                                             | Every message checked exactly                                                   |
| M4.10 | Concat in the builder, registered between Slice and Join; a sample; Concat conformance cases                                                                                                                           | Builder, registry and conformance tests pass                                    |
| M4.11 | Concat on the engine (CUSTOMERS and SUPPLIERS give 120 rows; a Distinct after; a Limit inside) and plan-only UNION ALL facts                                                                                           | Engine and plan-only tests pass                                                 |
| M4.12 | The Rename and Restrict autofixes and their buttons                                                                                                                                                                    | Each fix turns a Concat valid, or isn't offered                                 |
| M4.13 | Convert types: target types, schema, casts, the draft's checkbox; engine tests on ALLTYPES and Varchar lengths                                                                                                         | Widened Concats type as Cube infers, run on H2 and plan everywhere              |
| M4.14 | Both adding-an-operation guides, testing.md's conformance section, README lists, ISSUES drafts, one patch changeset                                                                                                    | `yarn check:ci` passes                                                          |
| M4.15 | Verification (reviewers and a skeptic per finding) and an evidence-folder browser rehearsal                                                                                                                            | Every finding fixed or recorded; the rehearsal passes                           |
| M4.16 | A demo video of M4's features (§11.3): Group and its editor, the grid's Group by, Concat, its autofixes and Convert types, with captions; key frames checked against their captions                                    | The video plays every M4 feature, each caption true on screen; sent to the user |
| M4.17 | Rebase after agreeing the landing order with cube-direct; fold the supersessions below; PR when the user asks                                                                                                          | The plan consistent; the PR open on the user's word                             |

**Landing order (user, 2026-10-09).** The PR is marked ready for review after M4.13, with the changeset (M4.14's) and
its description updated and every gate green; M4.14's guides, M4.15, M4.16 and M4.17's folding follow as fixes on the
open PR. cube-direct landed first (#5641), and M4 was rebased on it after M4.10. M4 merged on 2026-10-09 as #5649
(`d847e6721`), with M4.16's video; M4.14's guides, M4.15 and M4.17 follow in their own PR.

**Risks and open gaps:**

- Every non-H2 database fact is a plan 💭, and DuckDB not even that (its plans fail with a static connection; cube-direct
  makes it reachable). Widening relies on each database's UNION coercing within a family: only two Varchar lengths ran,
  on H2 ✅; numbers and dates are untested (StrictDate with Timestamp on H2 is reader-reported 💭).
- Cube's validation and the emitter's assertion are the only guards against a Concat count mismatch ✅. Untyped OTHER
  columns pass the type check: a warning only, as for Join. Exact nullability may expose M2 mismatches (hence M4.2
  first).
- Open: ALLTYPES' expected values (M4.6); where engine errors land (the `aggregation` role, the Concat); how §7.4's
  editor without settings carries Concat's autofix buttons (M4.12) before M4.13 gives it a draft; whether
  `CubeColumnPicker` keeps a stored order; a nameless saved aggregation with no auto-name (an unknown or empty function, or a column function without a column) is read as an empty name, written back as `name: ""` and invalid, its empty name reported once its function and column are valid (settled in M4.4); VARIANT, OPAQUE and enum rows (none in the fixture); the PCT manifests; Rank and DenseRank against Q4 (M5: `validateColumnAggregation` then takes the functions each use allows, so Rank stays unknown in a Group); copying Q2 and Q3 to QUESTIONS.md U12, which exists only on `cubeV1` and `cube-direct`.

**Supersessions** (applied in M4.17 to the sections they change; kept here as the record of what M4 changed):

- D5: clarified by Q5. §4: Group and Concat entries; §4.2: a widened Concat column takes the ancestor type. §7.2:
  Concat's ports read First and Second; §7.4: the Group and Concat editors.
- §5.7: Count rows; Count, DistinctCount and Count rows are not nullable, the rest are, and Sum and Average are the
  suite's declared exceptions (Q7); DECIMAL is numeric; VARIANT and OPAQUE offer Count only.
- §5.8 "Lambda (later)" and §8.8's Concat row: widening is a type-only `cast` within a family (`toString()` across
  families, later); the engine accepts a type next to its ancestor ✅, so strictness is D5's choice; a count mismatch
  is typed as the shorter relation ✅. §8.8's Group row and aggregations table gain Count rows.
- §8.9 and Appendix B: only Count, DistinctCount, Sum, Average and Count rows are typed `[1]` ✅; `concatenate`'s
  shorter typing; M4's ISSUES drafts once filed.
- §9 and Appendix A §12.4: Group by comes in M4 and adds Count rows (spec §7.2: Count of X).
- §10.3: the Group and Concat shapes, Q3's name on read, Q4's kept functions, Concat's key shipped with the kind.
- §11.3's M4 row: Convert types instead of a widen autofix, the Rename and Restrict autofixes, Count rows, Group by,
  and a suite exact on nullability with declared exceptions. §12.1: "conformance allows Cube ⊇ engine only" gives
  way to Q7. §12.2 item 3: answered (Q1).
- Appendix A: §7.2 (Count rows by default, `Aggregate all rows`, VARIANT and OPAQUE keys, key order); §7.10 (Convert
  types by a type-only cast, not "real conversions"; First and Second; Cube's messages; the autofixes; a setting where
  the spec says "no state"); §10 (Count rows, DECIMAL, VARIANT and OPAQUE, unknown functions kept); §10.2 (DistinctCount
  and Count rows not nullable either); §10.3 (names always stored); §17 (Concat's ports read First and Second, beside
  Join's Left and Right); §17.6 (keys in input order, 'Add aggregation' never disabled, a Concat editor where the spec
  says "Nothing"); §17.9 (Concat's help text).

### 11.6 M5: Partition (window functions)

M5 is built on the branch `cube-m4-followup`, after M4's follow-ups, and lands in the same PR, #5653 (user,
2026-10-09: no reviewer is free, so the work continues there). The branch was rebased on master `5e424277b` (#5656,
CSV into DuckDB) first. Its status is in [PROGRESS-M5.md](PROGRESS-M5.md). Requirements: `m5-requirements` (three
readers, for the node, for emitting and isolating windows and for the cross-cutting work, and a synthesizer that merged
them and re-ran 16 engine facts): 62 checklist items, 15 steps and 8 questions, the full result kept in the local
evidence folder (`m5-requirements-result.json`). Engine facts were probed on the local engine (`93d92b4`): ✅ only
where the synthesizer re-ran the probe, 💭 where only a plan was made or a reader reported it.

This subsection overrides the sections it names until they are updated (see "Supersessions" at its end).

**Settled at the start of M5** (user, 2026-10-09, all on the requirements' recommendation):

1. **Frames** (answers §12.2 item 8): D5's default, with no saved frame key and no frame clause written. With sort
   keys, an aggregate runs from the partition's first row to the current one, and rows tied on the sort share a
   value (SQL's `RANGE` default ✅); with none, it covers the whole partition ✅. The editor notes say so, including
   that a Rank's sort makes the node's Sum running. A per-node "running / whole partition" setting (the aggregates'
   `over()` without the sort, the ranks' with it) is the follow-up if users ask, saved on each aggregation entry, where
   an older Cube reads an unknown key as an Unknown node and can't run it, never as a node key it would ignore
   (M5.12); explicit frames are not planned (an
   unpartitioned window can't take a rows frame ✅, and date ranges plan on 4 of 12 types 💭).
2. **Functions:** the spec's, plus **Row Number**: no column, Integer, never empty, a sort required as for Rank,
   `{p,w,r|$p->rowNumber($r)}`. It gives an exact "top N per group" with a Filter after it, where Rank keeps ties.
   Not ntile, lag, lead, first, last, nth, Percent Rank or Cumulative Distribution (M5b adds all of them but nth,
   §11.9).
3. **Windowed Distinct Count and Distinct Value** are offered, as the spec does, natively: `count(distinct x) over (…)`
   and, for Distinct Value, `case when count(distinct x) over (…) = 1 then max(x) over (…) end`. They plan on every
   database with windows ✅ and run on H2 ✅; Postgres, SQL Server, Databricks and Trino are expected to refuse them at
   run time, and Oracle and BigQuery with a sort (vendor documentation, 💭): an error, never wrong rows. An editor
   note names the portability, the plan-only test pins the forms, and ISSUES gets a draft.
4. **The editor's layout** follows the original (QUESTIONS.md U13, on `cube-canvas`): "Window functions", then
   "Partition by", then "Order by".

**Decided without asking** (each has a precedent; for review):

- **Count rows** is offered in a Partition, as in a Group (M4's shared row model): `{p,w,r|1}:y|$y->size()`, which
  is `count(1) over (…)` ✅, saved as `{function: 'CountRows', name}`.
- **Spanner, Presto and Composite** plan no window at all (`Window Columns not supported for Database Type: <T>`, a
  500 ✅). A run there shows the engine's error on the capture node, as M2 leaves Spanner's missing row numbers to
  the engine; the plan-only test pins it. A refusal before running is a follow-up if they become targets.
- **No grid quick action** for windows: spec §12.4 lists none, and one couldn't ask for the partition or direction.
  M7 reworks the menu.
- **Row order:** a Partition keeps its input's row order, as Distinct does (`Distinct.ts:37-41`): it keeps every row
  and column, so the Sort's keys still exist after it, and Cube writes the order where it is used, at the capture
  after `from()` ✅ or before a later Limit, Drop or Slice. A Sort before a Partition gets no warning.
  `consumesInputOrder` is false: the window's own sort keys order it.
- **Group is unchanged.** Rank, Dense Rank and Row Number form a window-only set beside `AggregationFunction`, and the
  helpers (known, takes a column, auto-name, result type, nullability, `validateColumnAggregation`, reduce) take the
  set each use allows; in a Group, Rank stays `… is unknown.` (M4 Q4) and every M4 test passes unchanged.
- **Typing lambdas stay plain chains**: the engine types each node the same in chain and `let` form (8 of 8 💭, checked
  again in M5.3). Only the run lambda and Show Pure carry `let`s.
- **The saved shape is Cube's own** (`columns`, `sorts`, `aggregations`, as Group's and Sort's entries), never the
  spec's V1 `operations` with `olapRank` and `olapAggregation`.
- **Two Cube messages:** `Partition column "X" of type <T> cannot be partitioned.` (VARIANT and OPAQUE, as Group's
  keys) and `Aggregation function "<a>" requires at least one sort column.` (Rank, Dense Rank, Row Number), which
  starts with "Aggregation function" so the editor marks the function control.
- **Builder:** registered after Join (spec §7.0's order; Difference, M6, will go between them); icon `SigmaIcon`
  (`Icon.ts:156`), since `DataCubeIcon.Window` reads as a UI window; "Add window function" is never disabled (as
  Group's "Add aggregation"); "Add sort column" is disabled once every sortable column is used (spec §17.6).

**Window functions** (`Aggregation.ts`, extended; every column function as Group offers it, §11.5):

| Function (saved)    | Shown as         | Offered on        | Result type          | Nullable | In a window                                            |
| ------------------- | ---------------- | ----------------- | -------------------- | -------- | ------------------------------------------------------ |
| `Count`             | Count            | every type        | Integer              | no       | `{p,w,r\|$r.c}:y\|$y->size()` (never `count()`, below) |
| `DistinctCount`     | Distinct Count   | as in a Group     | Integer              | no       | `distinct()->size()`                                   |
| `DistinctValue`     | Distinct Value   | as in a Group     | the input's type     | yes      | `uniqueValueOnly()`                                    |
| `Sum`, `Average`    | Sum, Average     | as in a Group     | as in a Group (§5.7) | yes      | `sum()`, `average()`                                   |
| `Min`, `Max`        | Min, Max         | as in a Group     | as in a Group (§5.7) | yes      | `min()`, `max()`                                       |
| `CountRows`         | Count Rows       | no column         | Integer              | no       | `{p,w,r\|1}:y\|$y->size()`                             |
| `Rank`, `DenseRank` | Rank, Dense Rank | no column; a sort | Integer              | no       | `{p,w,r\|$p->rank($w,$r)}`, `$p->denseRank($w,$r)`     |
| `RowNumber`         | Row Number       | no column; a sort | Integer              | no       | `{p,w,r\|$p->rowNumber($r)}`                           |

- `count()` in a window compiles, typed Integer, but loses its `OVER` and fails on H2 ✅. A rank with no sort types
  and plans, then fails on the database with no location ✅, so Cube's sort check is the only guard.
- The engine types Sum and Average `[1]`, but an all-null partition gives null for both ✅: Cube says nullable, and the
  conformance cases declare them (M4 Q7). Count of an all-null column is 0, Count rows 1 ✅.
- Auto-names as in a Group (`FREIGHT Sum`), and `Count Rows`, `Rank`, `Dense Rank`, `Row Number`; names are always
  stored and editable (M4 Q3), and over 128 code points shown invalid, never cut.

**Partition** (`partition`, label `Apply Window Functions`), a `UnaryNode`:

- Holds `columns` (partition by, may be empty, in stored order), `sorts` (Sort's `{column, direction}`, may be
  empty) and `aggregations` (Group's `{column?, function, name}`). The constructor refuses only wrong shapes.
- Validation, in order: `Partition columns cannot have duplicates.`, then each column named, present (label
  `Partition column`) and sortable (the new message); each sort key as `validateSortKey`, then
  `Sort columns cannot have duplicates.` (never `Sort.validate`, which refuses an empty list; a sort column may also
  be a partition column); `Aggregations cannot be empty.`; then every row with the window set: function empty or
  unknown, a column on a no-column function (`… does not allow column.`), the column (label `Aggregation column`) and
  its compatibility, a rank with no sort (the new message), the name empty or invalid, the name folding to an input
  column's, two names folding to one. The engine fails on a duplicate with a 500 and no location 💭, so Cube refuses
  first.
- Schema: `undefined` unless valid, else the input's columns unchanged, then one per row, in listed order ✅.
- Saved: `{columns, sorts, aggregations}`, all three always written; entries read as Sort's and Group's (their readers
  shared); a non-string field is a decode error; an unknown key on an entry makes an Unknown node; an unknown or empty
  function is kept, invalid (M4 Q4), and so is a Rank saved with a column; a missing name gets the auto-name, or `''`.
- Emitted, always in array form `~[…]` (the single form lets a later filter run before the window, §8.6):
  `->extend(<over>, ~[<aggregates>])`, then `->extend(<over>, ~[<ranks>])`, then `->select(~[…])` only when the
  listed order differs, since the two extends give aggregates first ✅; never one extend holding both (a 500
  ClassCastException with no location ✅). `<over>` is `over(~[p…], [sorts])`, `over(~[p…])`, `over([sorts])` or
  `over([])` (never `over(~[], …)`, an NPE, or `over([], [sorts])`, a 400 💭). The emitter asserts the names equal the
  schema, as `GroupEmitter` does. `describe()`: `Apply 2 Window Functions`, `Apply 1 Window Function`.
- An isolation boundary (§8.6): bound with a `let` whenever it isn't the capture.

**Window isolation** (§8.6, built in M5.2 and M5.3 before Partition needs it):

- IR: `let` gets an origin, role `let`, so a failure stamped there lands on the window node; a `$n_…` reference takes
  its consumer's origin. IR `block` is dropped (the protocol has none: a block is a lambda with several statements).
  The serializer writes `letFunction` and `var`; `IRPrinter` prints `{| let n_x = …; <capture>;}`, which the engine
  parses back to the same JSON (a golden test).
- `TransformDefinition.isolationBoundary` (declared in §4.5, missing from `NodeRegistry.ts`). `QueryEmitter` binds
  every boundary node that isn't the capture, after the lets it reads (a node feeds only one other, `Query.ts`, so
  each is bound once), names it `n_<id>` when the id lowercased matches `[a-z0-9_]{1,28}` and no other
  let has it in any case, else `n_<k>`, never one of the lambda parameters Cube writes (`x`, `row`, `p`, `w`, `r`,
  `y`). An id like `a-b` would break the SQL ✅.
- The run lambda with lets is `{| <lets>; <capture relation>}->from(rt)->sort(<capture order>)->limit(n + 1)`: one
  `from()`, after the block, and the capture's sort and limit after it. Inside the block, the ORDER BY ends in a
  subselect: Sybase IQ then numbers the rows itself, and SQL Server's outer select has no order ✅. After it, every
  window database writes a `WITH`, keeps a filter's literal, and puts the ORDER BY at the root ✅. Without lets the run
  lambda is unchanged, and so is every M1–M4 golden.
- Never a `let` after a Sort: a Sort writes nothing where it stands, and lets follow only windows. A Limit, Drop or
  Slice inside a let still writes its sort just before it, as TOP, LIMIT or FETCH FIRST with an ORDER BY in the CTE:
  pinned on SQL Server and Sybase (Sort → Limit → Partition, and Sort → Partition → Limit) in M5.10.
- M2's row-number forms (`RowNumberEmitter.ts`) stay chains: they are array form, and a guard in the plan-only test
  checks every workaround database keeps the `cube_rn` predicate and writes no QUALIFY.
- Show Pure shows the lets; typing lambdas don't (above). An engine test through `V1_LegendCubeEngine` types, runs,
  renders and maps an error inside a let (the conformance suite types chains only).

**Databases.** No `CUBE_DIALECT_WORKAROUNDS` entry: the let form and the array form plan on the 17 types with windows
✅. The plan-only test gets `WINDOW_SHAPES` over `WINDOW_DATABASE_TYPES` (window shapes in `SHAPES` would fail every
Spanner, Presto and Composite loop), DuckDB in its model with a `duckDB` spec, the three refusals, and pins: no rows or
range clause, `count(col)`, `count(1)`, `avg(1.0 * …)`, `rank()`, `dense_rank()`, `row_number()`, no bare `count(` in
a window; a Filter after a Partition as a `WITH n_…` and a `WHERE` outside the window's select, never QUALIFY; the
capture's ORDER BY and limit at the root, never Sybase IQ's `limitoffset_via_window_subquery`; SQL Server and Sybase
with no ORDER BY in a subquery or CTE without TOP; `over (order by …)` and `over ()` with no partition; the windowed
distinct forms per database. ISSUES drafts: window `count()` losing OVER; a single-form window extend not isolated,
with QUALIFY dropped or refused; a rank with no ORDER BY planning; windowed `count(distinct)` portability; one nested
subselect per window column (M5.10). An unpartitioned window can't take a rows frame (no `over(SortInfo, Rows)` ✅),
but Cube writes no frame (Q1), so it isn't drafted.

**Builder.**

- `CubePartitionDraft`: partition columns stored in the input's order, a loaded order kept until the picks change
  (M4 Q2), a saved column the input lacks kept listed; sort rows as `CubeSortDraft`'s, a blank one left out; rows as
  `CubeGroupDraft`'s, the name following column and function until typed, Count set when a column is picked first,
  Count rows, Rank, Dense Rank and Row Number clearing the column; `build()` returns the original when nothing changed.
- `CubePartitionEditor` (Q4's order): "Window functions" rows (column, function, output name; the no-column functions
  show a short text in place of the column picker; the column type's functions, then Count Rows, Rank, Dense Rank and
  Row Number, a held unknown function kept visible; one blank row to start); "Partition columns" (a checklist, VARIANT
  and OPAQUE disabled with a reason); "Sort columns" rows (column, direction, move, remove; none to start). Each row
  shows its first problem; aria-labels `Partition columns`, `Sort column <n>`, `Sort direction <n>`, `Aggregation
column <n>`, `Aggregation function <n>`, `Aggregation output name <n>`. It fits the floating host (§12.2 item 1) and
  honours read-only. M5.6 first extracts the column checklist, the sort row and the aggregation row from the Group and
  Sort editors, with no behaviour change.
- Notes: running versus whole partition (Q1), ties sharing a value and a rank; Rank, Dense Rank and Row Number need a
  sort, and Rank skips numbers after a tie where Dense Rank doesn't; empty sort values come last ascending and first
  descending on H2 (databases differ 💭); an earlier Sort doesn't order the window; Count counts non-empty values and
  Count Rows every row; windowed Distinct Count and Distinct Value fail on some databases (Q3).
- Help text spec §17.9's ("Adds new columns with outputs of window functions for optional window partition and
  order."). `findColumnOrigins` follows a Partition: input columns by name, Distinct Value, Min and Max outputs to
  their column, so the Join's "type unknown" warning still sees them.

**Tests.** Core: every message exactly and in order, each function's schema per family, `describe()`, row order,
codec round trips and Unknown cases, `printIR` of each `over()` form, the split extends and the select, Group unchanged.
Conformance (`widerNullable` Sum and Average): every function on ORDERS, ALLTYPES' families, no partition, neither,
ranks only, Sum, Rank, Max in that order, after a LEFT join, after a Group, a Partition of a Partition, a Filter and a
Group after one. Two `operations.cube.json` samples. On the engine: ORDERS' tie on 1996-07-08 (Count rows 1, 2, 4,
4, 5; a Sum of EMPLOYEE_ID 5, 11, 18, 18, 22; Rank 1, 2, 3, 3, 5; Dense Rank 1, 2, 3, 3, 4 ✅), ALFKI's running Sum 6,
10, 14, 15, 16, 19 against a total of 19 ✅, ALLTYPES ID 3 ✅, France 77 after a Filter ✅, the top 3 per country ✅,
the null SHIP_REGION partition and null SHIPPED_DATE ranks 💭; DuckDB in memory as a second executed database through
the direct connection. A composition suite (`CubeWindowComposition.engine-roundtrip-test.ts`) runs Cube graphs, pairs
and triples with a Partition, on H2 against a small JS reference with D4's null rules; the evidence folder's `g1`
matrix stays there as a wider check. Integer columns over FREIGHT, whose REAL values show float noise ✅.

**Engine facts** (probes under `m5-requirements/`; `synth/` holds the re-runs, on the Cube fixture through
`partition-node/probe.mjs` unless `nw.mjs` is named, since the shared model types FREIGHT as String ✅):

| Fact                                                                                                                                                                                                  |     | Probe                                              |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | -------------------------------------------------- |
| A Filter after a single-form window runs before it (France 2); the array form and the let form keep it after (France 77); in the let form the capture's sort and limit after `from()` are at the root | ✅  | `synth/s1-let-filter.out` (`nw.mjs`)               |
| One extend with an aggregate and a rank: a 500 ClassCastException, no location; split extends type in emission order, and a select restores the listed order                                          | ✅  | `synth/s3-fixture.out` A–D                         |
| Running aggregates with a sort, ties sharing a value; the whole partition without one; Rank and Dense Rank on ties                                                                                    | ✅  | `synth/s3-fixture.out` E, `s6-fixture.out` J       |
| `count()` in a window fails on H2; a rank with no sort fails with no location; an all-null partition's Sum and Average are null, typed `[1]`                                                          | ✅  | `synth/s3-fixture.out` F–H                         |
| A let-bound Rank with a Filter on it gives the top 3 per country                                                                                                                                      | ✅  | `synth/s3-fixture.out` I                           |
| The capture's sort and limit inside the block leave the ORDER BY in a subselect (Sybase IQ numbers the rows itself); after `from()`, all 17 window types write a WITH and a root ORDER BY             | ✅  | `synth/s4-plans.out`, `s5-alltypes.out`            |
| Spanner, Presto and Composite refuse any window; 17 types plan one with no frame clause, and a sorted `count(distinct …) over` with no plan error                                                     | ✅  | `synth/s5-alltypes.out`                            |
| A let name that isn't an identifier types but breaks H2's SQL; an unpartitioned window can't take a rows frame                                                                                        | ✅  | `synth/s6-fixture.out` K, L                        |
| Chain and let typing agree node by node; a `from()` inside a let fails                                                                                                                                | 💭  | `emit-isolation/p4_typing_srcinfo.out`, `p1_*.out` |
| Window result types per family equal §5.7's Group table                                                                                                                                               | 💭  | `partition-node/t1-types.out`                      |
| A single-form window filter is QUALIFY on 4 types, refused on 6 and dropped on 9 (planned again in M5.10)                                                                                             | ✅  | `cross-cutting/out/all.out` w06a                   |
| Null placement in window sorts; windowed DISTINCT refused by Postgres, SQL Server, Databricks and Trino                                                                                               | 💭  | readers' notes; vendor documentation               |
| rowNumber, ntile, percentRank, cumulativeDistribution, lag, lead, first, last and nth run on H2; DuckDB runs the let form and `count(distinct) over`                                                  | 💭  | `partition-node/t5-others.out`, `p8_duckdb.out`    |

**Steps:**

| Step  | Deliverable                                                                                                                                                                       | Done when                                                                                      |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| M5.1  | This subsection and PROGRESS-M5.md (docs only)                                                                                                                                    | Committed and pushed to #5653                                                                  |
| M5.2  | `let` in the IR (origin, role), the serializer and `IRPrinter`; `block` dropped                                                                                                   | Unit tests pass; the engine parses the printed let form to Cube's JSON                         |
| M5.3  | `isolationBoundary`; the isolation pass in `QueryEmitter` (lets, names, the run lambda's shape), through a test-only window node; an engine test through the adapter              | Every M1–M4 golden unchanged; a let-form run lambda types, runs, renders and maps errors on H2 |
| M5.4  | The window functions in the aggregation model, use-aware helpers, the two messages                                                                                                | Every M4 test unchanged; each window cell and message tested                                   |
| M5.5  | Partition in the core: node, validation, schema, row order, emitter, codec; not registered                                                                                        | `printIR` shows each `over()` form, the split extends and the select; codec round trips pass   |
| M5.6  | Builder extraction: the column checklist, the sort row and the aggregation row from the Group and Sort editors                                                                    | M4's Group and Sort editor and draft tests pass unchanged                                      |
| M5.7  | Partition in the builder, registered after Join: draft, editor, notes, help text, icon, `findColumnOrigins`, samples, conformance cases                                           | Builder, registry, spec and conformance tests pass                                             |
| M5.8  | Partition on the engine (H2 values above; DuckDB through the direct connection) and in the browser (built, run, saved, an error inside a let shown on its node)                   | Engine tests pass; a Partition built, run and saved in the browser                             |
| M5.9  | The window composition suite                                                                                                                                                      | Passes in the engine-roundtrip group; a single-form emission fails it                          |
| M5.10 | Partition around the databases: `WINDOW_SHAPES`, the pins above, the row-number guard, the three refusals, ISSUES drafts; the patch changeset                                     | The plan-only test passes on every database type; every gate green                             |
| M5.11 | Both adding-an-operation guides, testing.md (window plans, DuckDB, the composition suite), README lists                                                                           | `yarn check:ci` passes                                                                         |
| M5.12 | Verification (reviewers and a skeptic per finding) and an evidence-folder browser rehearsal (`Apply Window Functions` after `Join Another Input` in the menu)                     | Every finding fixed or recorded; the rehearsal passes                                          |
| M5.13 | A demo video of M5 (§11.3): the palette entry, a running Sum by country against the whole-partition total, Rank and Dense Rank on ties, a top 3 per country, the let in Show Pure | Every caption true on screen; sent to the user                                                 |
| M5.14 | Rebase on the latest master, fold the supersessions below into the plan                                                                                                           | The plan consistent; the PR updated                                                            |

**Landing (user, 2026-10-09).** M5 is pushed to #5653 after each major step, and #5653's title and description grow to
cover it. The ingest branch (`cube-ingest`, based before M4) touches the same registries, `NodeRegistry`, `CubeIR`, the
serializer, `IRPrinter`, the messages and the conformance guard: whichever lands second resolves them. M5 doesn't edit
QUESTIONS.md, which other branches rewrite.

**Risks and open gaps:**

- Only H2 and DuckDB run here: every other database is a plan 💭, including windowed DISTINCT, null placement and
  RANGE ties. Sybase ASE gets a `WITH` and MemSQL's PCT manifest leaves out let tests; whether they accept the let
  form is unknown.
- The engine treats pushing a filter below a single-form window as intended; the array form is safe today only through
  its subselect isolation, and the lets are the guard. The CI engine image moves, so the composition and plan-only
  tests are what catch a change.
- A user may read a Sum as a partition total while a Rank in the same node makes it running: only the editor note
  guards it (Q1).
- The engine nests one subselect per window column, even inside one array-form extend over one `over()` (4 functions, 4
  levels; the select that restores the listed order adds none, M5.5's review ✅); wide Partitions, and a Partition of a
  Partition behind lets, make deep SQL. The plan-only test pins the depth (M5.10).
- The conformance suite never types the let form; M5.3's adapter test does, for the shapes it builds.

**Supersessions** (applied in M5.14 to the sections they change; kept here as the record of what M5 changed):

- D5 and §12.2 item 8: answered (Q1). §4.5: `isolationBoundary` on `TransformDefinition`; a Partition keeps its
  input's order (an exception to "no order", as Distinct).
- §5.7: the window functions' rows (Count rows, Row Number; Rank and Dense Rank never null).
- §8.6: the capture's sort and limit after `from()`, and "a trailing `from()` after lets fails" corrected; typing
  lambdas stay chains; `block` dropped; let names; the row-number forms guarded, not bound; never a let after a Sort,
  by construction.
- §8.8: the Partition row (array form, split extends and the select, the `over()` forms, Row Number); the aggregations
  table's window column (Count rows `{p,w,r|1}:y|$y->size()`, Distinct Value's `count(distinct)` form and portability
  note, Q3 instead of the `groupBy` + join fallback). §8.9: windowed Distinct Count's handling. §8.9 and §12.1: the
  dropped-filter list, from 5 databases to the 9 M5.10's plans found ✅.
- §10.3: the Partition shape. §11.3's M5 row: Row Number, D5's frames, windowed Distinct natively.
- Appendix A: §7.13 (Cube's saved shape, Row Number, Count rows, the rank-needs-a-sort rule and its message, the
  editor's order); §10 (window functions as their own set, Row Number); §16 (the two messages); §17.6 (the Partition
  editor). Appendix B: the window drafts once filed.

### 11.7 M6: Difference and Extend

M6 is built on the branch `cube-m6`, from `cube-dev` (`1f8f8cf0b`), and lands in a PR into `cube-dev` (user,
2026-10-10: with no approver free over the weekend, Cube's PRs are squash-merged into `cube-dev`, which reaches master
through the draft PR #5659). Its status is in [PROGRESS-M6.md](PROGRESS-M6.md). Requirements: the probes under
`m6-requirements/` in the local evidence folder, run on the local engine, and `g5-extend/errmap.mjs` run
again: ✅ where a probe ran, 💭 where only a plan was made or it is reasoned.

This subsection overrides the sections it names until they are updated (see "Supersessions" at its end).

**Settled at the start of M6** (user, 2026-10-10, all on the recommendation):

1. **Where an expression is edited:** inline in the node's panel, a row per column (name, a small Monaco editor, its
   type), as every other Cube editor works; an expand button grows one editor. Not spec §9.1's modal.
2. **What the user types:** a full lambda, `x | $x.PRICE * $x.QTY`, valid Pure as Legend DataCube and Query's text
   mode write it; a new column starts as `x | `. The variable can be renamed.
3. **A column can use the columns above it** in the same node: Cube writes one `extend` per column. One extend can't
   see its own columns (`The column 'a' can't be found in the relation`), a chain can ✅, and the chain plans on all
   20 database types ✅. Reordering columns can break a later one, which validation then reports.
4. **Retyping is automatic:** when an Extend's input or expressions change, the builder retypes it with the engine in
   the background. Meanwhile the node shows as pending (pulsing, "typing new columns" in the header) and the nodes
   after it wait, not errors; a failure shows on the column.
5. **Difference keeps native types** (supersedes spec §7.12's "always Float", and D5's "spec semantics" for the
   types; the rows are the spec's): `x_1` and `x_2` keep their input's type, nullable, and `x_valueDifference` is
   `coalesce(x_1, 0) - coalesce(x_2, 0)`: Integer for the integer family, Float for the float family, Number for
   decimals ✅, never empty. It plans on all 20 types ✅, where `toFloat()` is refused when planning on 7 (DB2, MemSQL,
   Spanner, Trino, Hive, Athena, Composite ✅) and loses a decimal's precision. The engine's own `columnValueDifference`
   keeps Integer too (`tdsExtension.pure`).

**Decided without asking** (each has a precedent or a probe; for review):

- **Difference first:** it is smaller, reuses Join, and settles how it leaves its columns out of
  `buildJoinSchemaColumns`' output (it filters them, with no `exclude` set) before Extend's larger work.
- **Extend is typed over the cube's model, not an empty one** (supersedes §5.7's "empty model"): over an empty model
  the engine can't type an expression with one of the model's enums or functions, or an enum-typed input column, and
  with the model it can ✅. A call takes about 60 ms with Northwind's model, 6 ms without ✅. As built (M6.6), typing
  is the chain Cube types any node, the input's relation then one `extend` per column, not a typed-parameter lambda
  written from Cube's types, so it works for every kind of source; an Extend's types then depend on what the engine
  sees of its input, which the typing records as a digest (M6.11, Retyping below).
- **Extend columns are always nullable.** A nullable column needs `->toOne()` before arithmetic, or the engine
  refuses it (`Collection element must have a multiplicity [1]` ✅); `toOne()` writes nothing in SQL, so an empty
  value still gives an empty result ✅ (§8.4's join condition relies on it). The engine then types the column `[1]`,
  so the conformance cases declare Extend columns wider (§11.5 Q7).
- **Result types:** a primitive (precise or abstract) or an enum, as the engine types it. `Any` (an `if` mixing
  types ✅), a relation, a class or an unknown type is refused with the spec's `"<name>" does not have a valid type.`;
  the editor shows the type the engine gave. As built, an enumeration result reads as a type Cube doesn't know and is
  refused (`dayOfWeek()`; `->toString()` works around it, M6.6).
- **Only a lambda with one parameter** (Q2): body-only text and two parameters get Cube's message before typing,
  since the engine's own are confusing (`Can't find property 'ID' in class … Relation` for two ✅).
- **Names** use `ColumnName.ts`'s rule (≤ 128 code points, folded comparison): not empty, not an input column's,
  not another new column's (spec §7.14's delta).
- **Saved shape** (Cube's own, never the spec's TDS expression objects, §9): `columns: [{name, code, lambda}]`, `code`
  the text as typed and `lambda` the engine's JSON for it without source information, so a cube runs and shows its
  text without an engine call; on a mismatch the JSON wins. With the last typing:
  `typed: {signature, types}` (as built: a digest of the input schema and the columns, and a type per column, every
  column nullable), so a loaded cube has its schema at once; it is retyped once on load, as a source is rechecked,
  which also catches model changes.
- **Emitted** as one `->extend(~[<name>: <lambda>])` per column in listed order, the lambda as the new `lambdaJson`
  IR stamped with the column's origin (a new `EmitRole.EXPRESSION`), so an engine error lands on the node. The user's variable is
  kept. `outputOrder` keeps the input's order (every row is kept, as Distinct).
- **Errors in the editor:** each expression is parsed with the source id `<node id>:<row key>`, so engine
  errors carry positions in the user's text, at parse and at typing ✅ (no prefix arithmetic, unlike DataCube's
  editor). The `->toOne()` hint follows a multiplicity error on `$x.<column>`.
- **Plan check on Validate only** (F10 or the button): `generatePlan` of the node's chain with `from(<runtime>)` on
  the cube's own runtime, since some expressions type and then fail when planned: `dayOfWeek()` on all 20 types,
  `dayOfWeekNumber()` on Hive and Composite, string `+` on Sybase, Hive and Composite, `dateDiff` on 4 ✅. With several
  columns, the plan of each prefix finds the first that fails. Not on every retype: a plan per edit is heavy.
- **Pending is its own state:** a new `ERR_TYPING` marker, styled as pending on the canvas, never as an error; the
  nodes after it show the upstream marker (`ERR_SCHEMAS`), as for any invalid input. Running waits for typing.
- **Retyping:** as soon as an Extend waits (a MobX reaction, without a delay): after an edit that changes its input
  signature or expressions, on load, and when what the engine is given for its input changes though Cube's schema
  doesn't (M6.11). Each typing records a digest of the input's emitted relation and the model (`upstream`, never
  saved), and the editor's own query rule makes an Extend whose digest differs wait (`ERR_TYPING`), so Execute waits
  too: an Extend above that gains or drops `->toOne()` changes what the engine types (Cube's schema says nullable
  either way ✅), as does a model change. All waiting Extends go in one batch (`typeLambdas`); an Extend after a
  pending one waits for the next round. It is not an undo step: the typed node replaces the one sent in the cube shown
  and in each undo snapshot that gives the engine the same input. An answer for a node changed meanwhile is dropped.
  Offline, a typing still current for its input (its signature, and its digest when it has one, as a loaded typing
  doesn't) stays in use, with a warning, as a table keeps its saved columns; any other becomes `failed` with the
  engine's message, and isn't retried until something changes.
- **Closing the editor** (M6.11): a panel whose expressions aren't validated stays open when closed or when another
  node is opened, with a notice to validate them or Cancel (user, 2026-10-10, over dropping them with a notice); Apply refuses them too, since a column's lambda comes only
  from the engine. A background typing of the node the panel shows keeps the panel and its rows.
- **Difference's shape:** `difference`, label `Compare Column Values`, ports `tds1` and `tds2` labelled Left and Right
  (spec §7.0); saved as `{leftColumns, rightColumns, differenceColumns}`, Join's key names (the spec's `joinColumns1`,
  `joinColumns2`); swapping its inputs swaps the key lists, as Join's does.
- **Two Cube checks for Difference** beyond the spec: a difference column can't be a join column, and each output
  name (`x_1`, `x_2`, `x_valueDifference`) must not be another output column's (an input may already have `x_1`).
- **Builder:** Difference registered between Join and Partition, Extend after Partition (spec §7.0's order); help
  texts spec §17.9's; no grid quick action for either. `findColumnOrigins`: Difference's keys as Join's and `x_1`,
  `x_2` to their columns; an Extend column has none (computed).

**Difference** (`difference`), a `BinaryNode`:

- Holds `leftColumns`, `rightColumns` (paired by position) and `differenceColumns`. The constructor refuses only
  wrong shapes.
- Validation, in order: Join's steps 1–4 (both key lists, as many on each side, each pair present and compatible);
  then `Difference columns cannot be empty.`, `Difference columns cannot have duplicates.`, and each difference
  column named, not a join column (Cube), in both inputs (labels `Left difference column` and `Right difference
column`, as Join's keys), of the same type (strict `equals`, spec message) and numeric (spec message); then the duplicate rule with the difference columns as `extra`; then no
  output name taken twice (Cube).
- Schema: `undefined` unless valid; else `buildJoinSchemaColumns(left, right, keys, FULL_OUTER)` without the
  difference columns, then, suffix by suffix (spec §7.12's order, `a_1, b_1, a_2, b_2, a_valueDifference, …`):
  `x_1` (left type, nullable), `x_2` (right type, nullable), `x_valueDifference` (by family, above, not nullable).
- Emitted: each side renames `x` to `x_1` or `x_2`, then Join's FULL path (keys to temporaries, `join(FULL)`, the
  merged keys' `coalesce` extend), then `->extend(~[x_valueDifference: x | $x.x_1->coalesce(0) - $x.x_2->coalesce(0)])`
  (`0.0` for the float family, so the result is Float ✅), then `->select(~[…])` in schema order. `describe()`:
  `Compare Column Values`.

**Extend** (`extend`, label `Extend Columns`), a `UnaryNode`:

- Holds `columns` (`{name, code, lambda}`) and `typing` (`unresolved`, `typed` with its signature and a type per
  column, or `failed` with a message and the column it names; both with the digest of what the engine was given,
  `upstream`, which only the editor reads).
- Validation, in order: `Columns cannot be empty.`; per column, the name (above) and an expression (spec's
  `"<name>" does not have an expression.`); then typing: pending (`ERR_TYPING`) when unresolved or its signature
  isn't the input's, the engine's message on its column when failed, then each type (spec's message). The editor's
  own query rule also makes it wait when its digest isn't its input's (M6.11).
- Schema: the input's columns, then one per column in listed order, typed as the engine typed it, nullable.
- Saved and emitted as above; `describe()`: `Extend with "x", "y"` (spec), redacted to `Extend with 2 columns`.

**Tests.** Core: every message exactly and in order; Difference's schema per family and with different key names;
`printIR` of both emitters; codec round trips and Unknown cases; a stale signature reads as pending. Builder: the
drafts, the editors, retyping with a fake engine (pending, typed, failed, a late answer dropped, offline), the registry.
On the engine: Difference on H2 with matched, left-only and right-only rows, empty values, each numeric family and
key names that differ; Extend's arithmetic, string and date expressions, a column using the one above it, a model enum
and a model function, error positions, the plan failure of `dayOfWeek()`; conformance (the typing form against the
chain's `lambdaRelationType`, Extend declared wider); both in the plan-only test on every database type.

**Engine facts** (probes under `m6-requirements/`, on the g4 model of `m5-requirements` plus a model function):

| Fact                                                                                                                                                                                                         |     | Probe                                       |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --- | ------------------------------------------- |
| Over an empty model, a model enum, a model function and an enum-typed input column fail to type; over the model they type. About 6 ms against 60 ms per call                                                 | ✅  | `p1-typing.out` A1–A3, A10                  |
| A system enum (`DurationUnit.DAYS`) types over an empty model; an `if` of mixed types types as `Any`; a nullable column in arithmetic is refused without `->toOne()`                                         | ✅  | `p1-typing.out` A4, A6, A9                  |
| One extend can't see its own columns; chained extends can, and plan on all 20 types                                                                                                                          | ✅  | `p1-typing.out` B, C4                       |
| `dayOfWeek()` types (as an enum) but fails to plan on all 20 types; `dayOfWeekNumber()` on Hive and Composite; string `+` on Sybase, Hive and Composite; `dateDiff` on Spanner, Hive, BigQuery and Composite | ✅  | `p1-typing.out` A8, C2, C3, C5, C6          |
| Parse and typing errors carry positions in the user's text under the given source id, the same over an empty model and the store's                                                                           | ✅  | `g5-extend/errmap.mjs` (run again)          |
| The emulated Difference types `x_1`, `x_2` as the input's type `[0..1]` and the difference `[1]`; it plans on all 20 types natively, on 13 with `toFloat()`                                                  | ✅  | `p2-difference.out`                         |
| The difference is Integer for Int and BigInt, Number for Double, Float4 and Numeric with `0`, Float for Double with `0.0`; all plan on 20                                                                    | ✅  | `p3-difference-types.out` (type-only casts) |
| Values (matched, one-sided and empty rows) and Float and Numeric columns of a real table                                                                                                                     | ✅  | M6.4 (`LegendCubeOperations`)               |

**Steps:**

| Step  | Deliverable                                                                                                                                                                                                                                        | Done when                                                                       |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| M6.1  | This subsection and PROGRESS-M6.md (docs only)                                                                                                                                                                                                     | Committed and pushed; a draft PR into `cube-dev`                                |
| M6.2  | Difference in the core: node, validation, schema, emitter, codec, messages; not registered                                                                                                                                                         | `printIR` shows the emulation; codec round trips; every message tested          |
| M6.3  | Difference in the builder, registered between Join and Partition: draft, editor (key pairs as Join's, a checklist of the numeric columns both inputs share), help text, icon, `findColumnOrigins`, samples, conformance cases                      | Builder, registry, spec and conformance tests pass                              |
| M6.4  | Difference on the engine (H2 values above, DuckDB), around the databases (shapes pinned on every type) and in the browser                                                                                                                          | Engine and plan-only tests pass; built, run and saved in the browser            |
| M6.5  | Extend in the core: columns, typing state and signature, validation, schema, row order, emitter (`EmitRole.EXPRESSION`), codec, `ERR_TYPING`; not registered                                                                                       | `printIR` shows the chain; codec round trips; a stale signature is pending      |
| M6.6  | The engine adapter: parse an expression with its source id, render it, type Extends over the model in one batch, plan a node's chain; errors mapped to node and column                                                                             | Engine tests for the facts above pass                                           |
| M6.7  | Retyping in the builder: after edits, on load and on a model change; pending on the canvas; not an undo step; late answers dropped; offline                                                                                                        | Store tests with a fake engine; the canvas shows "Typing…", then the types      |
| M6.8  | The Extend editor, registered after Partition: rows with Monaco, the input's columns (a click writes `$x.NAME`; no completion), Validate (F10) with markers and the `->toOne()` hint, add, remove, move, expand, read-only; help text, icon, notes | Editor tests pass; built, validated, run and saved in the browser               |
| M6.9  | Extend on the engine and in the conformance suite (the values and cases above), its shapes in the plan-only test                                                                                                                                   | Engine, conformance and plan-only tests pass                                    |
| M6.10 | Both adding-an-operation guides (a node the engine types), testing.md, README lists, the patch changeset                                                                                                                                           | `yarn check:ci` passes                                                          |
| M6.11 | Verification (reviewers and a skeptic per finding) and a browser rehearsal                                                                                                                                                                         | Every finding fixed or recorded; the rehearsal passes                           |
| M6.12 | A demo video of M6's features (§11.3): Difference and its editor, Extend's editor, typing, errors and a column using another, with captions; key frames checked against their captions                                                             | The video plays every M6 feature, each caption true on screen; sent to the user |
| M6.13 | Fold the supersessions below; the PR ready for `cube-dev` on the user's word                                                                                                                                                                       | The plan consistent; the PR ready                                               |

**Risks and open gaps:**

- An expression is arbitrary Pure on the user's model, as in Query's text mode: nothing new is exposed, but a function
  that types may still fail on the database, which only the plan check (on Validate) or a run shows.
- Typing over a large model costs a full compile per call (60 ms on Northwind); batching and the input signature keep
  calls few. If large models are slow, a model trimmed to the elements an expression names is the follow-up.
- Saved `lambda` JSON follows the engine's protocol version; a newer engine reads older JSON, but an older engine may
  not read newer JSON (as every saved lambda in Cube).
- Difference's FULL join plans as a native `full outer join` on 19 database types and is emulated on H2 (§8.8's Join
  row, M6.4); its values on databases other than H2 and DuckDB are plans only 💭.

**Supersessions** (applied in M6.13 to the sections they change; kept here as the record of what M6 changed):

- D5: Difference's types are native (Q5), its rows the spec's. §5.7: Extend typed over the cube's model, with the
  input signature, saved; Extend columns nullable. §5.8: the saved Extend shape.
- §8.8: the Difference row (native types, `coalesce(…, 0)`, `0.0` for floats) and the Extend row (one extend per
  column, the origin, the plan check); §8.8's expression notes (the cube's model, positions under a source id).
- §10.3: the Difference and Extend shapes. §11.3's M6 row: inline editing, chained columns, native types.
- Appendix A: §7.12 (native types, the two Cube checks, Left and Right); §7.14 (one lambda per column, columns using
  the ones above, always nullable, saved shape); §9 (Pure lambdas typed by the engine instead of TDS expression
  objects and §9.2's table; the editor inline, not a modal); §16 (the new messages); §17.6 (both editors).
- As built (M6.2–M6.11, folded in M6.13 with the above): §4 (Difference filters its columns out of
  `buildJoinSchemaColumns`, no `exclude` set); §4.11 (M6's messages); §5.7 (typing as a chain, its digest, the
  enumeration gap); §8.8's Join row (FULL native on 19 types); this subsection's saved typing, `lambdaJson`, source
  ids, Difference's check order and labels, the editor without completion, retyping and closing.

### 11.8 M3b: canvas and layout

M3b is built on the branch `cube-canvas`, from finos master `d847e6721` after M4 merged as #5649. Its first commit,
`12c8782f1`, copies the user's answers to [QUESTIONS.md](QUESTIONS.md) from #5655. Its status is in
[PROGRESS-M3b.md](PROGRESS-M3b.md).

Requirements came from `m3b-requirements` (run `wf_b6df1aad-dfc`):

- **Readers:** four, covering the answers and spec §17, the canvas and palette code, the editor shell and every editor,
  and how to build a floating host.
- **Synthesizer:** one, which checked the readers' claims.
- **Output:** 69 checklist items (C-01 to C-69), a 19-step build order, a per-editor fit table and 3 questions. The
  full result is `m3b-requirements-result.json` in the local evidence folder, with the reports under
  `m3b-requirements/`.

The grid is M7 and not part of M3b.

This subsection overrode the sections it names until M3b.18 folded it into them (see "Supersessions" at its end). It
takes over §12.2 item 1, which asked where the node editor opens:

- The original app opens a small floating editor anchored just below the node (spec §17.5's popover), where Cube had a
  side panel (§7.1, until M3b).
- Going back means a floating host for the same editors. §7.4 keeps the editors independent of where they are shown.
- The host must follow the spec's rule that clicking outside never closes the editor while a dropdown, picker or dialog
  opened from it is open.
- It must stay on screen near the canvas edges and scroll a tall editor.

**Settled by the answers** (user, 2026-10-09, QUESTIONS.md U1–U4 and U9; the Plan effects decide what Cube changes):

- **Adding (U3).**
  - A transform added without a node target goes after the selected node, spliced in. This covers a palette drop that
    misses a node, a right-click on empty canvas and 'Add Items'. It stands alone only in an empty query.
  - Dropped onto a node, or added from that node's menu, it goes after that node.
  - An empty query has no right-click menu.
  - The new node becomes the capture node only when it was added after the selected node (`Query.add`, unchanged).
  - No add opens the editor, and the defaults on creation are unchanged. _(2026-10-11: reversed by the user, an added
    transform opens its editor; QUESTIONS.md U3.)_
  - §7.3's drop row is corrected (it said "unconnected", following spec §17.4).
- **Sources (U4).**
  - Every way of adding a source opens the source dialog on that source's tab. That includes a source dropped onto a
    node (the node ignores it) and a node's right-click menu, where source items become enabled.
  - The node appears, unconnected, after the dialog's confirm.
  - The empty canvas's link opens the dialog with **no tab selected** and the prompt "Select source type above".
  - Per U4's Plan effect, the rest is what Cube already does: opening on click or drop, closing after each Add, and the
    Relational item's fallback to the table tab used last.
- **Finishing an edit (U2).**
  - Clicking another node applies this editor and opens that one in the same click.
  - Ctrl/Cmd+click on a node applies and closes the editor, then makes that node the capture node without opening it.
  - Execute and F9 apply the open editor first, then run the edited query.
  - While a dropdown, picker or dialog opened from the editor is open, outside clicks change nothing.
- **The node's messages (U1(c)).**
  - The node's tooltip shows after 500 ms, above the node, with its messages deduplicated and joined by newlines.
  - The red (invalid) and amber (incomplete) borders stay.
  - When an input is broken, the editor body is replaced by the upstream message.
- **Links from other screens (U9).**
  - Arriving never opens an editor or a dialog.
  - With `?sourceType=…&sourceId=…`, the source lands alone as the capture node, and the parameters are stripped
    whether it resolves or not. A source that can't be resolved shows "Error resolving source!" with the engine's
    message.
  - `?queryId=` wins when both are given. It stays in the address bar, which corrects spec §17.15. It needs M8.

**Settled at the start of M3b** (user, 2026-10-09, the requirements' questions):

1. **A floating editor** (Q1, recommended), replacing the side panel:
   - **Placement and size.**
     - Below its node, centred, 8px under it.
     - One fixed width for every node type: **432px**. U1's 27rem is read at a 16px root. Legend's root is 62.5%
       (`legend-art/style/normalize.scss`), so it is written in px, never `w-[27rem]` (which would be 270px).
     - A fixed title bar over a body of 80px to 33vh that scrolls.
   - **Over the grid.** It floats over the results grid, since the 60% cap is the canvas's, not the editor's.
   - **Edges.** It flips above the node when there is no room below and more above, and shifts to stay inside the
     window. Whether the original flips is unknown; this is Cube's choice.
   - **Following the node.** It follows its node through pan, zoom, fitView and resizes. Dialogs and menus opened from
     it sit above it.
   - **Built as** a non-modal MUI Popper (legend-art `BasePopper`) portalled to the body, anchored to the node's screen
     rectangle computed from the dagre layout and xyflow's viewport, at z-index 1250 (MUI modals are 1300). No new
     dependencies. xyflow's `NodeToolbar` is clipped by the canvas, and Data Cube's react-rnd windows have no
     click-away, so neither fits.
2. **Apply and Cancel stay** (Q2):
   - The footer keeps Apply and Cancel, and Cancel discards the edits.
   - Every other way of closing applies them: the ×, an outside click, another node, Ctrl+click, Execute or F9, the
     Undo button, hiding the graph, and emptying the query.
   - **Escape closes and applies**, as the original's does (decided without asking, since Q2's "Escape discards" option
     was not chosen). A field that uses Escape itself, such as a value being typed, takes the first press.
3. **The palette keeps its click** (Q3, recommended). It follows U3's rule: a transform goes after the selected node,
   and a source opens its tab. An **'Add Items ▾'** drop-down in the graph toolbar, listing the same items, replaces
   'Add table'.
4. **Entry links for data product access points only** (Q4, recommended), as the last step, which can be cut:
   - Legend Query reads and strips `?sourceType=dataProductAccessPoint&sourceId=…`.
   - The access point lands alone as the capture node.
   - The other source kinds wait, since a model table needs a model context and a direct connection needs settings
     that don't belong in a URL.
   - Which screens link in still needs the team (U9).

**Decided without asking** (from the requirements, for review):

- **One finish path.** Every close that applies goes through `nodeEditor.finish()`, in this order:

  1. blur the focused field inside the editor, so a value typed but not yet committed is kept;
  2. apply the data product warehouse text typed but not applied;
  3. apply the edits as one undo step.

  A draft the query can't take shows a notice instead of vanishing silently.

- **Outside actions apply first.** A node drag (connect or move), a handle connect and every canvas context-menu item
  apply the open editor first. Today they replace the node under the edits, which drops them with a notice.
- **The click-away runs on pointerdown,** so Execute, Undo and the grid's actions see the applied query. These don't
  close the editor:

  - a pointerdown on a node: the node's click handles it;
  - on the pane, the controls or the minimap: a pan or zoom keeps the editor; a plain click on the empty pane closes it;
  - on the splitter between the graph and the results, which the editor follows (M3b.5);
  - inside a MUI modal or popper;
  - while a Cube dialog is open, or while `nodeEditor.holdOpen()` is held (the spec's rule 2, for later editors'
    pickers);
  - a right-click.

  Native selects and date pickers are checked in a browser.

- **Later editors and portals** (M3b.4). A dropdown, picker or dialog opened from the editor and shown elsewhere (a
  portal) must be one of MUI's layers or call `nodeEditor.holdOpen()`. Otherwise a press in it closes the editor.
- **Grid quick actions while editing** (M3b.4). Sort by, Group by and Filter by are disabled while the editor holds
  edits: choosing one would apply them first, leaving the rows the menu was opened on stale.
- **Add Items** (M3b.9). It is a menu of the palette's items (legend-art's `DropdownMenu`, as Data Cube's): the arrow
  keys move through it, Enter adds, Escape closes it, and the focus goes back to its button. A disabled source item
  says why (another kind of source than the cube's, or the cube's own reason); the context menu lists the same items.
- **Header actions** (M3b.4). Show Pure, Export, Add table and a palette click apply the editor first, by keyboard
  too, as Execute and Undo do.
- **Shortcuts.**
  - F9 with changes runs even while the committed query is invalid: it applies first, then runs only if the result can
    run. The Execute button keeps its gating.
  - Ctrl+Z, like the Undo button, applies and then undoes, which drops the edits.
  - In a text field, Ctrl+Z stays the field's own.
  - Shortcuts do nothing while the editor is held open.
- **Kept from Cube:**
  - the problems list under the body (U1's Plan effect calls it better than the original), pinned between the body
    and the footer;
  - the node id in the title;
  - the net-change rule (an edit put back by hand adds no undo step);
  - the rule that the editor follows the cube on a re-check or Import.
- **Title.** The editor's title shows the label with each word capitalised (U1(a)), for display only.
- **Accessibility and test hooks.** The editor is `role=dialog`, non-modal, named by its title. Focus moves into it when
  it is opened from the keyboard, and returns to the node when Escape, Cancel or the × closes it with the focus inside
  it (or nowhere); focus the user moved elsewhere stays there. The problems strip shows three lines, then scrolls. Its
  test id stays `NODE_EDITOR`.
- **No footer on a source.** A source's editor has nothing to Apply or Cancel, so it has no footer: every close applies
  the warehouse text typed in a data product's editor (M3b.7).
- **One scroller.** The body is the editor's only scroller. Sort, Rename, Restrict and Group drop their inner caps, and
  Join's autofix list wraps long names.
- **Wording and the last tab.**
  - The empty canvas reads "[Connect to a source] to start a new one." This is the spec's wording, without "Load an
    existing query" until M8.
  - The Relational item keeps opening the cube's fixed tab, else the table tab used last.

**Editors in the floating host** (432px; body 80px to 33vh, about 297px tall in a 900px window):

| Editor                 | Fits                                                 | Work in M3b                                                    |
| ---------------------- | ---------------------------------------------------- | -------------------------------------------------------------- |
| Join                   | yes, tight: column selects clip names over ~12 chars | autofix list wraps; re-anchor after Swap Inputs and the fix    |
| Filter                 | yes, to about 4 nesting levels                       | blur before finish; native date pickers checked in the browser |
| Source (model, direct) | yes; the column table scrolls in the body            | none                                                           |
| Data product source    | yes                                                  | warehouse text applied on finish                               |
| Sort, Rename, Restrict | yes (Rename tight)                                   | inner `max-h-80` removed                                       |
| Distinct               | yes (text only)                                      | none                                                           |
| Limit, Drop, Slice     | yes                                                  | blur before finish                                             |
| Group                  | in width; ~650px tall, so it scrolls                 | inner `max-h-60`/`max-h-80` removed; problems pinned           |
| Concat                 | yes (`table-fixed`)                                  | fix buttons re-anchor after the re-layout                      |

**Steps:**

| Step   | Deliverable                                                                                                                                               | Done when                                                                                 |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| M3b.1  | This subsection, PROGRESS-M3b.md, §7.3's drop row and §12.2 item 1 (docs only)                                                                            | Committed; the draft PR open                                                              |
| M3b.2  | One finish path (`finish()`, `holdOpen()`), Ctrl+click and F9 and Ctrl+Z applying first, outside actions applying first; in today's side panel            | The finish, shortcut and outside-action tests pass                                        |
| M3b.3  | The floating host behind a prop: `CubeNodeEditorPopper`, the anchor function, the float layout of the panel                                               | Anchor tests pass; in the browser every node type opens below its node                    |
| M3b.4  | The click-away and Escape: pointerdown rules, pane click, hold, hiding the graph and emptying the query                                                   | Ordering and hold tests pass; in the browser B opens in one click, Execute runs the edits |
| M3b.5  | Switch over: the side panel removed                                                                                                                       | Every editor test green in the floating host                                              |
| M3b.6  | The editor's frame: title case, problems and footer pinned under the scrolling body, focus and `role=dialog`                                              | Frame tests pass                                                                          |
| M3b.7  | Each editor's sizing and edges (table above)                                                                                                              | In the browser every editor fits 432px with one scroller at 900px and 768px               |
| M3b.8  | One placement rule (`CubeAddPlacement.ts`) for palette click and drop and both context menus; sources on a node open their tab; no menu on an empty query | Placement and menu tests pass                                                             |
| M3b.9  | 'Add Items ▾' in place of 'Add table', from one shared item list                                                                                          | Add Items tests pass                                                                      |
| M3b.10 | The source dialog's no-tab state for the empty canvas's link; the empty-canvas wording                                                                    | Picker and canvas tests pass                                                              |
| M3b.11 | The node tooltip: legend-art Tooltip, 500 ms, above, one line per message                                                                                 | Tooltip tests pass; checked in the browser                                                |
| M3b.12 | Entry links for data product access points (Q4; can be cut)                                                                                               | Query page tests: stripped on success and failure, the banner, queryId wins               |
| M3b.13 | Docs and guides (hosting, editor contract), docstrings, one patch changeset                                                                               | `yarn check:ci` passes                                                                    |
| M3b.14 | The PR's description; marked ready for review                                                                                                             | Ready, every gate green                                                                   |
| M3b.15 | Verification (reviewers and a skeptic per finding), on the open PR                                                                                        | Every finding fixed or recorded                                                           |
| M3b.16 | A browser rehearsal of every gesture at window heights of 900px and 768px                                                                                 | Every item passes, with a screenshot                                                      |
| M3b.17 | A demo video (§11.3)                                                                                                                                      | Every caption true on screen; sent to the user                                            |
| M3b.18 | Rebase on the latest master; fold the supersessions below                                                                                                 | The plan consistent                                                                       |

Each step's tests run in jsdom where they can. Placement, flipping, 33vh and following pan and zoom are checked in the
browser on the dev server at :9003, since jsdom measures nothing.

**Coordination.** The sources session edits the same files: `CubeEditor.tsx`, `CubeEditorState.ts`,
`components/canvas/*`, `CubeNodeEditorPanel.tsx` and `CubeSourcePickerState.ts`. To keep conflicts small:

- new behaviour goes in new files (`CubeNodeEditorPopper.tsx`, `CubeNodeEditorAnchor.ts`, `CubeAddPlacement.ts`,
  `CubeAddItems.tsx`), and the shared files get call-site hunks;
- M3b.7's warehouse text, M3b.10's no-tab state and M3b.12's headless access point resolve touch that session's state;
  each is a small, separate hunk;
- the ingest source editor (branch `cube-ingest`) must follow the 432px width, the one-scroller rule and the finish path
  when it lands.

**Risks and open gaps:**

- **Untested in a browser.** The click-away order is untested there. pointerdown should run before Execute's click,
  and M3b.4 proves it.
- **Native popups.** Whether a native select's or a date picker's dismissing click reaches the page varies by browser.
- **Height.** At 33vh, Group, a long Filter, Concat and the source column tables scroll.
- **Short windows.** The editor goes above its node only when it fits there whole; when neither side fits (a tall
  editor, a node mid-window, a window around 560px tall), it stays below and shifts up into the window, over its own
  node (M3b.3, Popper's flip and preventOverflow).
- **Unverified width.** 432px can't be checked against the original, whose answers come from its specs.
- **Side branches.** Every untargeted transform add now moves the capture node. A side branch is built by dropping onto
  a node or by connecting handles.
- **Later editors.** M5's Window editor, M6's expression editor and the type-aheads must use `holdOpen()` and fit 432px.

**Supersessions** (folded in at M3b.18, 2026-10-10, along with Part B's steps 1–3; kept as the record of what M3b
changed):

- **§7.1:** the node editor floats below its node, in place of the side panel. The toolbar has 'Add Items ▾' in place
  of 'Add table'.
- **§7.2:** the node tooltip is legend-art's, after 500 ms.
- **§7.3:** palette click and drop and the context menus follow U3 (sources open their tab, even from a node); an empty
  query has no menu; Ctrl/Cmd-click applies first.
- **§7.4:** the floating host, the finish path, Escape, the click-away and the hold. Every close but Cancel applies.
  "Following the cube" is kept for re-checks and Import, while user actions outside the editor apply first.
- **§7.8:** `nodeEditor` gains `finish()` and `holdOpen()`; panel sizes no longer apply.
- **Settled at the start of M1.8b (§7.8):** the palette source item now also opens its tab when dropped onto a node.
- **Appendix A:**
  - §17.4: a transform drop is after the selected node (U3);
  - §17.5: the floating editor, with Cube's Apply/Cancel footer and problems list;
  - §17.15: queryId stays in the address bar (U9).

### 11.9 M5b: More window functions

M5b adds window functions to Partition (`Apply Window Functions`), which M5 built (§11.6). It is built on the branch
`cube-m5b`, stacked on `cube-m6` because #5662 isn't merged yet and both change the window and editor code, and lands
in a PR into `cube-dev`, rebased onto it once #5662 merges. Its status is in [PROGRESS-M5B.md](PROGRESS-M5B.md).
Requirements: the probes under `m5b-requirements/` in the local evidence folder, run on the local engine, and M5's
`m5-requirements/partition-node/t5-others`: ✅ where a probe ran, 💭 where only a plan was made or it is reasoned.

This subsection overrides the sections it names until they are updated (see "Supersessions" at its end).

**Settled at the start of M5b** (user, 2026-10-10, all on the recommendation):

1. **Seven functions:** Lag, Lead, NTile, Percent Rank, Cumulative Distribution, First and Last. Not Nth: "the
   partition's nth row" needs a whole-partition frame, which the engine writes only with a partition column
   (`over(~[], [sorts], rows(…))` is an NPE ✅, and there is no `over([sorts], rows(…))` ✅), and SQL Server has no
   `nth_value` 💭, though the engine plans it there ✅.
2. **Lag and Lead take an offset**, 1 by default, and nothing else: the first rows (Lag) or the last rows (Lead) of
   each partition are empty, as in SQL ✅.
3. **Last is the partition's last row** in the window's sort order, the same on every row, the mirror of First. It is
   written as First over the reversed sort, which gives the values `last_value` gives over a whole-partition frame ✅,
   needs no frame and works with no partition column ✅. The engine's own `last()` with no frame is the current row ✅.

**Decided without asking** (each has a precedent or a probe; for review):

- **Every new function needs a sort**, as Rank does: without one, H2 refuses Lag, NTile, Percent Rank and Cumulative
  Distribution (`ORDER BY NULL` ✅), and First and Last would take any row. The message is M5's
  `Aggregation function "<a>" requires at least one sort column.`
- **Columns and types:** Lag, Lead, First and Last take a column of any type a window can partition by
  (`isSortableType`: not VARIANT or a type Cube doesn't know), and give that column's type, nullable ✅
  (`Double[0..1]`, `Varchar(15)[0..1]`, `StrictDate[0..1]`). NTile is Integer, Percent Rank and Cumulative
  Distribution are Float, none of them nullable ✅. They take no column.
- **NTile takes a bucket count**, 4 by default (quartiles). More buckets than rows gives each row its own ✅.
- **Offsets and bucket counts** are whole numbers of at least 1, checked by Cube (two new messages). The engine has no
  `offset()` a query can call ✅, so Lag and Lead are the only way back or ahead.
- **Names:** saved as `Lag`, `Lead`, `NTile`, `PercentRank`, `CumulativeDistribution`, `First` and `Last`; shown as
  `Lag`, `Lead`, `NTile`, `Percent Rank`, `Cumulative Distribution`, `First` and `Last`. Auto-names as M5's:
  `<column> Lag` for a function of a column, the shown name for the others. A Group never offers them (§11.5 Q4).
- **Emitted** in M5's two extends and a third:
  - the aggregates in the first, as before; an aggregate can't share an extend with the new functions
    (ClassCastException ✅, as with the ranks);
  - the rank functions, NTile, Percent Rank, Cumulative Distribution, Lag, Lead and First in the second:
    `$p->ntile($r, n)`, `$p->percentRank($w, $r)`, `$p->cumulativeDistribution($w, $r)`, `$p->lag($r, n).c`,
    `$p->lead($r, n).c` and `$p->first($w, $r).c`. One extend holding them all runs on H2 ✅ and plans on the 17 window
    types ✅;
  - Last in a third, over the window with every sort reversed: `->extend(over(~[p…], [~s->descending()]),
~[n: {p,w,r | $p->first($w, $r).c}])`. It plans on the 17 window types ✅.
    Then, as before, a `select` when the functions are listed in another order. No frame is written (D5).
- **Databases:** every function plans on the 17 window types ✅; Spanner, Presto and Composite refuse any window, as in
  M5 ✅. DuckDB writes First as `first(…)` ✅. ClickHouse writes Lag and Lead as `lagInFrame`/`leadInFrame` over a
  whole-partition frame and First as `first_value` ✅, and those give wrong rows there (M5b.6, from ClickHouse's
  documentation 💭; ISSUES.md): First and Last skip NULLs, and Lag and Lead give a non-Nullable column's type default
  at a partition's edges instead of empty.
- **Ties:** Lag, Lead, First and Last take any of the rows tied on the sort, as Row Number does (§11.6 Q2); the editor
  says so.
- **Saved shape:** a window function's entry gains `offset` (Lag and Lead) or `buckets` (NTile), a JSON number, written
  whenever the entry has one (the editor gives each its default when the function is picked). A missing one stays
  missing and is reported, as a Limit's cleared size is, so an invalid setting is never read back as a valid one
  (M5b.6). An offset or a bucket count on a function that takes none, or one that isn't a whole number of at least 1,
  is kept and reported. An unknown key still makes an Unknown node (§10.3).

**Engine facts** (probes under `m5b-requirements/`):

| Fact                                                                                                                                                                      |     | Probe                           |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | ------------------------------- |
| Lag and Lead, offsets 1 and 2, of a number, a text and a date: values and types as SQL's; the edges empty                                                                 | ✅  | `p1-semantics.out` A1           |
| `offset()` can't be called; Lag, NTile, Percent Rank and Cumulative Distribution with no sort fail on H2                                                                  | ✅  | `p1-semantics.out` A2, A3, B2   |
| NTile 4, 10 and 1, Percent Rank and Cumulative Distribution: values as SQL's, types Integer and Float, never empty                                                        | ✅  | `p1-semantics.out` B1, B3       |
| With no frame, First is the partition's first row, Last the current row, Nth empty until the nth; over a whole frame, each is the partition's                             | ✅  | `p1-semantics.out` C1, C2       |
| A rank, NTile, Percent Rank, Lag and First share an extend; an aggregate can't share one with them                                                                        | ✅  | `p1-semantics.out` D1–D5        |
| First over the reversed sort gives `last_value` over a whole frame; a frame can't hold a rank or a Lag; no frame without a partition column                               | ✅  | `p3-frames.out` F1, F5–F7       |
| Each function alone, and the shape Cube writes, plan on the 17 window types                                                                                               | ✅  | `p2-plans.out`, `p4-plans*.out` |
| Reversed, a sort puts empty values at the other end too: where a database's default wouldn't (DuckDB, Trino, ClickHouse and others), the engine writes `desc nulls first` | ✅  | `LegendCubeDialects` (M5b.4)    |
| Values on H2 (ALFKI) and DuckDB, and on H2 against the composition suite's reference; Cube's types are the engine's, none declared wider                                  | ✅  | M5b.4                           |

**Steps:**

| Step  | Deliverable                                                                                                                                                           | Done when                                                                  |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| M5b.1 | This subsection and PROGRESS-M5B.md (docs only)                                                                                                                       | Committed and pushed; a draft PR into `cube-dev`                           |
| M5b.2 | The core: the seven functions, their types, validation (sort, offset, buckets), auto-names, the emitter's three extends, the codec's `offset` and `buckets`, messages | Every message tested; `printIR` shows the three extends; codec round trips |
| M5b.3 | The builder: the window function rows with an Offset or Buckets field, the column lists, help text and notes, samples                                                 | Editor and draft tests pass                                                |
| M5b.4 | On the engine: values on H2 and DuckDB, the plan-only test's window shapes on the 17 types, the composition suite's reference, conformance cases                      | Engine, plan-only, composition and conformance tests pass                  |
| M5b.5 | Guides, testing.md, the patch changeset                                                                                                                               | `yarn check:ci` passes                                                     |
| M5b.6 | Verification (reviewers and a skeptic per finding) and a browser rehearsal                                                                                            | Every finding fixed or recorded; the rehearsal passes                      |
| M5b.7 | A demo video of the new functions (§11.3), key frames checked against their captions                                                                                  | The video plays every new function, each caption true on screen; sent      |
| M5b.8 | Fold the supersessions below; the PR ready for `cube-dev` on the user's word                                                                                          | The plan consistent; the PR ready                                          |

**Risks and open gaps:**

- Values on databases other than H2 and DuckDB are plans only 💭; an older database may refuse a function it plans
  (an error), as with M5's windowed Distinct Count. ClickHouse is the exception: Lag, Lead, First and Last can give
  wrong rows there (Databases above), which only the engine can correct.
- Nth, a default value for Lag and Lead, and frames stay out; each is a follow-up if users ask.

**Supersessions** (applied in M5b.8 to the sections they change; kept here as the record of what M5b changed):

- §5.7: the window functions' rows and nullability. §8.8: the Partition row (the third extend, the new functions)
  and the window column of the aggregations table; "Window functions beyond the spec" (all offered but Nth).
- §10.3: the Partition shape (`offset`, `buckets`). §11.3: the M5b row.
- Appendix A: §10 (the functions beyond the spec), §16 (the two messages), §17.6 (the Partition editor's fields).

### 11.10 M7: Grid and presentation

M7 is built on the branch `cube-m7`, from `cube-dev` (`b10f82d0c`, which is finos master with every Cube PR through
#5666), and lands in a PR into `cube-dev`. Its status is in [PROGRESS-M7.md](PROGRESS-M7.md). Requirements: the
probes under `m7-requirements/` in the local evidence folder, run on the local engine against Cube's Northwind fixture
with its `CUBETEST` tables: ✅ where a probe ran, 💭 where only a plan was made or it is reasoned.

This subsection overrode the sections it names until M7.16 folded it into them (see "Supersessions" at its end).

**Settled at the start of M7** (user, 2026-10-11, all on the recommendation):

1. **Server-side mode is the default** (spec §12.1). A column header's sort and a drill-down re-derive the query and
   run it on the engine; today's grid sorts only the rows it fetched, which can mislead. **Local mode** stays as a
   toggle: one run, then ag-grid's own grouping, pivot, filters, tool panels and status bar on the fetched rows. Adding
   a drill-down level in local mode switches back to server-side (spec §12.1). _(M7.6: switching modes runs nothing
   again and keeps the rows current, where the spec resets the run, since Execute is the same flat run in both modes;
   the mode is remembered for the user.)_
2. **Each level is fetched whole**, bounded by the row limit (spec §12.3): the top level, and each expanded group's
   children, is one query with `limit(rowLimit + 1)`, its truncation shown. Not paging with Slice, which needs a total
   order to keep blocks from repeating or skipping rows (§11.4's row order). Answers are kept in a bounded LRU cache
   keyed by the derived query (§21's unbounded cache).
3. **Export:** CSV runs the whole query (the capture node, with the grid's sort, and no row limit) on the engine, as
   Legend DataCube does; XLSX is ag-grid's export of the rows the grid has loaded, expanded groups included, and its
   menu item says so. The engine has no XLSX format ✅.
4. **A group row's cells:** each numeric column shows its Sum, other columns are left empty, and a `Count Rows` column
   counts the group's rows; the drill-down's settings change any column's aggregation (supersedes spec §12.2's
   `fullSchema`, which counts every column).

**Decided without asking** (each has a precedent or a probe; for review):

- **Execute stays the flat run.** Execute runs the capture node as today: its rows, errors on nodes, staleness and
  the quick actions are unchanged, and local mode shows them. In server-side mode the grid's fetcher serves every
  level from the run on screen, the top one included whenever a drill-down or a header sort is set, at the cost of one
  more engine call after such an Execute. The run path is shared (`runCubeQuery`, M7.5).
- **Derived from the run, never the live cube.** An expansion or a sort uses the query, the context and the warehouse
  of the run on screen (`CubeExecutionResult`), so a stale grid never runs an edited cube under the old rows. The
  query is cloned and Cube's own nodes appended after its capture node: a Filter per expanded level, a Group for the
  next level, a Sort; the row limit is the emitter's. So every database workaround of §11.4 (row numbers, the padded
  Distinct, the runtime's database type) applies unchanged, and the outline is loaded before each derived run, as
  `needsDatabaseType` asks. The derivation is pure and lives in the core, unit- and engine-tested; the grid adapter
  stays thin, since ag-grid's server-side model can hardly be tested in jsdom.
- **Typed group keys.** A child request reads its keys from its parent rows' data, the values as the engine returned
  them, never ag-grid's string key, so no `"(null)"` sentinel exists (spec §12.2's `convertValueToKey`). An empty key
  becomes `IsEmpty`, any other `Equal`. A key's literal is the quick action's (`buildQuickFilterRule`), so a
  timestamp drops its `+0000` as Filter by does.
- **Decimal literals become strings first** (M7.2, ISSUES.md "Decimal literals lose precision"). An Equal on a
  Decimal group key would otherwise reach the known bug: the engine reads a JSON number through a double, so
  `1.23450000000000000001` matches `1.2345`, and a string keeps every digit ✅ (`p2-csv-decimal.out`).
- **A float key is matched as either width.** A 32-bit column's key comes back as its shortest text (`0.02`), and
  `Equal` with that double finds no row: 0 of 25 `FREIGHT` keys, 2 of 11 `DISCOUNT` keys ✅ (`p3-float-keys.out`). Cube
  can't tell a 32-bit column from its type: the engine types H2's `REAL` (32 bits) as `Double` and H2's `FLOAT` (64
  bits) as `Float4` ✅ (`CubeNorthwindModel.ts`, `p1-drill.out` E and I), so writing every `Float4` value as a 32-bit
  float would break `FLOAT` columns. A float key is matched by `In [v, fround(v)]`, the value as returned and the
  32-bit float nearest it written in full (`0.019999999552965164`), or by `Equal` when the two are the same; every
  `REAL`, `FLOAT` and `DOUBLE` key is then found again ✅ (`p3c-in-both.out`). Two groups merge only if one's key is
  exactly the other's 32-bit neighbour, which a 64-bit column can hold but the engine would then return with its own
  text 💭. Double, date and timestamp keys are found as returned ✅. M2's 'Filter by' on a `REAL` cell finds nothing
  for the same reason; it is an ISSUES entry, as its filter is the user's to read.
- **A group's aggregations are mapped back to their columns.** A level's Group names each aggregation as any Group
  does (`FREIGHT Sum`, M4's auto-name, numbered past a name the input has), since Cube's Group refuses an output
  named as an input column (§11.5), though the engine would take it ✅ (`p4-names.out`). The derivation returns, with
  the query, which output column holds each grid column's value, so a group row's `FREIGHT` cell shows the Sum and
  group and leaf rows share the grid's columns. `Count Rows` takes the M4 rule's next free name. Sorting a group
  level by a column sorts by its aggregation, and a column with none isn't sortable at that level; at the leaf level
  the same sort is the column's own (spec §12.2's remapping).
- **The Sum's nullability:** the engine types a Sum `[1]` but returns empty for a group of empty values ✅
  (`p1-drill.out` G); Cube's own types say nullable, as M4 settled (§11.5, `widerNullable`).
- **Drill-down settings are saved in the cube** (spec §13 `Meta.drilldown`), as `meta.drilldown: {columns,
aggregations}`, an aggregation per column only where it differs from the default; a cube without a drill-down writes
  nothing. An older Cube keeps the key in `meta`'s rest and writes it back (§10.3). A level whose column the capture
  node no longer has, or can't group (`isSortableType`), is reported in the grid and left out of the run; the cube
  still runs.
- **Entering a drill-down:** the cell menu's 'Drilldown by "X"' adds X as the next level (server-side mode only, spec
  §12.4). A bar above the grid lists the levels (`SHIP_COUNTRY › SHIP_CITY`), each removable, with the aggregation
  settings in a dialog ('Group rows…', a dialog rather than the popover first planned); changing it re-fetches,
  without an Execute.
- **The quick actions on a group row** filter by the cell's group key (Filter by) or its column (Sort by, Group by), as
  on a leaf row; on an aggregated cell Filter by is disabled, since the value isn't a row's.
- **Local mode's totals** (M7.6) read a column's values as JS numbers: the engine writes integers and decimals as
  exact text, which ag-grid's own Sum would join as text. A total past a double's precision is rounded there; local
  mode works on the fetched rows in the browser, and the exact totals are server-side mode's.
- **From the review of M7** (M7.14): a level's type must have Equal as well as group (`isDrilldownLevelType`), so
  StrictTime, whose groups Equal could never find again, isn't one; every group level's sort ends with the level's own
  column, so a sort that leaves ties (a constant outer key, equal aggregations) still cuts a level the same way; each
  server-side grid takes a token as it renders, and an answer for an earlier grid says nothing on the one shown now;
  widths set since an edit survive its undo (not across an import); a format with no places keeps the value's own
  digits (a currency at least its cents), and a negative that rounds to zero shows as zero; local mode's count totals
  and the drill-down's count aggregations aren't formatted; the groups column exports and copies its key; a local
  group row offers no Filter by; the shared format has its own Remove.
- **Late answers are dropped:** a fetch started before an Execute, a mode switch, a new cube or the grid's destruction
  is never applied, by the run id `execute()` already keeps, the fetcher's generation number and a token per grid.
- **Errors of a fetch** show in the grid region, as a run's, and fail only that level (`params.fail()`); they don't
  mark a node, since the user's nodes ran.
- **Stats (spec §12.6):** every run, fetch and export records `{label, time, durationMs, sql}` (§11.16: and its
  `lambda` and `error`, a failed run too, and the Pivot editor's value load), the 15 latest kept,
  shown in a Runs panel under the grid's toolbar (M7.10: a panel rather than the popover first planned, as Show SQL
  is) with a copy button per SQL. No trace link: Cube has no trace id to show.
- **The context menu (spec §12.4)**, curated: on a cell, 'Drilldown by', 'Sort by', 'Group by', 'Filter by' (the last
  three as M2 and M4 built them, in both modes); then 'Format Column' (number columns); 'Autosize Columns', 'Reset
  Columns'; 'Copy', 'Copy With Headers'; 'Export ▸ CSV (whole query)', 'Excel (rows shown)'. Cut and paste, and
  ag-grid's own client CSV export of the fetched rows, are gone.
- **Column formatting (spec §13.1) with the §21 fixes:** a column format `{type: number | currency | percent,
decimalPlaces?, currency? (an ISO 4217 code), grouping, redIfNegative, parenthesesIfNegative}` saved as
  `meta.presentation.columnHints` (by column name, or for every column of a type when 'apply to all'); red-if-negative
  wired to its own flag; ISO codes from `Intl.supportedValuesOf('currency')` in place of four symbols, so none falls
  back silently. Formatting reads the exact text: `Intl.NumberFormat` formats a decimal string with every digit
  (`$12,345,678,901,234,567,890.13` 💭, Node 20 and current browsers). Only number columns are formatted; a group
  row's `Count Rows` never is. Today's defaults (§7.8, 2026-10-10) apply where a column has no format.
- **Column widths** are read from and written to `meta.presentation.columnWidths` (decoded since M1, never applied).
  A width change is presentation, not a query edit: no undo step, and it doesn't make the rows stale.
- **CSV through the engine:** a new `CubeEngine.exportCsv(model, lambda, {abortController})`, the same execution
  request with `serializationFormat=CSV`, saved by the browser as `<cube name>.csv`. The engine writes RFC 4180 CSV
  with CRLF line ends, quotes and doubled quotes, an empty field for an empty value, a header even with no rows, every
  digit (`9007199254740993`, `2.5000`), timestamps as `2024-01-02 03:04:05.678` ✅; it answers `application/json` as
  its content type, so Cube sets `text/csv` itself, and a failure is a JSON error with status 500 ✅
  (`p2-csv-decimal.out`). Studio's client asks for the engine's `CSV_TRANSFORMED`, which writes timestamps as the grid
  shows them (`2024-01-02T03:04:05.678000000+0000`) ✅ (`p6-csv-formats.out`). An "Exporting…" note shows while it
  runs; Stop aborts it. The engine test client now sends the format the client asks for (it always sent `DEFAULT`).

**Engine facts** (probes under `m7-requirements/`):

| Fact                                                                                                                                          |     | Probe                |
| --------------------------------------------------------------------------------------------------------------------------------------------- | --- | -------------------- |
| A level: `groupBy` on the next key with a Sum and a count, sorted, limited; one and two keys; by a Decimal, a Boolean, a date and a timestamp | ✅  | `p1-drill.out` A–K   |
| An empty key is a group of its own (507 orders with no region); `isEmpty()` on the key finds its rows                                         | ✅  | `p1-drill.out` B, D  |
| A level below: one `filter` per expanded key, then the next `groupBy`; the leaf: the filters, a sort, the limit                               | ✅  | `p1-drill.out` C, E  |
| A Sum of only empty values is empty, though typed `[1]`                                                                                       | ✅  | `p1-drill.out` G     |
| An aggregation named after its column types, runs, and sorts by the aggregate                                                                 | ✅  | `p4-names.out` A, B  |
| A decimal literal as a JSON number loses digits past a double's; as a string it keeps them                                                    | ✅  | `p2-csv-decimal.out` |
| CSV: header, CRLF, quoting, empty values, every digit, an empty result, a failure                                                             | ✅  | `p2-csv-decimal.out` |
| Float, date and timestamp keys, as returned, found again by `Equal`                                                                           | ✅  | `p3-float-keys.out`  |

**Steps** (effort: high for the server-side work, medium for the rest, as agreed on 2026-10-10):

| Step  | Effort | Deliverable                                                                                                                                                                                     | Done when                                                                                      |
| ----- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| M7.1  | –      | This subsection and PROGRESS-M7.md (docs only)                                                                                                                                                  | Committed and pushed; a draft PR into `cube-dev`                                               |
| M7.2  | medium | Decimal literals sent as strings (ISSUES.md), with an engine test that a 20-digit decimal filter keeps its digits                                                                               | The engine test fails before the fix and passes after                                          |
| M7.3  | high   | The drill-down in the core: `Drilldown {columns, aggregations}` in `CubeMeta`, the codec's `meta.drilldown`, validation against the capture node's schema, the effective aggregations, messages | Codec round trips (rest kept); every message tested                                            |
| M7.4  | high   | Query derivation in the core: a level's query from the run's query, the expanded typed keys, the drill-down and the grid's sort; the leaf; sort remapping                                       | Unit tests on every shape; engine tests compare each level with a reference over the leaf rows |
| M7.5  | high   | The builder's row fetcher: one run path shared with Execute (outline, database type, abort, run id), the LRU cache, stats entries                                                               | Store tests: cache hits and eviction, late answers dropped, errors per level                   |
| M7.6  | high   | The grid: ag-grid's server-side row model on the fetcher, typed group rows, Execute filling the top level, header sorts re-deriving, per-level truncation; the mode toggle and local mode       | Grid tests on the datasource; the browser check of both modes                                  |
| M7.7  | high   | The drill-down in the UI: 'Drilldown by', the levels bar, the aggregation dialog, quick actions on group rows                                                                                   | Component tests; the browser check                                                             |
| M7.8  | medium | Export: `exportCsv` on the port and the engine, the download, XLSX of the loaded rows, the note, stats entries                                                                                  | Engine test of the CSV; component tests                                                        |
| M7.9  | medium | The curated context menu                                                                                                                                                                        | Menu tests per mode and cell kind                                                              |
| M7.10 | medium | The runs panel                                                                                                                                                                                  | Component tests                                                                                |
| M7.11 | medium | Column formatting and widths: `columnHints` in the core and codec, the Format Column dialog, rendering from exact text, the §21 fixes                                                           | Codec, formatter and dialog tests                                                              |
| M7.12 | medium | Around the databases: the derived shapes planned on every database type (plan-only test), the drill-down's conformance cases                                                                    | Plan-only and conformance tests pass                                                           |
| M7.13 | medium | Guides (hosting, testing.md), README lists, ISSUES drafts, the patch changeset                                                                                                                  | `yarn check:ci` passes                                                                         |
| M7.14 | high   | Verification (reviewers and a skeptic per finding) and a browser rehearsal                                                                                                                      | Every finding fixed or recorded; the rehearsal passes                                          |
| M7.15 | medium | A demo video of M7 (§11.3), key frames checked against their captions                                                                                                                           | Every caption true on screen; sent                                                             |
| M7.16 | –      | Fold the supersessions below; the PR ready for `cube-dev` on the user's word                                                                                                                    | The plan consistent; the PR ready                                                              |

**Risks and open gaps:**

- ag-grid's server-side row model can't be checked well in jsdom; the derivation and the fetcher carry the logic and
  the tests, and the browser checks (M7.6, M7.7, M7.14) cover the wiring.
- A level of many groups is cut at the row limit, like a flat run; a user who needs them all raises the limit or adds
  a Filter. Paging stays out (settled 2).
- XLSX holds only the loaded rows; number columns go as numbers, but their formats are not carried into it
  (ag-grid's Excel number formats are a follow-up).
- Pivot in server-side mode stays out, as in the spec (§12.3: asserted, not degraded); local mode offers ag-grid's.

**Supersessions** (folded in at M7.16, 2026-10-11; kept here as the record of what M7 changed):

- §7.8 (grid display): formats and widths applied. §9: the slice's client-side model becomes local mode; the M2 and
  M4 menus become the curated menu; "Later (M7)" moves here. §10.3: `meta.drilldown` and
  `meta.presentation.columnHints`. §11.3: the M7 row.
- Appendix A: §12.1 (server-side default kept, local mode kept), §12.2 (typed keys, named aggregations, Sum by
  default, no `CUBE AUTO COUNT`), §12.3 (bounded cache, whole levels), §12.4 (the curated menu), §12.5 (CSV through
  the engine, XLSX of loaded rows), §12.6 (no trace link), §13.1 (ISO codes, the red-if-negative fix, exact text).
- As built (M7.2–M7.15, folded in M7.16 with the above): §7.8's state (`gridFetcher`, `grid`, `export`,
  `runStats`); the Runs panel and the 'Group rows…' dialog in place of popovers; the review's fixes (StrictTime, the
  level's tie-break sort, the grid token); the groups column pinned on the left.

### 11.11 M6b: Filter by expression

M6b adds **Filter by Expression**: a node that keeps the rows for which a Pure condition, written as in Query's text
mode, is true. It reaches what the column Filter (§4.8) can't: two columns compared, functions, conditions on a
window's or an Extend's column. It reuses M6's Extend machinery (§11.7). It is built on the branch `cube-filter-expr`,
from `cube-dev` (`be232b8e2`), and lands in a PR into `cube-dev`. Its status is in [PROGRESS-M6B.md](PROGRESS-M6B.md).
Requirements: the probes under `filter-expr-requirements/` in the local evidence folder, run on the local engine: ✅
where a probe ran, 💭 where only a plan was made or it is reasoned.

This subsection overrides the sections it names until they are updated (see "Supersessions" at its end).

**Settled at the start of M6b** (user, 2026-10-11, all on the recommendation):

1. **Its own node**, `Filter by Expression`, after `Filter by Column` in the palette, with its own editor. The column
   Filter's saved shape and its offline validation stay as they are.
2. **One expression** per node; several conditions are combined in it with `&&` and `||`.
3. **A full lambda**, `x | $x.SHIPPED_DATE > $x.REQUIRED_DATE`, as in Extend and Query's text mode; any parameter
   name works ✅.

**Decided without asking** (each has a precedent or a probe; for review):

- **The node:** `ExpressionFilter`, saved as `expressionFilter`, holding the text as typed and the engine's JSON for
  it, as an Extend column does (`{code, lambda}`, §11.7), and a check. Its output is its input: the same columns,
  rows dropped, order kept. Its help: "Keeps the rows for which the expression is true." `describeRedacted` leaves the
  code out; the canvas shows the code.
- **Checked by the engine,** over the cube's model, as an Extend is typed: the input's relation as emitted, then
  `->filter(<lambda>)`. A condition that isn't one Boolean fails there with "Can't find a match for function
  'filter(…)'" and the whole relation type ✅, so the same call also types the condition alone, as an extend column
  under a generated name no input column has, which gives its type ✅ (`Integer[1]`, `String[1]`, `Boolean[2]`,
  `Boolean[0..1]`) for Cube's message: `Condition must give one Boolean, such as x | $x.PRICE > 10.` An error the
  engine locates in the text is underlined there ✅ (an unknown column, a parse error).
- **The check's state** is its own, not an Extend's typing: nothing yet; checked, with its signature (a digest of the
  input schema and the lambda) and what the engine was given for the input (`upstream`, §11.7); or failed, with the
  engine's message. Until the condition is checked for this input, the node waits (`ERR_TYPING`) and the nodes below
  it wait too, as below an Extend: no "columns known, node invalid" state, since Execute and the canvas read validity
  and schema together. Saved: `checked: {signature}` once checked, so a loaded cube is valid at once; it is checked
  again in the background, as a loaded Extend is typed again.
- **The builder generalises Extend's machinery, not a copy of it** (M6.11's review fixed 14 problems there): the
  background typing reaction and its in-flight set, the stale-input rule, the digest check in
  `replaceOutsideHistory`, the warning that keeps a current check when the engine can't be reached, checking again on
  import, F10, `applyDisabledReason` and the kept-open editor, and the pending label, worded for both ("Checking
  expressions…"). Each of these works for any engine-checked node: an Extend or a Filter by Expression.
- **Validate (F10)** parses the text, checks Cube's rules (a lambda of one parameter; no backslash in a LIKE function's
  literal, below) and the database's (on DuckDB, no `%` or `_` there), checks the condition with the engine, then
  plans the whole query up to the node for the cube's database, as Extend's Validate does: `year()` plans on 18 of
  20 database types (Hive and Composite refuse) ✅.
- **Parentheses:** Pure's `<`, `>`, `<=` and `>=` bind no tighter than `&&`, `||` and `!` ✅, so
  `$x.A > 1 && $x.B > 3` reads `(($x.A > 1) && $x.B) > 3` and `!$x.A > 1` reads `not($x.A) > 1`; `==` binds tighter
  ✅. When the engine refuses `and`, `or` or `not` of something that isn't a Boolean, the editor adds:
  `Put each comparison in parentheses: ($x.A > 1) && ($x.B > 3).` Never for `==`.
- **Empty values:** compared directly, a column that can be empty keeps Pure's meaning: `$x.A > 6` drops the rows
  where it is empty, and `!($x.A > 6)` keeps them (`is not null and …`, `is distinct from`) ✅ on H2 and DuckDB. After
  `->toOne()`, neither the condition nor its negation keeps them (`not A > 6`) ✅. A comparison types without
  `->toOne()` ✅, unlike an Extend's arithmetic. The editor notes both; the engine tests pin both.
- **Backslashes:** the engine escapes `%` and `_` in `startsWith`, `endsWith` and `contains` with `\` and writes no
  `ESCAPE` clause (Appendix B). On H2, where `\` is the escape character, a backslash in the pattern gives wrong rows
  ✅ (`contains('\\')` finds none; `endsWith('\\b')` finds 4 of 6). So, as the column Filter does (§8.2), a string
  literal with a backslash given to one of those functions is refused:
  `The value given to "contains" cannot contain a backslash (\) yet.` On DuckDB, where `\` is no escape character,
  `%` and `_` match wrongly instead ✅ (`contains('%')` finds the backslash's row), for the column Filter too
  (ISSUES.md). After M6b.9, on the user's choice, both filters refuse them for a DuckDB cube (§8.2):
  `On DuckDB, the value given to "contains" cannot contain % or _ yet: …`.
- **After a window:** a Partition is bound with a `let` and read by name (§8.6), so a condition on a window column
  runs after the window: the first row per country on H2 and DuckDB ✅, and the filter after the window on the 17
  window types' plans ✅. On an Extend's column too ✅.
- **Registration:** the palette, the editor and draft registries, help text, an icon; `findColumnOrigins` passes a
  column through; the samples in `operations.cube.json`, the spec corpus and a conformance case.

**Engine facts** (probes under `filter-expr-requirements/`):

| Fact                                                                                                                    |     | Probe                    |
| ----------------------------------------------------------------------------------------------------------------------- | --- | ------------------------ |
| `filter` types a condition of one row over the input, the columns unchanged; a nullable comparison needs no `->toOne()` | ✅  | `p1-filter.out` A        |
| A condition that isn't one Boolean fails with the whole relation type and no location; typed alone, it gives its type   | ✅  | `p1-filter.out` A, A2    |
| An unknown column and a parse error are located in the text                                                             | ✅  | `p1-filter.out` A18, A19 |
| `<`, `>`, `<=`, `>=` bind no tighter than `&&`, `\|\|` and `!`; `==` binds tighter                                      | ✅  | `p2-precedence.out`      |
| Compared directly, an empty value fails the condition and passes its negation; after `->toOne()` it fails both          | ✅  | `p1-filter.out` B, `p3`  |
| A backslash in a LIKE function's literal gives wrong rows on H2; `%` and `_` do on DuckDB                               | ✅  | `p3-values.out` F6–F10   |
| A condition on a window column after the `let` runs after the window (H2, DuckDB) and plans so on the 17 window types   | ✅  | `p3-values.out` W1–W3    |
| Most conditions plan on all 20 database types; `year()` on 18 (not Hive or Composite)                                   | ✅  | `p1-filter.out` C        |

**Steps:**

| Step  | Deliverable                                                                                                            | Done when                                                         |
| ----- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| M6b.1 | This subsection, PROGRESS-M6B.md and ISSUES.md's DuckDB entry (docs only)                                              | Committed and pushed; a draft PR into `cube-dev`                  |
| M6b.2 | The core: the node, its check, validation and messages, the emitter, the codec, the samples                            | Every message tested; codec round trips; `printIR` shows `filter` |
| M6b.3 | The builder's engine-checked nodes: Extend's typing machinery generalised, the condition's check and its message       | Extend's tests still pass; the new checks tested                  |
| M6b.4 | The editor, registered: the code editor, Validate (F10), the input's columns, the hints and notes                      | Editor and draft tests pass                                       |
| M6b.5 | On the engine: values on H2 and DuckDB (empty values, after a window, after an Extend), plans, conformance, the corpus | Engine, plan-only and conformance tests pass                      |
| M6b.6 | Guides, READMEs, testing.md, the patch changeset                                                                       | `yarn check:ci` passes                                            |
| M6b.7 | Verification (reviewers and a skeptic per finding) and a browser rehearsal                                             | Every finding fixed or recorded; the rehearsal passes             |
| M6b.8 | A demo video (§11.3), key frames checked against their captions                                                        | Each caption true on screen; sent                                 |
| M6b.9 | Fold the supersessions below; the PR ready for `cube-dev` on the user's word                                           | The plan consistent; the PR ready                                 |

**Risks and open gaps:**

- An expression is arbitrary Pure on the user's model, as an Extend's is (§11.7): a function that types may still fail
  on the database, which only the plan check (on Validate) or a run shows.
- The backslash rule reads literals only: a pattern built at run time, such as `'a' + $x.B`, isn't checked. _(M6d,
  §11.14: a pattern that isn't a literal is refused on its own, unless the cube runs on Snowflake, and Extend gets
  these rules too.)_
- DuckDB's `%` and `_` in LIKE functions (ISSUES.md) are refused for DuckDB cubes (§8.2) until the engine writes an
  `ESCAPE` clause. The refusal needs the model's outline to name the database: a cube whose outline doesn't load, or
  whose databases' connections differ in type, isn't refused. It reads literals only.

**Supersessions** (applied in M6b.9 to the sections they change; kept here as the record of what M6b changed):

- §4 (a node type), §8.8 (the Filter by Expression row), §10.3 (`expressionFilter`'s shape), §11.3 (the M6b row).
- Appendix A: a node beyond the spec (beside §7.x), §16 (the new messages), §17.6 (the editor).

---

### 11.12 M6c: Pivot

M6c adds **Pivot**: a node that turns the values of a column into columns, through the engine's `pivot()`. Each
picked value becomes a column holding one aggregate, and each combination of the row columns that has a picked value
becomes a row. The spec
has pivot only as ag-Grid's local mode (§12.1), which pivots the fetched rows in the browser; local mode keeps it. M6c
is built on the branch `cube-pivot`, from `cube-dev` (`c1b40a480`), and lands in a PR into `cube-dev`. Its status is
in [PROGRESS-M6C.md](PROGRESS-M6C.md). Requirements: the probes under `pivot-requirements/` in the local evidence
folder, run on the local engine: ✅ where a probe ran, 💭 where only a plan was made or it is reasoned. Nothing runs
on Snowflake, Databricks or SingleStore locally: for those, ✅ means planned.

This subsection overrides the sections it names until they are updated (see "Supersessions" at its end).

**Settled at the start of M6c** (user, 2026-10-12):

1. **The engine's `pivot()`**, disabled wherever the database is known not to run it, such as H2 connections
   (user's direction), in its **static form** (on the recommendation): one pivot column, its values listed, one
   aggregation. Cube knows the columns without a run, and the form plans on Snowflake, Databricks and SingleStore.
   The dynamic form (columns from the data) plans on Snowflake and DuckDB only, and needs a run to learn its columns.
2. **Values loaded and picked:** Cube asks the engine for the column's distinct values and the user ticks the ones
   to pivot, up to a cap. A value the data gains later shows only once picked.
3. **Columns named by the value** (`2000`, `2011`), renamed after the pivot; a name a row column takes gets a suffix.
4. **Disabled in the palette and on the node:** where Cube knows the database and it can't pivot, `Pivot by Column`
   is greyed with the reason, in the palette and the menus, and a Pivot already there is refused with a message.

**Decided without asking** (each has a precedent or a probe; for review):

- **The node:** `Pivot`, saved as `pivot`, `Pivot by Column` after `Group by Column` in the palette, one `tds` input:
  `{rows, column, values, aggregation}`. `rows` are the row columns in input order, as Group's keys (none gives one
  row ✅); `column` the pivot column; `values` typed literals, in the engine's order; `aggregation` one of Group's
  (`{function, column}`, §11.5), with no name. Help: "Turns the values of a column of the previous data set into
  columns: a row for each combination of the row columns that has a picked value, and a column for each picked value,
  holding its aggregate." The tests' example of
  a kind Cube doesn't know, `pivot`, becomes `future`.
- **Pivot columns:** a String, an integer (Integer, SmallInt, TinyInt, BigInt), a StrictDate or a Boolean ✅. Other
  types are refused (`Pivot column "<c>" of type <T> cannot be pivoted.`): a float matches its values inexactly, as
  M7 found for `REAL`; DateTime, Decimal and enumerations aren't probed yet (a Decimal's refusal is pinned, M6c.7's
  review).
- **Values:** one to 100, each of the column's type, none twice, and no two differing only in case (`foldColumnName`):
  DuckDB gives both the first one's numbers ✅ (`p6`, found in M6c.7). A value whose text can't name a column (empty, or
  holding `"`, `\` or a control character, or longer than 128 code points; `isValidColumnName`, §7.5), or holding a
  backtick (`isValidColumnName` from M6d, §11.14), is refused: the engine drops `"` from the names it generates ✅, and writes the
  pivot's own name `'<v>__|__cube_pivot'` unescaped, so a backtick ends it early where names are quoted with
  backticks, on Databricks and MemSQL ✅ (plans, M6c.7's review; Appendix B). The editor loads them as
  `<input>->select(~c)->filter(c is not empty)->distinct()->sort(~c->ascending())->limit(101)` on the cube's runtime:
  an empty value can't be listed in Pure, and would take the row that tells whether there are more. A load the user
  leaves by picking another pivot column is dropped. The editor greys a value it can't tick, saying why: one that
  can't name a column, one differing only in case from one ticked, or any once 100 are ticked; All skips them.
- **The aggregation:** Sum, Count, Count rows, Distinct Count, Average, Min or Max ✅, on a column that is neither the
  pivot column nor a row column, offered per family as in a Group (§10). Distinct Value is left out: DuckDB refuses
  its pivot ("Pivot expression must contain exactly one aggregate") ✅.
- **Output schema:** the row columns, as in the input, then one column per value, in the values' order, named by
  the value's text (`2024-01-31`, `true`); a name a row column or an earlier value already has, compared folded,
  takes `_2`, `_3`, … (§7.11's rule). Each value column is typed as the aggregation is in a Group (§5.7): Count,
  Distinct Count and Count rows are Integer and never empty, 0 where a combination has rows but none with that value
  ✅; the others can be empty, and are there ✅. The static pivot first keeps only the rows with a listed value
  (`where c in (…)`, `processStaticPivot` in the engine, on every dialect), so a combination whose rows hold only
  values not listed has no row ✅ (pinned on DuckDB, M6c.7's review). No row order, as a Group.
- **Emission:** a `select` of the row, pivot and aggregated columns; then
  `pivot(~column, [values…], ~cube_pivot: x|… : y|…)`; then a `cast` naming each column,
  `(rows…, '<v>__|__cube_pivot': T, …)`; then a `rename` of each value column to its name ✅ on DuckDB. The `select`
  is needed: every column the pivot doesn't consume becomes a row column ✅. The cast must name exactly the listed
  values: a value it names that isn't listed types but doesn't run ✅. Later steps (Filter, Sort, Extend, Rename) use
  the renamed columns ✅. The cast's column types: the precise types Cube infers (`Varchar(10)`), each never empty
  where Cube infers so (`[1]`) ✅ (`p4`, `p5`).
- **Which databases:** the static pivot plans, with the outer select reading the names the `PIVOT` gives, on
  **Snowflake, Databricks, SingleStore (`MemSQL`) and DuckDB** ✅, and runs on DuckDB ✅. It is refused on H2, which
  can't translate it ✅, and on the 13 types that assert "pivot is not supported" ✅, and on Aurora, whose SQL the
  engine writes as Postgres's, asserting the same (read from its source: the open-source engine loads no Aurora
  extension to plan with, M6c.7's review). Trino and Athena plan it with no `PIVOT` at all, reading columns nothing
  produces ✅ (ISSUES.md): refused too. 17 types in all. A missing type (no outline, or
  databases of different types) allows it; the engine's plan or run says the rest. Databricks needs an engine with
  legend-engine#5142 (2026-09-10).
- **Which database a cube runs on:** data product and ingest cubes run on a lakehouse runtime Cube writes, and today
  report none (§11.4). One table maps the runtime's kind to the engine's type: `LakehouseRuntime` → `Snowflake` (every
  lakehouse runtime routes there for now, user 2026-10-11), `LakehouseSingleStoreRuntime` → `MemSQL`; a Databricks
  runtime will be another subtype of the base lakehouse runtime. Nothing else changes for them: Snowflake has no
  workaround (§11.4).
- **One predicate** in core says whether a node's run depends on the database type: a Drop, Slice, Limit or
  Distinct, a filter giving a pattern `%` or `_` (§8.2), a Pivot. `needsDatabaseType` (which makes a run, an export
  or Show Pure wait for the outline) and the editor's database rule read it, rather than a list per feature. The
  outline also loads whenever the cube reads a table (`outlineLoading`), so the type is known before any run.
- **The guard:** core's `getDatabaseProblems` refuses a Pivot on a type that can't run it, saying
  `Pivot is not supported on H2.`, which the editor's rule shows on the node and the emitter throws on, as for
  DuckDB's `%` and `_` (§8.2). The palette and the Add menus (the canvas's context menu and the header's Add Items)
  grey `Pivot by Column` with the same reason
  when the selected node's database is known and refuses it; the model's outline is loaded when a cube with a table
  opens, so the reason is there before any run.
- **The grid:** server-side mode runs a Pivot as any node, and drills down by its row columns; local mode's ag-Grid
  pivot stays, for quick looks at the fetched rows.

**Engine facts** (probes under `pivot-requirements/`):

| Fact                                                                                                                  |     | Probe                                 |
| --------------------------------------------------------------------------------------------------------------------- | --- | ------------------------------------- |
| `pivot()` types as `Relation<Any>` with no columns; after a `cast`, the cast's columns                                | ✅  | `p1`, `p2`                            |
| H2 runs neither form ("Dialect translation for node of type PivotedRelation not implemented")                         | ✅  | `p1-pivot.out`                        |
| The static form takes one pivot column and one aggregation; two of either doesn't compile                             | ✅  | `p2-native.out` A                     |
| Every column not pivoted or aggregated becomes a row column                                                           | ✅  | `p2`, `p3` B                          |
| Static + cast + rename plans on Snowflake, Databricks, MemSQL and DuckDB, the outer select reading the PIVOT's names  | ✅  | `p3-static.out` A                     |
| Trino and Athena plan it with no PIVOT; the 13 others assert "pivot is not supported"                                 | ✅  | `p3-static.out` A                     |
| Sum, Count, Count rows, Distinct Count, Average, Min, Max run; Count and Distinct Count give 0 where no rows are      | ✅  | `p3-static.out` B                     |
| Distinct Value's pivot fails on DuckDB                                                                                | ✅  | `p3-static.out` B                     |
| Strings with a space, `'`, `\|`, `\`, dates and booleans make names; `"` is dropped from them                         | ✅  | `p3-static.out` C                     |
| A cast naming an unlisted value types but returns fewer columns; one missing a value the data has drops it silently   | ✅  | `p2-native.out` C                     |
| A cast can give Cube's precise types, and `[1]` for a column never empty                                              | ✅  | `p4`, `p5`                            |
| Two values differing only in case (`'a'`, `'A'`): DuckDB gives both columns the first one's numbers                   | ✅  | `p6-case.out`                         |
| The static pivot first keeps only the rows with a listed value (`where c in (…)`): a combination with none has no row | ✅  | M6c.7 review, `CubePivot` engine test |
| Databricks and MemSQL quote names with backticks, and the engine escapes none in the pivot's own column name          | ✅  | M6c.7 review, plans                   |
| Aurora's SQL is Postgres's, which asserts "pivot is not supported" (read from source: no Aurora extension locally)    | 💭  | M6c.7 review                          |

**Steps:**

| Step  | Deliverable                                                                                                          | Done when                                                        |
| ----- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| M6c.1 | This subsection, PROGRESS-M6C.md, ISSUES.md's Trino and Athena entry (docs only)                                     | Committed and pushed; a draft PR into `cube-dev`                 |
| M6c.2 | The core: `pivot` freed in the tests; the node, its schema, validation and messages, the emitter, the codec, samples | Every message tested; codec round trips; `printIR` shows `pivot` |
| M6c.3 | Databases: the one predicate, the guard and its message, the lakehouse runtime table, the outline loaded on open     | The guard tested per type; data product cubes report Snowflake   |
| M6c.4 | The editor, registered: rows, the pivot column, values loaded and picked, the aggregation; the palette greyed        | Editor and draft tests pass                                      |
| M6c.5 | On the engine: DuckDB direct connections, plans on the 20 types with a canary for the refusals, conformance, corpus  | Engine, plan-only and conformance tests pass                     |
| M6c.6 | Guides, READMEs, testing.md, the patch changeset                                                                     | `yarn check:ci` passes                                           |
| M6c.7 | Verification and a browser rehearsal on a DuckDB direct connection                                                   | Every finding fixed or recorded; the rehearsal passes            |
| M6c.8 | A demo video (§11.3) on DuckDB, with H2's refusal shown                                                              | Each caption true on screen; sent                                |
| M6c.9 | Fold the supersessions below; the PR ready for `cube-dev` on the user's word                                         | The plan consistent; the PR ready                                |

**Risks and open gaps:**

- Snowflake, Databricks and SingleStore are checked by plans only: data products can't run on the open-source engine
  (ISSUES.md). A cube on them is first run in GS.
- One pivot column and one aggregation; several would need the dynamic form, its discovery run, and Snowflake.
- A value the data gains after it was picked is left out, with no warning.
- `LakehouseRuntime` → Snowflake holds while lakehouse runtimes route there; the table is the one place to change.

**Supersessions** (applied in M6c.9 to the sections they change; kept here as the record of what M6c changed):

- §4.5 (the node), §8.8 (the Pivot row; the database type, from a lakehouse runtime's kind too, and the outline
  loaded on open, stated there rather than in M2's §11.4; Pivot's databases), §9 (a Pivot node beside local mode's
  pivot, the spec's §12.1), §10.3 (`pivot`'s shape), §12.1 (a risk).
- Appendix A: a node beyond the spec (beside §7.2), §16 (the new messages), §17.6 (the editor); Appendix B: Trino and
  Athena planning `pivot()` without a PIVOT.

### 11.14 M6d: Operations cleanup

M6d gathers what was left in the built operations (asked 2026-10-13, after M6c): the small bugs ISSUES.md listed, a
name rule found in M6c's review, aggregations the engine supports that Cube didn't offer, and Filter by Expression's
gaps. It is built on the branch `cube-ops-cleanup`, first stacked on `cube-pivot` (#5674), then rebased onto
`cube-dev` once #5674 was in, and lands in a PR into `cube-dev`. Its status is in [PROGRESS-M6D.md](PROGRESS-M6D.md). Requirements: the probes under
`cleanup-requirements/` in the local evidence folder (`c1`–`c4`), run on the local engine: ✅ where a probe ran, 💭
where only a plan was made or it is reasoned. (M8's section is §11.13, written on another computer.)

This subsection overrides the sections it names until they are updated (see "Supersessions" at its end).

**Settled at the start of M6d** (user, 2026-10-13):

1. **The scope**, on the recommendation: the row limit's text, a Join's shared columns in any case, the grid's
   Filter by on a 32-bit float, no backtick in the names Cube gives columns, Min and Max on strings and Booleans,
   Distinct Count and Distinct Value on enumerations, and Filter by Expression's two gaps.
2. **The grid's Filter by on a float** writes `In [the value shown, its 32-bit value]` when the two differ, as M7's
   drill-down keys do (`buildDrilldownKeyRule`), so a `REAL` value is found ✅ (M7's probe).
3. **A `startsWith`, `endsWith` or `contains` given a pattern that isn't a literal** (a column, or texts joined) is
   refused, in Filter by Expression and in Extend, unless the cube's database is known to be Snowflake: the engine
   writes it right only there ✅ (`c2`, plans), and wrong rows or a failure on the other 19 types ✅ (`c1`, `c2`).

**Decided without asking** (each has a precedent or a probe; for review):

- **Names:** `isValidColumnName` refuses a backtick, so no name Cube gives a column (a Rename's, an Extend's, a
  Group's and a Partition's outputs, a Difference's, the Join autofix's, a drill-down's, a Pivot's value columns)
  holds one: Databricks and MemSQL quote names with backticks and the engine escapes none ✅ (M6c.7's review,
  Appendix B). `isPivotValueText` becomes `isValidColumnName`. A source column holding one is read as it is.
- **A Join's shared columns in any case:** `getDuplicateJoinColumns` compares names folded (`foldColumnName`), as
  Rename, the autofix and Cube's temporary columns do (M2.16), so a left `ID` and a right `id` are reported; the
  autofix renames each side's own name (`ID_1`, `id_2`). A key pair whose names differ only in case is two columns,
  so a duplicate too. Difference shares the rule.
- **Aggregations** (`getAvailableAggregations`, so Group, Partition, Pivot and the drill-down offer them alike):
  - Min and Max on a String: typed as the column (`Varchar(10)`), may be empty ✅ (H2, DuckDB, `c3`); they follow
    the database's collation (`Zed` before `fig` on both) ✅; planned on the 20 types, as windows on the 17 ✅.
  - Min and Max on a Boolean: typed Boolean, may be empty ✅ (H2, DuckDB). The engine writes `max(b)` over a
    Boolean column on SQL Server, Sybase and Postgres, which reject it 💭 (vendor documentation), as for Distinct
    Value of a Boolean, which Cube already offers (ISSUES.md's draft widened to Min and Max).
  - Distinct Count and Distinct Value on an enumeration: typed by the engine (M4's `n2-enum`), not run (no source
    Cube reads gives an enumeration column yet) 💭.
- **The row limit** is read with `parseWholeNumberText`, as the operations' size fields are: `0x10`, `1e3`, `0b11`
  and `1.0` are refused.
- **Filter by Expression's refusals shown once:** a refusal Cube makes before the engine (a backslash, `%` or `_` on
  DuckDB, a non-literal pattern) shows once, in the panel's problems, not also under the code.
- **Extend gets Filter by Expression's literal rules too:** no backslash in a `startsWith`, `endsWith` or `contains`
  literal, and no `%` or `_` on DuckDB (§11.11), since an Extend's Boolean column is written the same way.

**Engine facts** (probes under `cleanup-requirements/`):

| Fact                                                                                                                            |     | Probe               |
| ------------------------------------------------------------------------------------------------------------------------------- | --- | ------------------- |
| A column pattern in `contains`: DuckDB writes the column's SQL as the pattern's text and finds nothing; H2 matches its `%`, `_` | ✅  | `c1-patterns.out`   |
| On the other 17 types the column's SQL is written as the pattern's text too; Snowflake writes `contains(a, b)` right            | ✅  | `c2-plan-patterns`  |
| `startsWith`/`endsWith` with a column pattern fail on H2; texts joined with `+` fail on H2 and DuckDB                           | ✅  | `c1`, `c2`          |
| Min and Max on a String and a Boolean, in a groupBy, an aggregate, a window and a static pivot, run on H2 and DuckDB            | ✅  | `c3-aggregates.out` |
| A Boolean column's Min and Max are `max(b)` on SQL Server, Sybase and Postgres; MemSQL and Oracle compare `'true'`/`'false'`    | ✅  | `c4-boolean-column` |

**Steps:**

| Step  | Deliverable                                                                                                          | Done when                                             |
| ----- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| M6d.1 | This subsection, PROGRESS-M6D.md, ISSUES.md (docs only)                                                              | Committed and pushed; a draft PR into `cube-dev`      |
| M6d.2 | Core: names, a Join's shared columns in any case, the aggregations, the pattern rules (Filter by Expression, Extend) | Every rule and message tested                         |
| M6d.3 | Builder: the row limit, the grid's Filter by on a float, refusals shown once, the editors' function lists            | Editor, draft and grid tests pass                     |
| M6d.4 | On the engine: the aggregations, the pattern refusal's canary, the float filter, the Join in any case on DuckDB      | Engine, plan-only and conformance tests pass          |
| M6d.5 | Guides, READMEs, testing.md, the patch changeset                                                                     | `yarn check:ci` passes                                |
| M6d.6 | Verification and a browser rehearsal                                                                                 | Every finding fixed or recorded; the rehearsal passes |
| M6d.7 | A demo video (§11.3)                                                                                                 | Each caption true on screen; sent                     |
| M6d.8 | Fold the supersessions below; the PR ready for `cube-dev` on the user's word                                         | The plan consistent; the PR ready                     |

**Risks and open gaps:**

- Refusing a non-literal pattern unless on Snowflake blocks a column pattern on Databricks and SingleStore until the
  engine writes it right there (an engine issue drafted in ISSUES.md).
- A Boolean's Min and Max fail on SQL Server, Sybase and Postgres, as Distinct Value does; not refused yet.

**Supersessions** (applied in M6d.8 to the sections they change; kept here as the record of what M6d changed):

- §4.2 (the column-name rule), §4.7 (the Join's duplicate rule and autofix), §4.8 (the grid's Filter by rule), §5.7
  and §11.5's aggregation table (availability), §8.8 (the patterns paragraph), §9 (the grid's Filter by), §11.11 (its
  literal-only gap closed; Extend's rules are in §8.8's paragraph rather than §11.7, M6's history).
- Appendix A: §10.1 (availability), §16 (the new message, the backtick); Appendix B: column patterns written as text.

### 11.15 UX pass from the demo review

Built on `cube-ux-pass`, from `cube-dev` (`603598e37`), in a PR into `cube-dev` (finos/legend-studio#5684). The user
reviewed the full walkthrough's frames on 2026-10-13 and picked items 1–6, 10, 11 and 12 of the review, with three
new features; the whole list is the "UX backlog" in [ISSUES.md](ISSUES.md).

**Settled** (user, 2026-10-13):

- **Group rows' numbers** (1, 2): a group row's aggregated number is shown with its thousands grouped, an integer's
  too (a sum or a count is a quantity, not an id), and a fraction rounded to the column's scale (`Numeric(14,4)` → 4
  places) or 2 places, so an Average no longer shows every digit. A group's key is never touched, a user's column
  format wins, and hover, copy, CSV and Excel keep the exact value (`formatGroupRowValue`, `getTypeScale`).
- **Steps not set yet** (3, 4): the core's nodes say whether they have none of the settings they need (`isUnset()`,
  a Group only with neither keys nor aggregations); the canvas shows the type's name and a muted "Not set", and the
  tooltip leaves out the placeholder description.
- **Plain row-step names** (5): "Drop first rows", "Take first rows", "Take a range of rows", in place of the
  internal app's `<x>` placeholders (Appendix A, §7.0); editor titles keep `a`, `an` and `of` in lower case.
- **Types by the kind of value** (6): the editors' column lists say Text, Whole number, Number, Decimal, Date, Date and
  time, Time, True or false, and ", optional" (", can be empty" until 2026-10-15, user); the exact type is on hover, and
  messages that compare types (Join, Concat, a table that changed) keep it (`getColumnTypeLabel`,
  `getExactColumnTypeLabel`).
- **Run this step** (10): the node menu's and editor's Select is "Run this step"; the step that runs carries a "▶
  Runs" badge on the canvas and in its editor. `selected` and the saved spec are unchanged (Appendix A, §17.9's
  tooltip).
- **A DuckDB sample** (11): "Sales (DuckDB)", a direct-connection model with setup SQL, as the H2 samples are, with
  two Pivot examples, so Pivot runs on a sample; both run on the engine (`CubeExamples.engine-roundtrip-test.ts`).
- **Redo and the Cube menu** (12): Redo (Ctrl/Cmd+Shift+Z, Ctrl+Y) until a new change; a source re-checked while a
  change waits to be redone stays checked, as the undo snapshots do (§7.8). New, Import and Export are under one
  "Cube ▾" menu.
- **A column preview for tables**: in Sample Data, Project Database and Direct Connection, the table picked last
  shows its columns under the list once the clicks pause (250 ms), as an access point does; Add takes the columns
  read and asks the engine only for the others.
- **Copy, paste, duplicate, Delete**: on the focused step only, never while typing: Delete or Backspace removes it,
  Ctrl/Cmd+C copies, Ctrl/Cmd+V pastes after it, Ctrl/Cmd+D duplicates; the node menu has Copy, Paste and Duplicate.
  Each is one undo step. A copied source pastes only into a cube of its model and runtime, or an empty cube, which
  takes them. The core's `copyQueryNode` copies a node through its spec codec under a new id.

**Supersessions** (folded in with this pass):

- §7.8: `redoStack` and `copiedNode` in the state. §9: the group rows' number display.
- Appendix A: §7.0 (the row steps' names), §17.9 (the Select tooltip, now "Run this step").

### 11.16 Debugging runs: each run's Pure, and readable requests

Built on `cube-debug-runs`, from `cube-dev` (`afd8bdadd`), then replayed onto `master` as `cube-debug-runs-master`, in a
PR into `master`, finos/legend-studio#5689 (user, 2026-10-14); rebased onto `master` (`a8532a256`) once #5685 brought
`cube-dev`'s work there. Its status is in
[PROGRESS-DEBUG-RUNS.md](PROGRESS-DEBUG-RUNS.md). The user asked on 2026-10-14 to see the Pure that a drill-down
level runs (Show Pure shows only the canvas's query, §8.7) and for a way to read the engine requests in the
browser's dev tools, which shows the execute payloads as binary.

This subsection overrode the sections it names until DR.7 folded it into them (see "Supersessions").

**Why the payloads can't be read** ✅ (read in the code): Cube's host builds its engine client with
`enableCompression: true` (`LegendQueryCubeHost.ts`), as Legend Query's editor and Data Cube do. legend-shared then
deflates every request body (`NetworkUtils.ts`, `compressData`) and sends it as `Content-Type: application/zlib`, so
the Network tab shows `POST …/pure/v1/execution/execute` with a binary payload. The response isn't compressed: its
`activities[0].sql` holds the SQL. A drill-down level fetched before is served from the fetcher's cache (§11.10) and
sends nothing.

**Decided without asking** (precedents in the repo; for review):

- **Every run keeps the lambda it sent.** `runCubeQuery` returns the execution lambda it emitted, and each Runs entry
  keeps it, so Execute, each drill-down level or sorted fetch (labelled as today, e.g. `Rows of SHIP_COUNTRY
"France"`), the CSV export and the Pivot editor's value load (newly recorded, "Values of X") can each show their
  query. Nothing is rendered when a run is recorded: the Pure is asked of the engine only when the user opens it.
- **Show Pure on a run shows it as it ran,** its row limit (`limit(rowLimit + 1)`) included. The header's Show Pure
  keeps leaving the limit out (user, 2026-10-10), since it shows the cube's query; a run's Pure is for debugging what
  was sent. Same dialog, titled with the run's label.
- **Copy JSON** copies the lambda as the engine receives it: lossless numbers, pretty-printed. The V1 serialization
  stays behind the port (`CubeEngine.serializeLambda`), so the panel never touches a `V1_*` class.
- **Failed runs are recorded too,** with the engine's first line, since the query that failed is the one to debug.
  Only a stopped run records nothing; a grid fetch whose answer is dropped for a newer run is listed, since it ran
  (M7 left those out). _(Supersedes spec §12.6's "each successful run".)_
- **Dev Settings** (user, 2026-10-14: not in the Runs panel, but as Legend Query's editor has them): a menu (☰) at
  the top left of the page, in the palette's header, opens a Dev Settings modal with Query's two fields:

  - "Engine client base URL": the engine Cube's requests go to, used once the text is a URL, with "Reset engine
    URL" back to the deployment's. It lasts while the application runs, as Query's does.
  - "Engine client request payload compression": off, the Network tab shows each request's JSON. Kept per user
    (`userDataService`, as the grid mode is), on by default. The engine reads both.

  Cube's engine, connection explorer and service and ingest catalogs each have their own engine client, built from
  the same configuration; each registers with the host's `CubeEngineClientSettings`, which Dev Settings changes, so
  all of them follow, from the next request. Legend Query's own editor keeps its own Dev Settings: these reach only
  Cube's clients. Models already loaded aren't reloaded from the new engine.

**Steps:**

| Step | Deliverable                                                                                                 | Done when                                                     |
| ---- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| DR.1 | This section, PROGRESS-DEBUG-RUNS.md, a draft PR                                                            | Pushed                                                        |
| DR.2 | Runs keep their lambda and record failures; the Pivot value load and the export recorded; `serializeLambda` | Unit tests on each recorder                                   |
| DR.3 | The Runs panel: Show Pure and Copy JSON per run, a failed run's error                                       | Component tests                                               |
| DR.4 | Dev Settings: the engine URL and request compression, from the menu at the top left                         | Tests; the Network tab shows JSON on the dev server, zlib off |
| DR.5 | Verification: an inline review, a browser rehearsal on the dev server                                       | Findings fixed                                                |
| DR.6 | A demo video                                                                                                | Every caption true on screen; sent to the user                |
| DR.7 | Fold the supersessions in                                                                                   | §8.7, §9, §11.10 and Appendix A read as built                 |

**Supersessions** (folded in at DR.7, 2026-10-14):

- §9 and §11.10 (Runs): each entry also keeps its lambda, with Show Pure and Copy JSON; failed runs are recorded; the
  Pivot value load is a run; Dev Settings at the top left (the engine URL, request compression).
- §8.7 (Show Pure): a run's Pure shows its row limit.
- Appendix A: §12.6 (failed runs recorded).

### 11.17 Legend Cube in Studio: Create MatView

Built on `cube-studio`, now on master (`a8532a256`), in a PR into master (finos/legend-studio#5690), for a demo (user,
2026-10-14): an ingest definition's editor opens Cube to build a materialized view of the project's own sources.

**Settled** (user, 2026-10-14):

- **The button:** "Create MatView" (first "Create MatView with Cube", shortened: user, 2026-10-14) in the ingest
  definition editor's header, beside Lineage and View SQL, where `NonProductionFeatureFlag` is on _(2026-10-15, the
  user: approved for production; the button no longer depends on the flag, which still gates Legend Query's
  `/query/cube`, D11)_. Cube opens full
  screen over the editor, as Studio's embedded query builder does (`EmbeddedCubeState`, `LegendStudioCubeHost`).
- **Sources:** the ingest definitions and data products of the project being edited, as the workspace holds them,
  and nothing else (no sample data, examples, databases or services). One kind of source per cube, as in Legend Query.
- **The source dialog in Studio:** a screen of its own for each: the project's definitions, or products, by name,
  each path muted beside it; no class, producer deployment, search across the platform or warehouse to pick; the
  lakehouse runtime its runs use named instead (`CubeHost.project`).
- **Runs:** as Studio's own queries of an ingest definition or a data product run: the workspace as the model, on
  the project's first lakehouse runtime's environment, the cube's warehouse starting as the runtime's
  (`LegendStudioCubeWorkspace`, the engine's `ingestDefinitionSource` and `dataProductModelSource`).
- **Add MatView** (named Add as MatView until §11.18): the step that runs, as a lambda with no runtime and no row
  limit, becomes a data set of the definition:
  `{name, primaryKey, privacyClassification: {sensitivity}, source: {_type: 'FunctionSource', function}}`
  with the empty lists the engine gives every data set it parses (`ingestPartitionColumns`, `preprocessors`,
  `storageLayoutClusterColumns`, `storageLayoutPartitionColumns`), a local change in Studio. The dialog asks for the
  name, the data's classification (one of `dataProductConfig.classifications`, as a data product's access points
  offer, or typed when the config gives none) and the primary key, any of the step's columns, kept in their order
  (2026-10-14: a view without a classification failed with a NullPointerException), but the milestoning columns a
  write mode adds, `LAKE_DIGEST`, `LAKE_IN_ID`, `LAKE_OUT_ID`, `LAKE_FROM` and `LAKE_THRU` (user, 2026-10-14).
- **The light theme:** the demo is shown in Studio's light theme; Cube's dialogs draw their primary button as an
  accent button in every theme.

**In the builder:** `CubeHost.project` and `offersSampleData`, `CubeEditor`'s `headerActions`,
`buildCubeRunningStepLambda`, `buildCubeEngine`'s `ingestDefinitionSource` (with `getModelElements`) and
`dataProductModelSource`, `readCubeIngestDataSets` and `formatCubeIngestUrn`. The palette offers a table or a function
only with a tab that reads it; the Ingest tab picks the only producer deployment, starts on the environment's
warehouse, and hides Mode with one class; the floating step editor goes into the dialog that hosts the page; the
canvas takes xyflow 12's colours back where React Flow 11's stylesheet is loaded too (Risks).

### 11.18 Import Pure, and Edit MatView in Studio

Built on `cube-import-pure`, from master (`b33fba599`, after #5690), in a PR into master (user, 2026-10-14). Steps
and status in `PROGRESS-IMPORT-PURE.md`.

**Settled** (user, 2026-10-14):

- **Import Pure is a standard Cube feature:** in the Cube ▾ menu beside Import and Export, in every host. It reads a
  Pure lambda, as text or as the engine's JSON, into steps. The canvas lays them out as it always does, and each step
  takes its defaults for what Pure doesn't carry: grid column widths, formats, drill-down, step ids.
- **Edit MatView in Studio:** the ingest definition editor opens Cube on one of the definition's materialized views,
  read by Import Pure's reader; that view is the input Studio opens Cube with (`EmbeddedCubeState.open(ingest,
matView)`). Cube's header button is **Add MatView** for a new view and **Save MatView** for one opened, which
  replaces that view's lambda in place, keeping its name, place and other keys.
- **What is saved doesn't change:** the step that runs, with no runtime and no row limit
  (`buildCubeRunningStepLambda`, which only Studio's button calls); a Limit step the user adds stays.
- **Cube loads lazily in Studio,** with its dialog on the first opening, as Legend Query loads its Cube page.
- **A view Cube reads but writes differently** opens with a note that saving rewrites it the way Cube writes it
  (recommended to the user, 2026-10-14).
- **Save MatView asks again only when it must** (after #5695's classification and primary key, 2026-10-14): a view
  is saved at once unless its data has no classification (Studio wrote views without one before #5695, the cause of
  its NullPointerException) or one the config doesn't list, or its primary key has a column the step that runs no
  longer has, or a milestoning one. Then Add MatView's dialog opens as Save MatView, on the view's own classification
  and the key columns it can keep, and says why. A view saved gains the empty lists the engine reads
  (`ingestPartitionColumns`, `preprocessors`, `storageLayoutClusterColumns`, `storageLayoutPartitionColumns`) that it
  lacks. The user keeps Save MatView one click: a view's classification and key are otherwise set when it is added
  (2026-10-14).

**Reading a lambda:**

1. **Protocol JSON to IR** (`V1_readCubeLambda`, builder `v1/`): the inverse of `V1_serializeCubeLambda`, tolerant of
   the shapes the engine's grammar gives (source information, multiplicities, short enumeration paths). What Cube
   has no IR for is refused.
2. **The run's own parts come off by position.** A run's lambda has two shapes (§8.6): without lets
   `<relation>->sort(…)->limit(n)->from(<runtime>)`, with lets `{| <lets>; <relation>}->from(<runtime>)->sort(…)->limit(n)`.
   `from()` comes off and its runtime is the cube's; the capture's sort becomes a last Sort; lets are put back where
   they are read. A `limit` beside `from()` may be the grid's row limit (the Runs panel's Pure has one) or the user's:
   it is kept as a Limit step, with a note saying so. A matview has none of these parts.
3. **Sources are bound before the steps are read,** by the host: each accessor becomes a source step, typed where the
   host can. A host with a project (Studio) binds the project's own; another host binds within the cube's current
   model and source. An accessor that can't be bound is named in the error.
4. **IR to steps** (`readCubeQuery`, core, host-free), from the sources up. A step Cube writes as several functions
   is recognised whole: a Join's temporary key renames, FULL merge and last `select`; a Difference; a Concat that
   converts types; a Partition's `extend`s and `select`. Where the reading depends on the columns (the `select`
   that ends a Join or a Partition, a negation on a column that can be empty), the candidate step is written again by
   Cube's emitter, on the inferred schemas, and kept only when it gives the same Pure. A filter Cube can't read as
   its own becomes a Filter by Expression, an `extend` that isn't a window an Extend; anything else is refused,
   naming the function. Nothing is imported in part.
5. **Exactness:** once read, and once its sources and expressions are typed, the cube is written again, in the form
   the lambda came in (a matview's relation, or a run's lambda with no row limit), and compared with it at one level:
   protocol JSON without source information, number literals by value. Exact, rewritten, or unchecked; Studio's note
   shows for the last two.
6. **Binding rules:** one kind of source per cube, so a lambda mixing `#I`, `#P` and `#>` is refused; a matview that
   reads another matview is refused with a message of its own, as matview data sets are never sources.
7. **Expressions:** an Extend column's and a Filter by Expression's lambda is stored as the editor stores one (no
   source information, number literals as digit strings), its code the engine's grammar of it; an Extend is typed
   during the read, through the host's engine, so the steps after it can be checked.

**Not in the first cut:** the per-database rewrites (SQL Server, Sybase, DB2, ClickHouse, SingleStore), refused with
a message (Snowflake and Databricks have none); services, which Cube doesn't emit yet; a `select` that reorders
columns outside a Join or a Partition, which no Cube step does. The known gaps are in ISSUES.md, Import Pure.

**Built** (2026-10-14, steps IP.1–IP.12 in `PROGRESS-IMPORT-PURE.md`): the reader (`V1_readCubeLambda`,
`splitCubeLambda`, `readCubeQuery`, `StepPatterns.ts`, `isSameCubeIR`), the binding (`bindCubeLambdaSources`), the
import (`CubeLambdaImportState`: `CubeEditor`'s `initialLambda`, and the Cube ▾ menu's Import Pure dialog), the banner,
Studio's Edit MatView and Save MatView, and Cube loaded lazily in Studio. Every node of the core's samples and of the
nine examples reads back into the steps that wrote it, written the same, from Cube's JSON and through the engine's
grammar. Videos `28-edit-matview.webm` and `29-import-pure.webm`.

### 11.19 Legend Analytics: Cube as its own application

Built on `cube-analytics`, from `master` (`1cc69259a`), in a PR into `master`. Its status is in
[PROGRESS-ANALYTICS.md](PROGRESS-ANALYTICS.md). The demo review (`DEMO-RECAP.md` on the fork's `cube-demo-feedback`,
"Discussion points" 1) proposed that Cube become Legend Analytics, which later takes in Data Cube's grid and Legend
Query's builder. On 2026-10-14 the user decided that Cube is the first iteration of Legend Analytics, run as an
application of its own. This section covers that first iteration only: today's Cube page, hosted by its own app.

This subsection overrides the sections it names until AN.6 folds it into them (see "Supersessions").

**Settled** (user, 2026-10-14):

1. **A separate application, Legend Analytics,** whose first iteration is the Cube page as it is today.
2. **`/query/cube` stays** in Legend Query, on the same shared host. Whether it later redirects to Analytics is decided
   later.
3. **M8's store stays as #5679 has it** (`pure/v1/query/cube`, the `cube` collections). The user updates that PR when
   the time comes.
4. **Start now, from `master`.**

**Decided without asking** (precedents in the repo; for review):

- **The libraries keep their names,** `legend-cube` and `legend-cube-builder`. The user asked what makes more sense;
  this was the recommendation, for three reasons:
  - The repo names apps after products and libraries after the parts inside them: `legend-application-data-cube` over
    `legend-data-cube`, `legend-application-query` over `legend-query-builder`.
  - Analytics is where Cube's canvas, Data Cube's grid and Query's builder meet, so the product name belongs on the
    app. A library called `legend-analytics` would hold only the canvas.
  - A rename now (274 files import the core, both packages are on npm at 0.0.4) would conflict with #5690 and #5679
    for no change in behaviour. If the libraries ever become where the three merge, a rename is one mechanical PR.
- **Three packages,** as Data Cube and Marketplace have:

  - `@finos/legend-application-analytics`: the config, plugin manager, plugins, base store, routes and page.
  - `@finos/legend-application-analytics-bootstrap`: `LegendAnalyticsWebApplication.run`, the dev setup script and
    the styles.
  - `@finos/legend-application-analytics-deployment`: private; webpack, Tailwind and `analytics.config.js`.

  The two library packages start at `0.0.0`, with a patch changeset.

- **The address:** base `/analytics/`, dev port 9009, with the Cube page at the root. 9000 (Studio), 9001 (Query),
  9004 (Pure IDE), 9005 (REPL), 9007 (Data Cube) and 9008 (Marketplace) are taken.
- **One host for both deployments:** `LegendCubeHost` in legend-cube-builder, built from a plain `LegendCubeHostConfig`.
  - The config holds the engine's config and, all optional:
    - the depot, lakehouse, lakehouse platform and marketplace server URLs;
    - the marketplace application URLs;
    - the enterprise stereotype;
    - the lakehouse environment the host remembers for the viewer.
  - It builds what `LegendQueryCubeHost` builds today, by the same rules: no lakehouse, no data products; no
    platform, no ingest data sets; no depot, no projects.
  - Query's host becomes a subclass that passes Query's config. Tests still hand it an engine, an explorer and a
    project catalog, and M8 adds its store there on rebase.
  - Studio's host (#5690) stays its own, since its only sources are the project's ingest definitions.
- **Marketplace links** are built in the builder from the marketplace application URLs, the same address as Query's
  `EXTERNAL_APPLICATION_NAVIGATION__generateMarketplaceDataProductUrl`, which stays as part of Query's public API.
  Query's remembered lakehouse environment (`LegendQueryUserDataHelper.getLakehouseUserInfo`) stays Query's: its host
  passes it in, and Analytics remembers none.
- **The config,** as Query's minus what is Query's own:
  - `engine` is required.
  - `depot`, `lakehouse` (`url`, `platformUrl`) and `marketplace` (`url`, `productionParallelUrl`, `serverUrl`) are
    optional. Data Cube's config requires `lakehouse`; Cube doesn't need one.
  - `extensions.core` has `oidcConfig`, `enableOauthFlow` and `dataProductConfig.publicStereotype`, as Query's has.
  - There are no Studio, Data Cube or Legend AI URLs.
  - The storage key is `legend-analytics`, so what Cube keeps per user (row limit, grid mode, compression, warehouse)
    starts fresh.
- **Auth, as Query has it:**
  - OIDC is optional: `AuthProvider` and `LegendTokenSync`, with `withAuthenticationRequired` under `enableOauthFlow`.
  - The token client follows `enableTokenClient`.
  - The base store reads the current user from the engine and sets up telemetry, as Query's does.
- **No feature flag:** deploying the app is the opt-in (D11). Query's `/query/cube` keeps the `NonProductionFeatureFlag`
  gate it has on `master`.
- **The app's plugins** register Cube's shortcuts (`LEGEND_CUBE_COMMAND_CONFIG`) and set up the code editor, which
  Cube's expression editors, SQL dialog and spec transfer use, as Query's plugins do. The browser tab reads "Legend
  Analytics".
- **The light theme, as Query draws it** (found at AN.3): the theme defaults to light, as in Query and Data Cube. But
  much of Query's light look is Query's own stylesheet (`legend-application-query/style/light-mode.scss`, "a poor-man
  version of theming for Legend Query"), which Analytics can't import. Without it, Cube's dialogs had blue headers.
  - Measured on Query's Cube page (:9002): the rules of that stylesheet that match the page as each surface opens. The
    surfaces were the Examples, Show Pure, Show SQL and source dialogs, Dev Settings, a node editor and its tooltip,
    the canvas and grid context menus, and a notification.
  - 27 rules match. The shared theme already gives Analytics one of them (the colour variables). The other 26 (inputs,
    menus, form fields, modals, the tooltip, notifications, the assistant's button, scrollbars, the splitter) are
    copied into the bootstrap's `light-mode.scss`, which its `index.scss` imports after every other style, as Query's bootstrap does its own.
  - Not copied: the results grid's font. Query's styles load ag-grid's base stylesheet a second time after Balham's,
    so Query's grid shows 14px Helvetica Neue. Analytics shows Balham's own 12px.
- **No download helper:** Cube downloads its files through data URIs (`downloadFileUsingDataURI`), so the bootstrap
  registers no service worker (`withDownloadHelper`), as Data Cube's doesn't.
- **No deep links in this iteration:**
  - `?sourceType=…&sourceId=…` (Query's `LegendQueryCubeEntry`) and M8's `/:cubeId` move into the builder once M8
    lands, so both apps read them the same way.
  - Moving them now would conflict with #5679, which rewrites them.
- **No Docker image in this iteration:** publishing `finos/legend-analytics` needs the maintainers (the Docker Hub
  repository and the release workflows). Marketplace and REPL publish none either. The deployment is built by
  `check-build`.

**Facts** (✅ read in the code):

- ✅ The builder uses no graph manager: Cube writes its lambda JSON itself and talks to the engine through its own
  clients. The app needs no graph presets.
- ✅ `LegendQueryCubeHost` (234 lines) builds the engine and every catalog from Query's config, and uses two of
  Query's helpers: the marketplace link and the remembered lakehouse environment.
- ✅ Query and Data Cube both use react-oidc-context's `AuthProvider` with `LegendTokenSync`. Query keeps OIDC in
  `extensions.core.oidcConfig`, Data Cube at the top level.
- ✅ Query's `/cube` route is mounted only with `extensions.core.NonProductionFeatureFlag` (on `master` since #5685).
- ✅ Cube's dialogs and menus are legend-art's (`Modal`, `ModalHeader`, `MenuContentItem`, `PanelFormTextField`), whose
  light look comes from each application's own stylesheet (see "The light theme, as Query draws it").

**Steps:**

| Step | Deliverable                                                                                             | Done when                                                                            |
| ---- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| AN.1 | This section, PROGRESS-ANALYTICS.md, a draft PR                                                         | Pushed                                                                               |
| AN.2 | The shared host: `LegendCubeHost` and `LegendCubeHostConfig` in the builder; Query's host built on it   | Builder unit tests; Query's host tests pass unchanged                                |
| AN.3 | The app: three packages, registered (root `dev:analytics`, `tsconfig.json`, `check-build`), a changeset | The app's tests (config, the page at the root, shortcuts); `dev:analytics` serves it |
| AN.4 | Verification: an inline review; a browser rehearsal on :9009 against the engine on :6300                | Findings fixed                                                                       |
| AN.5 | A demo video                                                                                            | Every caption true on screen; sent to the user                                       |
| AN.6 | Fold the supersessions in                                                                               | §3.5 and the builder's `docs/hosting.md` read as built                               |

**Later** (each its own PR, not this one):

- After M8: saved cubes and deep links in Analytics (`/analytics/:cubeId`, `?sourceType=…`), with the entry helpers
  moved into the builder.
- Links to Analytics from Marketplace and Studio (an `analyticsApplicationUrl` in their configs).
- A Docker image, with the maintainers.
- Whether `/query/cube` redirects to Analytics.
- One shared legacy light stylesheet for Query and Analytics, in place of the rules Analytics copies from Query's.
- Data Cube's grid as the results grid, and Legend Query's builder as a node (data spaces), each its own milestone
  (DEMO-RECAP discussion points 1 and 2).

**Risks:**

- **Conflicts.** #5679 changes Query's host: its store moves into the subclass. #5690 also adds a PLAN section before
  §12; keep both.
- **Two apps drifting apart:** the shared host keeps the sources and their rules in one place. Each app keeps only its
  config and its shell.

**Supersessions** (folded in at AN.6, 2026-10-14):

- §3.5: Cube has three hosts:
  - Legend Query (`/query/cube`) and Legend Analytics (`/analytics/`), which share `LegendCubeHost`;
  - Studio's embedded cube (#5690).

### 11.20 Variant (JSON) columns: a readable cell, Explore JSON, Explode Array, Parse as JSON

Built on `cube-variant`, from master (`dd0b62af9`), in a PR into master. Steps and status in
[PROGRESS-VARIANT.md](PROGRESS-VARIANT.md). The user asked on 2026-10-14 what Cube can do with Variant columns, which
usually hold JSON, and whether anything in Legend's UIs helps navigate them. The analysis, its probes and the user's
answers are in the evidence folder's `variant/` (`PROPOSAL.md`). legend-lite's Data Cube (github.com/neema2/legend-lite,
`datacube/src/json-shape.ts`, `ui/json-fields.ts`) is the inspiration the user pointed to.

V.8 folded it into the sections it names (see "Supersessions").

**Settled** (user, 2026-10-14):

1. **The whole proposal in one milestone:** the readable cell, Explore JSON, Parse as JSON, and the Explode Array node.
2. **Explore JSON opens from the grid's column menu:** a Variant column's cell menu, beside Sort by and Filter by, and
   its header menu.
3. **Sampling:** the first 1,000 values, with "Read every row" on request.
4. **Engine gaps** are drafted in ISSUES.md for the user to file; Cube works around them.

**Engine facts:**

- ✅ Read in legend-engine `93d92b4852`:
  - **Variant functions:** `get(key)`, `get(index)`, `to(@T)`, `toMany(@T)`, `toVariant`, `fromJson`, `toJson`, and
    the relation `flatten(~col)` inside `lateral`.
  - **Array functions after `toMany(@Variant)`:** `size`, `map`, `filter`, `fold`, `sum`, `min`, `max`, `first`, `at`,
    `slice`, `sort`, `contains`, `joinStrings`, and `keys`/`values` through `to(@Map<String, Variant>)`. All are
    written to SQL (`pureToSQLQuery_variant.pure`).
  - **Per database:** Snowflake, Databricks, Postgres and DuckDB implement them. Each excludes 14–17 Variant PCT tests
    and no `lateral`/`flatten` test. H2 excludes 78, `get` and `fromJson` among them, and 13 `lateral` tests: it has
    no Variant support.
  - **What the exclusions are on those four:**
    - `to(@Integer)` of a float or float string, `to(@StrictDate)` of a bad string, `to(@Enum)`, and `toMany` of a
      non-array return a value or nothing where Pure throws;
    - `to(@Class)` isn't supported;
    - nested list or map literals fail.
  - **The engine sends a Variant cell as compact JSON text** (`RelationalResult.java`): Cube's reader takes it as a
    string.
  - **Schema exploration maps only the standard JDBC types** (`SchemaExportation.java`): a DuckDB `JSON` column comes
    back as `Other`, which Cube hides, so a direct connection never shows it.
- 🧪 Run on the local engine, DuckDB (evidence `variant/probe-*`), with a `SEMISTRUCTURED` column in a text model over
  a DuckDB file:
  - Cube types the column Variant, and its cell is the JSON text.
  - **In an Extend, through Cube's own typing:**
    - `get`/`to` paths;
    - `get(0)`;
    - `toMany(@Variant)->size()`;
    - `->map(i | $i->get('qty')->to(@Integer))->sum()`;
    - `->filter(…)->size()`;
    - a Variant-valued `get`;
    - a Boolean condition on a path.
  - **Straight to the engine:**
    - `lateral(x | $x.payload->get('items')->toMany(@Variant)->flatten(~item))` gives one row per element. A row
      with no elements, or a null document, comes back once with null.
    - grouping and sorting by an extracted field;
    - `keys()` and `values()` through a Map;
    - `toJson` after `toOne()`.
  - **On a VARCHAR column:** `fromJson()` gives the same results.
  - **Fails:**
    - `toMany(@String)->contains('b')`: invalid SQL on DuckDB. The form through `toMany(@Variant)->map(…to(@String))`
      works.
    - `toMany(@Variant)->exists(…)`: "Cannot cast a collection of size 0".
- 💭 A Snowflake data product is the main place Variant columns come from, and Snowflake isn't probed here: its SQL is
  known from the manifests only.

**Decided without asking** (for review):

- **The readable cell (V.2):**
  - **One line:** a Variant cell is shown on one line in legend-lite's notation, `{name: ann, tier: gold}`,
    `[a, b]`. Numbers are kept as written, and a key or string is bare unless it could be mistaken for something
    else.
  - **The value stays the JSON text** for Copy, CSV and Excel.
  - **"Open JSON"** in the cell menu opens the document pretty-printed as a collapsible tree in a dialog, with Copy
    JSON. Text that isn't JSON is shown as it is.
- **Shape inference (V.3)** is host-free code in the builder (`src/stores/variant/`). It reads a sample's documents
  with legend-shared's lossless JSON, so `1` and `1.0` differ and large ids keep their digits. Each position records:
  - how often it is present and how often null;
  - its scalar kinds, and its top 50 values;
  - its object fields;
  - its array element shape and lengths.
- **Types are suggested conservatively**, since the engine won't catch a wrong one:

  - integers mixed with floats are Float;
  - Date and DateTime only when every sampled value matches;
  - any other mix is String.

  The panel shows the top values beside the suggestion. The engine then types the column, as any Extend's.

- **The offers.** Each is Pure text for one Extend column, with a default name: the path joined with `_`, made a valid
  column name, and numbered `_2`, `_3` … if taken. Only forms run on legend-engine are offered.

  | Field            | Offers                                                                                                                                  |
  | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
  | scalar           | the value, as its suggested type: `…->get(k)->to(@T)`                                                                                   |
  | object or array  | as JSON: `…->get(k)`, a Variant column of its own, to explore again                                                                     |
  | object           | its keys, as text: `…->to(@Map<String, Variant>)->toOne()->keys()->joinStrings(', ')`                                                   |
  | array            | number of elements (`size`); one row per element (adds an Explode Array node)                                                           |
  | array of scalars | all values, as text (`map(e \| $e->to(@String))->joinStrings`); contains _v_ for its top values (the `map` form, not `toMany(@String)`) |
  | array of objects | first element's _k_ (`get(0)->get(k)->to(@T)`); total of a numeric _k_ (`map(…)->sum()`)                                                |

  Not offered: `exists` (an engine error); a tuple explode of several fields (`toVariant(newMap(…))`, nested maps
  excluded on Snowflake and DuckDB); `to(@Class)`.

- **Explore JSON (V.4):**
  - **Where:** "Explore JSON…" in a Variant column's cell menu and header menu opens a dialog.
  - **The sample:** the query up to the node that ran, then a Restrict to the column and an Is Not Empty filter,
    1,000 rows. It is run through `runCubeQuery` as the Pivot editor's values are, so it is listed in Runs ("Sample
    of payload") and can be stopped.
  - **"Read every row":** runs the same query with no row limit, through `runCubeQuery` too (V.4: simpler than
    parsing the CSV export, and its cells arrive as the grid's do). It is listed in Runs ("Every value of payload")
    and can be stopped.
  - **The dialog:** a searchable field tree, each field with its kind and share ("in 83% of the rows read"). The
    picked field's offers show on the right, and ticked offers become the columns of one new Extend, added after the
    node that ran as one undo step. The engine parses each column's text before it is added; the background check
    types it.
  - **Refusals:** on a database without Variant support (H2), or while the rows are stale, a run is in flight or the
    cube is read-only, as for the quick actions.
- **Parse as JSON (V.5):** a String column's cell menu offers "Parse as JSON", which adds an Extend column
  `<name>_json: $x.<name>->toOne()->fromJson()`, a Variant column. Refused on H2.
- **Explode Array (V.6)** is a new core node, `explode`, "Explode Array", after Extend in the palette.
  - **Settings:** a Variant column, a path of keys to its array (empty when the column is the array), and the new
    column's name.
  - **Output:** the input's columns plus the new one, a nullable Variant: one row per element. A row with no
    elements, or none, is kept once with null.
  - **Emission:** `lateral(x | $x.<col>->get(k)…->toMany(@Variant)->flatten(~<name>))`, with no new IR kind.
  - **Databases:** run only on the types that support it (Snowflake, Databricks, Postgres, DuckDB). Any other known
    type is refused with a message, as Pivot is; typing lambdas carry no type.
  - **Its editor** picks the column, the path from the column's sampled shape (or typed keys), and the name. Explore
    JSON's "one row per element" adds one, prefilled.
  - **As built (V.6):**
    - **Every database keeps the row** of an empty array or a null document, once, with no element 📄🧪:
      Snowflake writes `flatten(…, outer => true)`, Databricks `explode_outer`, Postgres `left join lateral … on true`,
      DuckDB `unnest` of `[NULL]` for an empty list. Read from each dialect's source and pinned by plans
      (`LegendCubeDialects` engine test); run on DuckDB.
    - **The engine types the element `[1]`** though it can be empty 🧪: Cube says nullable, the conformance and
      example tests declare it wider, and an engine issue is drafted in ISSUES.md.
    - **Postgres may fail on an array of strings** 📄: its plan reads each element as text (`#>> '{}'`), then casts
      it to `jsonb`. Not run here; drafted in ISSUES.md to confirm.
    - **Its editor:** a column picker that greys columns that aren't Variant ("not JSON"), the keys as rows, and
      Find arrays, which reads the column's first values after the node's input, as Explore JSON does (listed in
      Runs), and lists the arrays they hold. The name follows the keys (`<last key>_element`, free in the input)
      until typed.
    - **Explore JSON** adds its Extend, then the Explode, as one undo step (`addConfiguredNodes`). One array is
      ticked for one row per element at a time: two would give a row for each pair of their elements.
    - **Import Pure reads it back:** the reader takes `lateral` in Explode Array's form, and refuses another.
    - **The example** "Order lines from JSON": the orders parsed, their `items` exploded, then each line's product and
      quantity, 12 rows (10 lines, and once each the order with no lines and the one with no document).
- **A JSON column's fields in the expression editors (V.9, user, 2026-10-15, after a mockup):**
  - **Where:** a Variant input column's chip in the Extend and Filter by Expression editors gets `{}` and a caret,
    which open its fields in place, under the chips. The grid's Explore JSON stays as a second way in (user).
  - **The sample:** read when opened, just before the step being edited, as Explore JSON reads one (listed in
    Runs, Stop, Read every row), so no run of the cube is needed and stale rows don't matter; kept while the editor
    is open. Refused, the caret greyed with the reason, where the database has no Variant functions.
  - **A field clicked** writes its expression where the chips write theirs (the Extend's expression last used, the
    condition): a value as its suggested type, an object or array as JSON, the column itself at the root. Its other
    forms (counts, lists, contains, the first element's fields, totals) are listed under it; one row per element is
    greyed: it is a step of its own. In the Extend editor, "+ column" adds the field as a new column, named as
    Explore JSON names it, replacing a new row still empty.
  - **Variant columns get a tree icon** (`SitemapIcon`) wherever a column's type icon shows.
- **A Variant column itself stays not sortable, groupable or comparable,** with Count only and empty tests (§11.4).
  Grouping or sorting on a raw Variant isn't probed on legend-engine, and extracting a field first covers the need.
- **Samples:**
  - **The DuckDB Sales dataset gains a table `demo.orders`** (V.5), with a VARCHAR column `document` of JSON
    documents: each order has a customer object, an array of items, a flag, a date and tags. One order has no
    document.
  - **Examples:** "Orders from JSON" parses them (an Extend `document_json`), and V.6 adds one that runs Explode
    Array. No other local source can show a Variant column: H2 has none, and the DuckDB direct connection hides
    `JSON` columns.
  - **An example runs** once its expressions are typed as well as its tables (V.5): an Extend waits for the
    engine's typing.
  - **The engine tests** do what the probe does: create a DuckDB file through a direct connection, then read it
    through a text model declaring `SEMISTRUCTURED`.

**Steps:**

| Step | Deliverable                                                                              | Done when                                                           |
| ---- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| V.1  | This section, PROGRESS-VARIANT.md, ISSUES.md's engine drafts, a draft PR                 | Pushed                                                              |
| V.2  | The readable Variant cell, and Open JSON's tree dialog                                   | Unit and component tests                                            |
| V.3  | Shape inference and the offers                                                           | Unit tests: lossless numbers, conservative types, each offer's Pure |
| V.4  | Explore JSON: the sample through Runs, Read every row, the dialog, the new Extend        | Store and component tests; engine test of each offer's Pure         |
| V.5  | Parse as JSON, and the DuckDB `demo.orders` sample with its example                      | Tests; the example runs on the engine                               |
| V.6  | Explode Array: core node, codec, emission, database refusal, editor, palette             | Core and builder tests; engine tests (DuckDB) and conformance       |
| V.7  | Verification: an inline review, a browser rehearsal; a demo video                        | Findings fixed; every caption true on screen                        |
| V.8  | Fold the supersessions in                                                                | §7.8, §9, §11.4 and the builder docs read as built                  |
| V.9  | A JSON column's fields in the Extend and Filter by Expression editors (user, 2026-10-15) | Store and component tests; the video                                |

**Risks:**

- **Snowflake isn't probed:** the Snowflake SQL for `get`, `to` and `lateral` is known from its manifests only.
- **Wrong guesses give wrong rows on these databases.** Hence the conservative types, and the top values shown beside
  them.
- **Sampling the first rows can miss rare fields.** The share and Read every row make that visible.
- **Read every row** reads the whole column into the browser; a huge column is slow, and the run can be stopped.

**Supersessions** (folded in at V.8, 2026-10-15):

- §9 (grid): a Variant cell's one-line view, Open JSON, Explore JSON, Parse as JSON.
- §7.8 (state): the Explore JSON state.
- §11.4 (types): Variant unchanged, plus the new node's type rules.
- The core's and the builder's operation docs: Explode Array.

### 11.21 The editor's guided tour

**Why:** users don't find much of what Cube does. The user chose a guided tour of the editor first (2026-10-15), ahead
of a "help mode" and a hosted user guide (planned separately, below).

**As built:**

- **Where it starts:** "Take the tour" in the page menu (☰, top left) and on the empty canvas. Nothing starts it on its
  own.
- **On an empty cube** where examples are offered, it opens "Top customers by orders" behind its first step, which says
  so; Undo takes it back. Otherwise it tours the cube as it is, after the node editor's edits apply.
- **One step at a time:** the page dimmed but for the step's controls, and a card beside them (Back, Next or Done, End
  the tour, "n of 15"). The card follows the controls as the page moves them. Esc ends it; the arrow keys move; the
  focus stays on Next, so Enter moves on. Clicks on the page are held, and the page's shortcuts wait (`isDialogOpen`).
- **Steps:** welcome, sources, steps, the graph, the step that runs, Execute and Rows, Server-side or Local, the
  results, Show SQL, Runs, the Cube menu, Add Items, Examples, Undo/Show Pure/Hide graph, and the page menu to end.
  Their text is in `__lib__/LegendCubeTour.ts`, and says only what the page does.
- **Anchors:** a control joins a step by carrying `cubeTourAnchor(...)` (`data-cube-tour`); a step points at the box
  around its controls. A step whose controls aren't on the page (the graph hidden, no SQL yet, a host without Examples)
  is skipped.
- **No new dependency:** the card is placed by legend-art's `BasePopper`; in a page hosted in a dialog (Studio), the
  tour stays in the dialog's layer.
- **Not in v1:** a step inside a node's editor (its outside-click closing would fight the card), and remembering who has
  seen the tour.

**Tests:** every step's controls are on the page of an example cube (what keeps the tour from going stale); the example
opened and taken back by Undo; keys, Back, End the tour; the graph's steps skipped when it is hidden; shortcuts held
while the tour shows.

**Next (planned, not built):**

- **A user guide on legend.finos.org**, its pages and screenshots in legend-studio (`docs/user-guide/cube/`), the
  screenshots made by a script that marks the controls by their names. The legend repo's site would read the folder
  when it builds (a second Docusaurus docs section), with a daily build so new pages show within a day; or Studio's CI
  would push it into the legend repo, as legend-engine's does (needs a token the FINOS admins grant).
- **Help mode:** every control labelled at once, from the same text as the tour and the guide.

**Steps:**

| Step | Deliverable                                                     | Done when                                 |
| ---- | --------------------------------------------------------------- | ----------------------------------------- |
| T.1  | The tour: steps, anchors, the card, the menu item and the offer | Store and component tests; a browser walk |
| T.2  | A demo video                                                    | Every caption true on screen              |

### 11.22 Join: the other input from the menu, and suggested join columns (feedback, 2026-10-16)

Feedback on the Join node after an audit: adding one left its Right input empty, to fill by dragging a node onto it,
and its join columns were picked from whole column lists, with nothing suggested.

- **The other input from the menu (user: Join only, for now).** On the canvas's menu, hovering Join Another Input (or
  the right arrow key) opens "Join with": the end of each other chain that wouldn't close a loop (a node feeds one
  other only), labelled as on the canvas, a step with the sources it reads (`Distinct Values · from CUSTOMERS`).
  Picking one adds the Join after the node with it as the Right input, as one step to undo, the model's outline read
  first. A click on the item adds the Join alone, as before, and its editor then offers the input it waits for.
  Concatenate and Compare Column Values keep dragging for now. The submenu opens in a popper above MUI's modal menu,
  whose focus trap is lifted on the canvas's menus.
- **Suggested join columns** (`CubeJoinSuggestions`), strongest first, comparable types only, each pair once:
  1. a join the database defines between the tables the inputs' columns come from (`findColumnOrigins`, through
     Renames and other steps), either way round, every pair of a compound join mapped; the outline now reads a
     Database's joins (equality of columns, ANDed; self-joins and other operations left out) and its tables'
     primary keys;
  2. a table's whole primary key that the other side names alike;
  3. `<table>_ID` (or `<table>Id`) against that table's `ID` or single-column primary key;
  4. a column both sides name alike, key-like names (and primary key columns) first; others only when nothing better
     is suggested, as they are mostly columns the join must rename.
     Sources other than relational tables (data products, ingest, functions) get the name-based ones only: their
     columns are not traced to tables, and an ingest data set's primary key is not on its node.
- **Filled in when clear (user: yes).** A Join that gets its second input with no join columns (from the menu, the
  editor's input picker, a drop or a drag) takes a suggestion when one is clearly it: the only database join, else
  the only primary key both sides name alike, else the only key-like column both sides name alike. Never over its
  own columns, never on Swap Inputs.
- **The editor** lists the suggestions not taken (four at most) with their reason and Use, which replaces blank pairs
  or adds after the picked ones; the column pickers list the suggested columns first, in a Suggested group.
- Verified in Chromium against the local engine on Northwind (`ORDERS ⋈ SHIPPERS` on the database's `SHIP_VIA =
SHIPPER_ID`, then `⋈ CUSTOMERS` on `CUSTOMER_ID` through the first Join), and by keyboard; video 35.

## 12. G. Risks and open questions

### 12.1 Risks

| Risk                                                                                                                                                        | Impact                                           | Mitigation                                                                                                                                                                  |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Engine semantics drift: 55 relational, compiler or relation commits in 30 days; CI uses the moving `:snapshot` image ✅                                     | Golden tests break, or semantics change silently | Assert semantics (rows, types), not SQL text; log the engine commit per run; keep the window-regression and dialect harnesses as tests; pin the image digest if churn hurts |
| The engine does not type-check `==`, `in` or join keys ✅                                                                                                   | Runtime database errors or silent coercion       | Mandatory Cube validation (§5.4, §5.6) with negative acceptance cases                                                                                                       |
| Window extend + filter gives wrong rows; a filter on the window column is silently dropped on 9 database types ✅ (plans, M5.10)                            | Wrong numbers in production                      | The array form and `let` isolation (M5, §8.6); the plan-only and composition tests; engine issues drafted in ISSUES                                                         |
| Engine multiplicities are wrong for outer joins, and for Sum and Average ✅                                                                                 | Wrong operator offers, wrong grid nulls          | Cube infers nullability; the conformance suite is exact on nullability but for each case's declared wider columns (§11.5 Q7)                                                |
| Engine typing bugs: `CHAR(n)`→`Varchar(1)`, `BINARY` 500, views `Varchar(0)`, `OTHER`→`String` with numbers, CLOB invalid JSON ✅                           | Bad types, crashes                               | Picker flags; no length validation; the Cube fixture avoids them; a 200 with an unparseable body is treated as an error                                                     |
| Studio library defects (batch `result`/`results` until #5593, lossy relation-type metadata, transformer bugs) ✅📄                                          | Cube built on broken APIs                        | Cube's own `v1/` seam (D8); upstream PRs separately                                                                                                                         |
| The AGENTS.md V1 rule vs repo reality 📄                                                                                                                    | Review friction                                  | V1 symbols only under `legend-cube-builder/src/graph-manager/protocol/pure/v1/`, engine-backed tests excepted (§3.7); propose an AGENTS.md clarification                    |
| Local inference (§5) must equal engine typing as transforms grow                                                                                            | Divergence and confusing errors                  | Conformance suite per node type (from M4); engine typing for Extend with caching                                                                                            |
| Saved format changes before the store exists                                                                                                                | Stranded exports                                 | Format marked draft until M8; migrations are still written                                                                                                                  |
| Northwind reloads on every connection (≈0.6 s per execute) ✅                                                                                               | Slow tests                                       | Compile-only (`lambdaRelationType`, ~20–70 ms) for schema assertions; few executions                                                                                        |
| Pivot on Snowflake, Databricks and SingleStore is checked by plans only; `LakehouseRuntime` → Snowflake holds while lakehouse runtimes route there ✅ (M6c) | A pivot that fails first in GS                   | Plan-only tests per type with a canary for Trino and Athena; one table maps a lakehouse runtime to its database (§8.8, §11.12)                                              |
| Data products and ingest are absent from the open-source engine ✅                                                                                          | M9 slips                                         | Mocks (D6); mapping-based data product modes first                                                                                                                          |
| An ingest definition is read through its grammar and the engine's parse, with no project version (§6.7)                                                     | A definition the parse rejects can't be added    | The error shows on the tab with Retry; reading it from Depot at its deployed version is the TODO; Part B2 checks real definitions                                           |
| Single runtime and single database per query (v1, and in the `let` form)                                                                                    | Some joins not expressible                       | Explicit validation messages; revisit with the depot catalog and services                                                                                                   |
| Grid license assumed (D3)                                                                                                                                   | Watermark on unlicensed builds                   | The local dev host is `localhost` (no watermark) ✅; the community-only path is documented if ever needed                                                                   |

### 12.2 Open questions (none block M1)

1. **Entry points, sources modal and final look** (D7 follow-up): the sources modal and the data product and direct
   connection tabs were built in M3 (§6.8). **Where the node editor opens, the canvas gestures and the entry links moved
   to M3b** (§11.8, user, 2026-10-09): settled there as a floating editor below the node. Still open: which Legend Query
   surfaces link to `/cube` (U9 needs the team), and the rest of the final look.
2. **Sort not at the sink:** answered in M2 (§11.4). Cube writes the order where it is used: just before a Limit, Drop
   or Slice that takes rows by it, and before the capture's limit. It warns only when the order is lost (a Join, a
   Group, a Concat, a Restrict that drops sort keys, a later Sort on all the same columns); the warning is derived, never a validation
   error, so Execute stays enabled.
3. **Count rows:** answered in M4 (§11.5 Q1). Cube adds a Count rows aggregation with no column alongside the
   non-null Count: `x|1 : y|$y->count()`, saved as `{function: 'CountRows', name}`. The grid's Group by adds it.
4. **Views and tables with `BINARY` columns** in the picker. v1 default (§6.2.6): views hidden; `BINARY` tables shown as "unavailable" and not selectable. Revisit with the M3 sources modal.
5. **SNAPSHOT versions** in the depot picker: allow, at a recompile on every call, or resolve to a concrete version?
6. **Multiple databases or runtimes per query:** when, and with what engine support? Today it is a two-step plan with
   no pushdown, or a plan error.
7. **Engine image pinning for CI:** keep `:snapshot` (the repo norm) or pin a digest?
8. **Window frames:** answered in M5 (§11.6 Q1). D5's default, with no frame saved or written: running with a sort,
   ties sharing a value, and the whole partition without one. A "running / whole partition" setting on each
   aggregation entry is the follow-up if users ask; explicit frames are not planned.
9. **Running queries in the browser with DuckDB WASM** (user, 2026-10-08, low priority, no milestone yet): Cube emits a
   Pure lambda and the engine runs it (D9). Running in the browser needs either a second emitter, from the Cube IR
   (§8.3) to DuckDB SQL, or the engine's SQL plan run locally. Either way, precise types, NULL rules and join semantics
   must match what this plan verified on the engine, and the data must reach the browser somehow. Data Cube already
   uses `@duckdb/duckdb-wasm` (1.31.0), which is the precedent to study. First step: a research note with the options
   and a recommendation, no code.
10. **The next sources, still open** (2026-10-08; settled parts in §6.8): versions (recommended: `latest` resolved to a
    concrete version at pick time, since the engine caches `latest`; SNAPSHOT opt-in, item 5). Access badges for data
    products are settled in §6.8 (#5652).

Questions about the original app's UI that the spec leaves open are in [QUESTIONS.md](QUESTIONS.md).

---

## Appendix A: Spec deltas, section by section

The user accepted the departures from the spec's guidance sections (§14.4, §17.7, §17.11) on 2026-10-05.

| Spec §                            | Status           | Change                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| --------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Preamble (22–30), §0              | Superseded       | Non-negotiable = §3 (extended), §4–5, §7–10, §16 semantics. Wire = relation lambda (protocol JSON). Storage = CubeSpec v1. Sources = relational tables, then services, functions, data products, ingest                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| §1                                | Kept, nuanced    | "No server round trip for editing" holds for the slice's nodes; Extend typing uses an engine call, cached (§5.7)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| §2.1, §18                         | Superseded       | Host auth, config, telemetry                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| §2.2                              | Kept + narrowed  | The core has no host imports through the slice (§3.3). From M2.0 it may use legend-graph's metamodel types, but still no `V1_*` and no UI or app packages (D12)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| §3.1                              | **Extended**     | Precise primitive registry, families, comparison classes (§5.4); interning per `(path, params)`; unknown → Opaque instead of throwing; `Decimal`, `StrictTime`, `Variant` added                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| §3.2                              | **Extended**     | `nullable` on columns; unique names asserted; `equals` ignores nullability                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| §4                                | Kept + fixes     | Acyclicity invariant; `connect(…, port)`; per-type ids; port labels                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| §5                                | Kept + extended  | Query-level rule pass; host issues map (display only)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| §6.1                              | Idea kept        | Source and transform registries + builder adapters; batch resolution                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| §6.2–6.7                          | Superseded       | §6 of this plan                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| §7.0, `V1:` lines                 | Replaced         | Saved shapes (§10.3) + emission table (§8.8)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| §7.0 names (§11.15)               | Changed          | The row steps are "Drop first rows", "Take first rows" and "Take a range of rows", in place of `Drop first <x> rows`, `Take first <x> rows` and `Take rows <x> to <y>` (the user's review of the demo, 2026-10-13)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| §7.1 (M2)                         | Extended         | Cube adds `Sort column "<c>" of type <type> cannot be sorted.` (VARIANT, OPAQUE), then `Sort columns cannot have duplicates.` One `isSortableType` also serves the editor's picker, the grid's Sort by and the row-number fallback's default key. The direction messages are unreachable                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| §7.2 (M4)                         | Extended         | The grid's Group by adds Count rows, not Count of X; no keys reads `Aggregate all rows`; VARIANT and OPAQUE keys are refused (`… cannot be grouped.`); keys keep the stored order, which the engine follows ✅ (§11.5)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| §7.4                              | Kept + labels    | Emit `select` in input order; the editor stores picks in input order too. The spec names no labels: Cube's messages use `Columns` (empty, duplicates) and `Column` (unnamed, not in the input) (M2)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| §7.5 (M2)                         | Fixed            | `isValidColumnName` replaces the regex: non-empty, trimmed, no `"`, `\` or control characters, ≤ 128 code points 💭. Spaces, hyphens, `'` and unicode work; `"` breaks at execution, `\` later references ✅. New names may not equal untouched input columns or each other, in any case (`foldColumnName`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| §7.7–7.9 (M2)                     | Changed          | No `arguments.length` trick: no JS defaults, so an explicit `undefined` stays cleared and invalid, saved and loaded too (§10.3); the defaults (10; 10 and 20) live only in the registry's `create(id)`. Size 0, Drop 0 and an empty Slice stay invalid, though the engine takes them (M2)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| §7.9, §17.9                       | Changed (D5)     | `[start, stop)`; help-text copy fixed (counting from 0). The canvas reads `Take rows 10 to 20 (20 excluded)`, or `Take rows 10 to (blank)` without a stop; the palette label stays `Take rows <x> to <y>` (M2, §11.4)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| §7.10 (M4)                        | Extended         | Precise-strict equality, a type next to its ancestor too; Cube's precise messages after the spec's; ports First and Second; a setting where the spec says "no state": Convert types, a type-only cast within numbers, strings or dates, not real conversions; Rename and Restrict autofixes (§11.5)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| §7.11                             | Kept + extended  | FULL OUTER; nullability and merged-key rules (§4.7); step 4 per §5.4. Autofix (M2): `c_1`/`c_2`, then `c_<side>_2`… when taken in any case, ≤ 128 code points; the key lists rewritten through the renames; offered only when the duplicate rule is the join's only error                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| §7.12 (M6)                        | Changed          | The spec's rows (a FULL join, every key once, empty values counting as 0), emulated; **native types** (Integer, Float or Number by family, not always Float; §11.7 Q5); two Cube checks (a difference column can't be a join column; no output name taken twice); the inputs labelled Left and Right                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| §7.13 (M5)                        | Extended         | Saved as Cube's own `{columns, sorts, aggregations}`, never the V1 `operations` (`olapRank`, `olapAggregation`); Row Number and Count rows added; Rank, Dense Rank and Row Number need ≥ 1 sort (`Aggregation function "<a>" requires at least one sort column.`); VARIANT and OPAQUE partition columns refused; Count emitted as `size()`; D5's default frame, none saved or written; the editor's sections in the original's order, functions first, labelled Window functions, Partition columns and Sort columns (§11.6 Q4)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| §7.14 (M6)                        | Changed          | One lambda per column (`x \| …`), each able to use the columns above it (one `extend` per column); every new column nullable; names by `ColumnName.ts`'s rule, not an input column's nor another new column's, compared folded; saved as `{name, code, lambda}` with the last typing (§10.3)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| Beside §7.x (M6b)                 | Added            | Filter by Expression, Cube's own node: a Pure condition of one row, checked by the engine, beside the spec's Filter (§4.8, §11.11)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| Beside §7.2 (M6c)                 | Added            | Pivot, Cube's own node: a column's picked values become columns, through the engine's static `pivot()`, refused on the databases that can't run it; ag-grid's local pivot unchanged (§4.5, §8.8, §11.12)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| §7.16                             | Fixed            | Unknown gets synthetic per-instance ports (keeps its edges and raw JSON); not rewireable; re-saved with regenerated `inputs`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| §8.2                              | **Extended**     | Matrix keyed by family (§5.5); VARIANT/OPAQUE row                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| §8.3, §21                         | **Fixed**        | Operator availability and value-type validation (§5.6) with new messages                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| §8.2, §8.3 (M1.5)                 | **Restricted**   | A StartsWith/EndsWith/Contains value (or a negation's) may not contain `\`: the engine does not escape it in LIKE patterns, so the rows would be wrong (§8.4, Appendix B). Temporary; lifted when the engine is fixed. User OK 2026-10-06 (option a: refuse, not pre-escape)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| §8.4                              | Replaced         | Emission (§8.4 of this plan) + saved filter shape (§10.3). NULL behaviour documented (D4)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| §9 (M6)                           | Replaced         | Pure lambdas, as written in Query's text mode, stored as the engine's JSON with the text, typed by the engine over the cube's model instead of TDS expression objects; §9.2's table becomes help only, with Pure names (§8.8); edited inline in the node's panel, a row per column, not in a modal; Validate (F10) parses, types and plans (§11.7)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| §10 (M4, M5, M5b)                 | Extended         | Count rows, an aggregation with no column (§12.2 item 3); DECIMAL counts as numeric; VARIANT and OPAQUE offer Count only; an unknown, empty or window-only function is kept on the Group, invalid, and saved again unchanged (§11.5 Q4). Rank and Dense Rank, with Row Number, which Cube adds (auto-named `Row Number`), form a window-only set of their own (`WindowRankFunction`) beside the aggregation functions, which a Partition offers as a Group does, Count rows included (§11.6). M5b adds NTile, Percent Rank and Cumulative Distribution to that set, and the window-only row functions Lag, Lead, First and Last (`WindowRowFunction`), which take a column; Lag and Lead take an offset and NTile a bucket count; all need a sort; Nth is left out (§11.9)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| §10.1                             | Extended (M6d)   | Per-family view; Min and Max on strings and Booleans, typed as the column, and the distinct ones on enumerations (§5.7, §11.14)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| §10.2                             | **Replaced**     | §5.7 measured table; Count, DistinctCount and Count rows are not nullable, every other aggregate is (M4); nor are Rank, Dense Rank and Row Number (M5)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| §10.3 (M4)                        | Changed          | Output names are always stored: the editor fills in the auto-name and follows column and function changes until the user edits it; a saved aggregation with no name gets it on read. Count rows' auto-name is `Count Rows` (§11.5 Q3)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| §11.1                             | Idea kept        | `CubeEngine` port (§8.7)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| §11.2–11.3                        | Superseded       | –                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| §11.4                             | Superseded       | Host HTTP client; keep "truncate and show trace link" behaviour via host                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| §12–13                            | Idea kept        | §9 of this plan; a drill-down derived as Cube's own nodes; `limit + 1`; typed group keys; §21 fixes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| §12.1 (M7)                        | Kept + changed   | Server-side mode is the default, and the spec's local mode is kept as a toggle, remembered per user; switching runs nothing again and keeps the rows current, since Execute is the same flat run in both modes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| §12.2 (M7)                        | Changed          | Keys typed from the parent rows, no `"(null)"` sentinel (an empty key is IsEmpty, a float key `In [v, fround(v)]`); a level's aggregations named as a Group's and mapped back to their columns; a number's Sum by default, other columns empty, and a `Count Rows` column in place of `fullSchema`'s count of every column and `CUBE AUTO COUNT`; a level's sort ends with its own column                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| §12.3 (M7)                        | Changed          | Each level fetched whole, cut at the row limit, not paged; a bounded LRU cache (200,000 rows) keyed by the derived lambda; pivot stays out of server-side mode                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| §12.4 (M2, M4, M7)                | Changed          | Sort by, Group by (M4, §9) and Filter by add a node after the selected one, never run, and are disabled on stale rows, during a run, read-only, or on a type or value they can't use; no icons. M7 curates the menu: 'Drilldown by' first, the quick actions, 'Format Column…', then columns, copy and Export; cut, paste and ag-grid's export gone; Filter by on a group row only on its key                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| §12.5 (M7)                        | Changed          | CSV of the whole query through the engine (`CSV_TRANSFORMED`), no row limit, Stop aborts; XLSX is ag-grid's export of the rows shown, numbers as numbers, unformatted                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| §12.6 (M7)                        | Changed          | A Runs panel of the 15 latest runs, fetches and exports, with their SQL and Copy SQL; no trace link, as Cube has no trace id. §11.16: failed runs are listed too, each run with Show Pure and Copy JSON, and Dev Settings (the engine URL, request compression)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| §13.1 (M7)                        | Changed          | Saved as `meta.presentation.columnHints`; ISO 4217 codes from `Intl.supportedValuesOf('currency')` in place of four symbols; red if negative on its own flag; the exact text formatted, every digit kept; a group row's count never formatted; a format for every number column with its own Remove                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| §14, §15                          | Superseded / out | §10; publishing out of scope. §14.4 "Save disabled while invalid": Export spec is allowed for invalid queries; server Save gating decided in M8 (§10.3)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| §16                               | Kept + additions | §4.11; M5 adds `Partition column "<c>" of type <T> cannot be partitioned.` and `Aggregation function "<a>" requires at least one sort column.` (§11.6); M6 adds Difference's `Difference column "<c>" cannot be a join column.` and `Difference output column "<c>" is not valid column name.`, and Extend's one-parameter lambda (`"<c>" must be a lambda with one parameter, such as x \| $x.PRICE.`), the engine's failure on a column or on all, and the pending marker `ERR_TYPING` (§11.7); M5b adds `Aggregation function "<a>" needs an offset that is a whole number of at least 1.` (or `a bucket count`) and `Aggregation function "<a>" does not take an offset.` (or `a bucket count`) (§11.9) M6b adds Filter by Expression's `Condition must be a lambda with one parameter, such as x \| $x.PRICE > 10.`, `Condition must give one Boolean, …, not <type>.`, `Condition can't be checked: <detail>`, `The value given to "<fn>" cannot contain a backslash (\\) yet.` and the pending marker `ERR_CHECKING` (§11.11); M6c adds Pivot's `Row column "<c>" of type <T> cannot be grouped.`, `Pivot column "<c>" of type <T> cannot be pivoted.`, `Pivot column "<c>" cannot also be a row column.`, `Pivot values cannot be more than 100.`, `Pivot value "<v>" is not a valid <T>.`, `Pivot values "<a>" and "<b>" differ only in case, …`, `Pivot value "<v>" cannot name a column.`, `Aggregation function "<a>" cannot be used in a pivot.`, `Aggregation column "<c>" cannot be a row column or the pivot column.` and `Pivot is not supported on <database>.` (§11.12); M6d adds `The value given to "<fn>" must be a text in quotes unless the cube runs on Snowflake: …` (§11.14), and a backtick makes a name invalid |
| §17                               | Guidance         | §7; a floating editor below the node, as the popover (M3b); visible Left/Right, First/Second; xyflow + dagre; deep links as path params (M3/M8). §17.11: Execute needs only the capture subtree to be valid (§10.3). §17.7: uncoercible text is kept as an invalid value with a type message (§4.9), and STRING values are not trimmed                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| §7.11, §17.3 (2026-10-11)         | Changed          | The spec's Join reads "Join additional input" whatever its keys; Cube's reads `<type> join on "A" = "B"` once it has key pairs. A node's tooltip leaves out its internal id, and a source shows its name with where it comes from below; the full description stays in the tooltip. An added transform opens its editor, and Apply closes it (UX audit, user)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| §17.4 (M3b)                       | Changed          | A transform added without a node target goes after the selected node, spliced in, not unconnected (U3); a source opens its dialog tab, even dropped onto a node or picked from a node's menu (U4); an empty query has no context menu; 'Add Items ▾' lists the palette                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| §17.5 (M3b)                       | Kept + extended  | The floating editor below the node (432px, flips, follows pan and zoom), with Cube's Apply/Cancel footer and problems list. Every close but Cancel applies, Escape included; outside clicks change nothing while a layer opened from it is open (`holdOpen()`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| §17.6 (M4, M5, M5b, M6, M6b, M6c) | Changed          | Group keys stored in input order, as Restrict's; 'Add aggregation' never disabled (a column can be aggregated several ways); Concat's editor, "Nothing" in the spec, has a comparison table, the autofix buttons and Convert types (§11.5). The Partition editor lists window functions first, then partition columns (a checklist, VARIANT and OPAQUE disabled with a reason), then sort rows, as the original does (§11.6 Q4); 'Add window function' is never disabled, and 'Add sort column' is disabled once every sortable column has a row. From M5b, a Lag or Lead row has an offset field and an NTile row a bucket count field, marked when invalid; the column list offers Lag, Lead, First and Last after the aggregations and NTile, Percent Rank and Cumulative Distribution after the ranks (§11.9). The Difference editor has Join's key rows and a checklist of the Left input's columns, each that can't be compared saying why; the Extend editor a row per column (name, move, expand, remove, a code editor, its type or problem), Add column, Validate (F10) and the input's columns, a click writing `$x.NAME` (§11.7) The Filter by Expression editor has the condition in a code editor, its status, Validate (F10), the input's columns, a click writing `$x.NAME`, and notes on parentheses, empty values and backslashes (§11.11). The Pivot editor has the row columns ticked, the pivot column, Load values and the values ticked (All, None), the aggregation's function and column, and notes; a column or value it can't take is shown disabled, saying why (§11.12)                                                                                                                                         |
| §17.9 (M4)                        | Changed          | Concat's help text: "Combines the rows of the two previous data sets, keeping duplicates, in no particular order. Both must have the same columns: the same names, in the same order, with the same types." (§11.5 Q8)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| §17.9 Select (§11.15)             | Changed          | Select is "Run this step", its tooltip "Make this the step that runs: Execute shows its rows in the grid."; the step that runs carries a "▶ Runs" badge                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| §17.15 (M3b)                      | Changed          | `?queryId=` stays in the address bar (U9). Entry links open data product access points only (`?sourceType=dataProductAccessPoint&sourceId=…`), stripped whether they resolve or not                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| §19.1–19.3                        | Superseded       | §3 (two packages), §8 (relation functions, not `meta::pure::tds::*`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| §20                               | Idea kept        | §11 (headless first; join and filter in M1; persistence last)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Appendix C.4                      | Replaced         | §8.5 lambda + relation type; §11.2 acceptance                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |

## Appendix B: Defects found (upstream, non-blocking, D8)

**legend-studio**

- **Fixed by #5593 (2026-10-06):** the batch relation-type call read `results` while the engine returns `result`,
  which also broke data-product code; legend-graph now reads `result`.
- `V1_RemoteEngine.ts:190-205` (`buildRelationTypeMetadata`), `RelationTypeMetadata.ts`: precise type parameters are
  dropped.
- `MetaModelConst.ts:64-82`: `PRECISE_PRIMITIVE_TYPE` has nonexistent `Date`/`Time`/`Decimal` paths and a wrong
  `Timestamp`. `PrecisePrimitiveType` has no package, so round trips shorten paths.
- `V1_ValueSpecificationTransformer.ts:517`: ColSpec `function2` is built from `function1`. `:460`/`:496` drop the
  ColSpec type; `:397` throws on precise literals.
- `V1_AccessorHelper.ts:358-367`: the schema-qualified table lookup is overwritten at `:367` (wrong table on name clash).
  `STO_Relational_Helper.ts:222-263` diverges from engine typing.
- Legend Query's version-revert modal crashes to a blank page when `lightQuery` is unset (a query without an
  execution context) 📄. Four `LegendQueryApplicationPlugin` types are declared but never used.
- `legend-lego` `DataGrid` always registers enterprise modules. The query e2e README claims a community grid by default.
  Outside production builds it also swaps `console.error` for `console.debug` on every render (`DataGrid.tsx:69-74`)
  and restores it only in `onGridReady`, so an error logged while a grid renders, or after a re-render that doesn't
  remount it, shows only as a debug message.
- `legend-art` pins `react-reflex` 4.2.7, whose `ReflexContainer.getSize` and `onStopResize` read `element.ref`. With
  React 19, opening a resizable panel or dragging a splitter logs "Accessing element.ref was removed in React 19"
  (seen on the Cube page when the editor panel opens, M1.9). The fix is a `react-reflex` upgrade in legend-art, its own
  PR.
- Repo: `.yarn/constraints.pro` is never read (`yarn constraints` is a no-op). AGENTS.md's V1 rule doesn't match Data
  Cube. Bootstrap and deployment changeset entries are auto-generated by the release script (AGENTS.md wording).

**legend-engine** (issue write-ups)

- Window `count()` / `distinct()->count()` lose OVER (`pureToSQLQuery.pure:6979-6982`): typed Integer, planned with no
  OVER on every database type, and failing on H2 ✅. Cube writes a window's counts with `size()` (§11.6). Drafted in
  ISSUES.md for the user to file; not filed yet.
- A single-AggColSpec window extend is not isolated (`:4231-4233`), so a following filter is pushed below the window
  (France counts 2, not 77, on H2 ✅). A filter on the window column becomes QUALIFY on 4 types, is refused on 6 and
  is silently dropped on 9: SQL Server, Sybase, Sybase IQ, DB2, Oracle, Trino, BigQuery, Athena and ClickHouse (✅,
  plans, M5.10). Cube writes the array form and binds every window that isn't the capture with a `let` (§8.6). Drafted
  in ISSUES.md for the user to file; not filed yet.
- A windowed `distinct()->size()` or `uniqueValueOnly()` plans `count(distinct …) over (…)` on all 17 window types and
  runs on H2 ✅, though Postgres, SQL Server, Databricks and Trino reject DISTINCT in a window aggregate, and Oracle
  and BigQuery with a sort (💭, vendor documentation): the error comes only from the database. Cube offers both
  natively, with an editor note (§11.6 Q3). Drafted in ISSUES.md for the user to file; not filed yet.
- A window extend of several columns plans one nested subselect per column once anything follows it (a limit, a
  filter, a `let`), though they share one window ✅: the rows are right, but the SQL grows a level per window column.
  Drafted in ISSUES.md for the user to file; not filed yet.
- `CHAR(n)` → `Varchar(1)` (`RelationalCompilerExtension.java:1030`).
- `BINARY`/`VARBINARY` give a "Match failure" that kills the table accessor.
- View columns are typed `Varchar(0)`.
- Outer joins don't widen multiplicity; Sum and Average are reported `[1]` but can be null (an all-null group's Sum ✅;
  Count, DistinctCount and Count rows are rightly `[1]`, Min, Max and DistinctValue `[0..1]`, M4). A consequence:
  `not(…)` on a column NULL-padded by an outer join is rendered without its NULL branch and drops those rows, so Cube
  guards every negation of a nullable column with `isEmpty` (§8.4).
- NPEs: `groupBy(~[], …)`, `groupBy` with no aggregations, `concatenate` with a column-count mismatch, which compiles,
  typed as the shorter relation ✅, and fails only at execution (M4).
- `rank` without ORDER BY compiles and plans on all 17 window types, then fails on H2 as a 500 with no location ✅;
  Cube refuses it first (`Aggregation function "<a>" requires at least one sort column.`). Drafted in ISSUES.md for
  the user to file; not filed yet.
- Mixing FuncColSpec and AggColSpec in one `extend` throws a ClassCastException.
  `if()` drops type parameters ("Wrong type variables count").
- A duplicate column from `rename` (to a name the relation has) or `select(~[A, A])` fails as an HTTP 500,
  `Compilation error at ??, "The relation contains duplicates: [X]"`, not a 400 with the call's source location, so
  the error can't be placed on a node ✅. Cube's validation refuses both first (Rename's collision check, Restrict's
  duplicates). Drafted in ISSUES.md for the user to file; not filed yet.
- Exact comparisons on H2 32-bit `REAL` columns silently match nothing (`cast(x as float)` against a REAL), whatever type the model declares.
- H2 CLOB values serialize as invalid JSON (`ValueTransformer`). DateTime literals below seconds precision are
  truncated to the day. `toDecimal` truncates the scale on H2.
- Slice fails PCT on SQL Server (`limit m,n`); drop fails PCT on SQL Server and DB2 (`limit m,-1`); Sybase emits the same SQL but has no PCT module. CTE names are not quoted for keywords on
  SQL Server, DB2 and Sybase.
- On SQL Server, the engine writes `distinct()` then `limit(n)` as `select top n distinct …`:
  `sqlServerExtension.pure:66` puts TOP before DISTINCT, and T-SQL needs `select distinct top n` (💭, plans only: no
  SQL Server in reach). Cube pads every Distinct on SQL Server (§11.4). Drafted in ISSUES.md for the user to file; not
  filed yet.
- `rewriteSliceAsWindowFunction` (`extensionDefaults.pure:39`), which Sybase IQ uses for a `limit`, `drop` or `slice`
  in a subquery (`sybaseIQExtension.pure:265`) and MemSQL for a `drop` (`memSQLExtension.pure:389`), numbers the rows
  by the first ORDER BY key only, so ties on that key take any rows. It also keeps the select's `distinct`
  (`extensionDefaults.pure:67`), so on Sybase IQ a limit after `distinct()` numbers every row and DISTINCT removes
  nothing. Plans on `93d92b4` ✅; their SQL took the wrong rows on H2 and SQLite. Cube numbers those rows itself (every
  Limit on Sybase IQ, since the engine also names its numbering column `row_number`, which clashes with an input column of
  that name) and pads Sybase IQ's Distinct (§11.4). ClickHouse writes a Drop's offset after a descending key as
  `nulls firstoffset m`, one word; Cube numbers that Drop's rows too. Drafted in ISSUES.md
  for the user to file; not filed yet.
- `escapeLikeExprDefault` (`extensionDefaults.pure:1061-1070`) escapes `_` and `%` but not the escape character `\`,
  so on H2 a backslash in a `startsWith`, `endsWith` or `contains` value changes what matches; the engine test at
  `testWithFunction.pure:59-61` locks today's SQL in. Cube refuses such values (§8.4). It writes no `ESCAPE` clause,
  so DuckDB, which has no default escape character, matches the escaped `%` and `_` as a backslash then a wildcard;
  Cube refuses those on DuckDB (§8.2, §11.11).
- A static `pivot()` with its `cast` plans on Trino and Athena with no `PIVOT`: the outer select reads the pivoted
  columns from a subquery that never pivots ✅ (plans, M6c). The 13 other types without pivot assert "pivot is not
  supported". Cube refuses a Pivot on both (§8.8, §11.12). Drafted in ISSUES.md for the user to file; not filed yet.
- `startsWith`, `endsWith` and `contains` given a pattern that isn't a literal write the pattern's own SQL as the text
  to match on 18 types (DuckDB found nothing ✅), match its `%` and `_` as wildcards on H2 with `concat`, and are written
  right only on Snowflake (`contains(a, b)`) ✅ (plans, M6d). Cube refuses such a pattern unless on Snowflake (§8.8,
  §11.14). Drafted in ISSUES.md for the user to file; not filed yet.
- `processIdentifierWithQuoteChar` (`extensionDefaults.pure:677`) wraps a name in the dialect's quote character and
  strips only `"`, so a backtick inside a name ends it early on Databricks and MemSQL, which quote with backticks ✅
  (plans, M6c.7). A pivot's own column name holds the value's text, so Cube refuses a backtick in a pivot value
  (§11.12); a Rename or an Extend to such a name has the same defect, from before M6c. Drafted in ISSUES.md for the
  user to file; not filed yet.
- Enum equality after `project` compares the source value. Dotted quoted table names split into four path parts and
  resolve the wrong table. A literal one past the long range silently wraps.
- `BaseStoredVersionedAssetDao`: no version check, non-atomic updates, `GET` rewrites the document, and id reuse
  corrupts history.

## Appendix C: Evidence index

**Spec:** [docs/design/WIP-CUBE-SPEC.md](../../../docs/design/WIP-CUBE-SPEC.md).

**Northwind:**

- model: [Northwind.pure](../../../packages/legend-manual-tests/src/__tests__/query-builder/model/Northwind.pure)
- engine DDL and loader: `legend-engine-xts-relationalStore/legend-engine-xt-relationalStore-execution/legend-engine-xt-relationalStore-executionPlan-connection/src/main/resources/org/finos/legend/engine/plan/execution/stores/relational/connection/driver/vendors/h2/h2NorthwindDdl.sql`
  and `…/src/main/java/…/ds/specifications/LocalH2DataSourceSpecification.java:38-74`, `…/vendors/h2/H2Commands.java:73-87`

**Engine relation functions:**

- definitions: `legend-engine-core/legend-engine-core-pure/legend-engine-pure-code-functions-relation/legend-engine-pure-functions-relation-pure/src/main/resources/core_functions_relation/relation/functions/`
  (`transformation/join.pure:18-24` JoinKind, `slice/slice.pure`, `olap/over.pure`, `ranking/*.pure`)
- compile-time types: `legend-engine-core/legend-engine-core-base/legend-engine-core-language-pure/legend-engine-language-pure-compiler/src/main/java/org/finos/legend/engine/language/pure/compiler/toPureGraph/handlers/Handlers.java`
  (join 482-519, concatenate 1852-1871, rename 2379-2405)

**Engine SQL generation:** `legend-engine-xts-relationalStore/legend-engine-xt-relationalStore-generation/legend-engine-xt-relationalStore-pure/legend-engine-xt-relationalStore-core-pure/src/main/resources/core_relational/relational/pureToSQLQuery/pureToSQLQuery.pure`
(routing 11561-11618, join 7900-7975, null-safe equality 9137-9152, window 4231-4297 and 6979-7021).
Legacy TDS: `…/core_relational/relational/tds/tds.pure:30-86`, `tdsExtension.pure:96-148`.
H2 portability manifest: `legend-engine-xts-relationalStore/legend-engine-xt-relationalStore-dbExtension/legend-engine-xt-relationalStore-h2/legend-engine-xt-relationalStore-h2-PCT/src/main/resources/pct-manifests/relational-h2/RelationFunctions_manifest.json`.

**Engine types:** `RelationalCompilerExtension.java:933-1100` (path in §5.1); precise primitives in
the `legend-pure-m3-precisePrimitives` 5.105.0 jar (`platform_precise_primitives/precisePrimitives.pure`).

**Engine endpoints:**

- typing: `legend-engine-core/legend-engine-core-base/legend-engine-core-language-pure/legend-engine-language-pure-compiler-http-api/src/main/java/org/finos/legend/engine/language/pure/compiler/api/Compile.java:143-210`
  (`lambdaRelationType`, `/batch` → `LambdaRelationTypesResult.result`)
- query store: `legend-engine-application-query/` (`QueryStoreManager.java`, `DataCubeQueryStoreManager.java`, `Query.java:25-53`)
  and `legend-engine-shared-mongo/…/BaseStoredVersionedAssetDao.java`

**Studio APIs:** [V1_EngineServerClient.ts](../../../packages/legend-graph/src/graph-manager/protocol/pure/v1/engine/V1_EngineServerClient.ts),
[V1_RemoteEngine.ts](../../../packages/legend-graph/src/graph-manager/protocol/pure/v1/engine/V1_RemoteEngine.ts),
[V1_TypeSerializationHelper.ts](../../../packages/legend-graph/src/graph-manager/protocol/pure/v1/transformation/pureProtocol/serializationHelpers/V1_TypeSerializationHelper.ts),
[FormatterUtils.ts](../../../packages/legend-shared/src/format/FormatterUtils.ts).

**Legend Query:** [LegendQueryNavigation.ts](../../../packages/legend-application-query/src/__lib__/LegendQueryNavigation.ts),
[LegendQueryWebApplication.tsx](../../../packages/legend-application-query/src/components/LegendQueryWebApplication.tsx),
[LegendQueryApplicationConfig.ts](../../../packages/legend-application-query/src/application/LegendQueryApplicationConfig.ts),
[ExistingQueryDataCubeViewer.ts](../../../packages/legend-application-query/src/stores/data-cube/ExistingQueryDataCubeViewer.ts) (embedding precedent).

**Data Cube (neighbour):**

- [DataCubeEngine.tsx](../../../packages/legend-data-cube/src/stores/core/DataCubeEngine.tsx)
- [DataCubeQueryBuilderUtils.ts](../../../packages/legend-data-cube/src/stores/core/DataCubeQueryBuilderUtils.ts)
- [LegendDataCubeDataCubeEngine.ts](../../../packages/legend-application-data-cube/src/stores/LegendDataCubeDataCubeEngine.ts)
- ingest data sets (§6.7): [LakehouseProducerDataCubeSourceBuilderState.ts](../../../packages/legend-application-data-cube/src/stores/builder/source/LakehouseProducerDataCubeSourceBuilderState.ts),
  [LakehouseProducerDataCubeSource.ts](../../../packages/legend-application-data-cube/src/stores/model/LakehouseProducerDataCubeSource.ts),
  and legend-graph's `createAccessorFromPackageableElement` ([V1_PureGraphManager.ts](../../../packages/legend-graph/src/graph-manager/protocol/pure/v1/V1_PureGraphManager.ts))
- **Reuse:** the xyflow + dagre stack, engine-client calls, and the undo / commit-on-Apply patterns.
- **Keep separate:** snapshot model, filter and aggregate classes, type utilities, grid datasource, persistence.

**Scratch evidence** (important harnesses become repo tests in M1.7, M4 and M5): live-test lambdas, the window
regression matrix (`g1/`), dialect plan dumps (`g4/`) and the persistence harness (`persistence-verify/`) are kept
outside the repo, in the local planning evidence folder (PROGRESS.md › Open items).
