Skip to content

Commit 434c6c7

Browse files
feat(spec)!: an analytics dataset dimension's and measure's field is a column reference (#21220) (#21240)
Fixes #21220 Clause-②: yes Dispatched by the PM claim `5938507454` (PM loop round 1, `domain:spec` seat 1), on the triage direction `5938409101`. An ADR-0021 dataset's `dimensions[].field` and `measures[].field` now take the column-reference accept set the cube members they compile to already hold since #20943 (PR #20998), from ONE shared declaration. A non-column value is refused at parse, at `dimensions.N.field` / `measures.N.field`, with a prescription naming the ADR-0021 form. The runtime door from PR #21190 is untouched and stays as defence in depth. The changeset carries the `(narrowing)` arm, the BREAKING banner at `minor`, and the ADR-0087 marker `registered dataset-member-field-expression-refused, dataset-count-measure-empty-field-removed`. ## Patch round 2 — contract review `5940617829`, item ①.6 The review found one lossless sub-shape that the first round sent to D3 only: a dataset measure `{ aggregate: 'count', field: '' }`. It parses on the base, and the #21190 door skips it (its `!== ''` guard). On SQLite's native path it compiled to `COUNT()` and answered 200. Its producer is Studio's dataset inspector. This round: - **New D2 conversion `dataset-count-measure-empty-field-removed`** in `MAJOR_18_CONVERSIONS` (`order: 54`, inserted at its identifier's sort position, defined directly above `elementFilterRemoved`). `main` landed `form-field-public-picker-removed` at 53, so this entry takes the next free number. - It drops `field` from a `count` measure whose `field` is exactly `''`. Without the key, `compileDataset` emits `sql: m.field ?? '*'`, which is `COUNT(*)`: the row count. - Its mechanics are measured from the precedent `time-default-utc-suffix-dropped`, not recalled: `toMajor: 18`, `retiredFromLoadPath: true` (ADR-0087's ratified pre-GA policy for a lossless repair), and `retiredAfter: '17.5.0'` (the spec's current version, the same value the precedent carries). - It uses the shared `stripKeys` helper and emits one notice per removed key. - Its fixture is disjoint. The controls are left as stored: a dimension `''`, a `sum` over `''`, a count over `'*'`, a count with no `field`, and a count over a column. - **Scope: only `count` + `''`.** A non-count measure with `''`, a dimension with `''` and every expression have no working row or no mechanical rewrite, so they stay D3-only. A padded value has no known producer and is out of scope. - **The D3 entry links the conversion** with `conversionIds: ['dataset-count-measure-empty-field-removed']`, the way `18.time-default-zone-refused.ts` links its conversion. Its `reason` now says what is true: the door refuses an expression, and it never judged `''`. It also says that D2 carries the `count` + `''` repair and D3 the rest. `acceptanceCriteria`, the `STEP18_RATIONALE` fragment, the changeset, the two ledger notes and the `dataset.zod.ts` comment are corrected to match. `registry.ts` was regenerated by `gen:migration-registry`. - **Pins.** The new `src/conversions/dataset-count-measure-empty-field-removed.test.ts` covers four things on a STORED row, through `applyConversionsToStoredItem('dataset', row)`: - The key is dropped, with one notice per measure, and the row then parses. - Every control is the same reference. - The replay is idempotent. - The conversion is registered under major 18, retired from the load path, and linked from the D3 entry. The table-wide fixture replay in `conversions.test.ts` covers before → after. - **What a NEW save does.** The write path parses with the current schema and replays no conversion, so a new Studio save of `field: ''` is still refused at save with the prescription to omit the key. The producer-side change stays objectui's. A row already stored that way is repaired on load at every stored-row seam. ## What changes **`@objectstack/spec`** - **One pattern.** The new module `packages/spec/src/data/analytics-column-reference.ts` declares the column path once (a bare identifier, then zero or more `.identifier` hops). It sits outside the `data` barrel, like `ui/analytics-carrier-filter.ts`, so it is not published API. It exports two anchored forms built from that one source string: - `ANALYTICS_COLUMN_REFERENCE`: the path, or `'*'`. - `ANALYTICS_COLUMN_PATH`: the same path without the `'*'` arm. - **Cube layer unchanged.** In `data/analytics.zod.ts`, `CUBE_MEMBER_SQL` is now that same `RegExp` object: `const CUBE_MEMBER_SQL = ANALYTICS_COLUMN_REFERENCE`. The declaration stays in this file on purpose. ADR-0021's 2026-10-01 note links to `analytics.zod.ts#CUBE_MEMBER_SQL`, and ADRs are a governed surface this PR does not edit. The cube members' JSON-Schema `pattern` is byte-identical; the new pin asserts it. - **Dataset layer.** In `ui/dataset.zod.ts`: - `DatasetMeasureSchema.field` uses `.regex(ANALYTICS_COLUMN_REFERENCE)`. Admitted: a column, a relationship path, or `'*'`. A count may still omit `field`. - `DatasetDimensionSchema.field` uses `.regex(ANALYTICS_COLUMN_PATH)`, so a dimension also refuses `'*'` (see measurement 3). - Both are `.regex()`, not refinements, so the published JSON Schema carries each as a `pattern`. `dropped-refinements.baseline.json` is untouched. - The refusal code is `invalid_format`. Each prescription opens with the contract sentence and names ADR-0021. - The measure prescription names the measure `filter` form and `derived: { op, of: [...] }`, with the 0–1 ratio scale. - The dimension prescription says a CASE bucket becomes a field of the object. - The two `describe()` texts now say "never a SQL expression". `content/docs/references/ui/dataset.mdx` is regenerated from them. - **ADR-0087.** - New D3 entry `migrations/entries/semantic/18.dataset-member-field-expression-refused.ts`. `registry.ts` was regenerated by `gen:migration-registry` and never hand-edited inside the markers. - One hand-written `STEP18_RATIONALE` fragment, inserted at the id's sort position with `order: 58`. Round 1 used 57. After the round-2 base merge it takes 58, because 57 is allocated to the in-flight PR #21244's fragment. Neither list requires unique orders: `step18-rationale-merge.test.ts` models two fragments sharing one `order` and only asserts a positive integer, and `main` already holds two 56s. So whichever of the two PRs lands first, neither re-orders. - One D2 conversion, `dataset-count-measure-empty-field-removed`, for the one lossless sub-shape (a `count` measure's `field: ''`; see Patch round 2). The D3 entry carries the rest: an expression has no mechanical rewrite into a column. - No `RETIRED_KEYS_BY_MAJOR` row: no key left the shape. - **Liveness.** The `dataset` ledger rows `dimensions.field` and `measures.field` stay `live`. Each is re-verified on 2026-10-01, with the narrowing recorded in its `note`. - **Guide.** `content/docs/data-modeling/analytics.mdx` gains one "Key rules" bullet saying that `field` is a column reference. **Ratchets, as expected for a value narrowing.** The `api-surface`, `authorable-surface`, `json-schema.manifest`, `export-origins` and `declaration-map` artifacts are byte-identical. `spec-changes.json` and the upgrade guide stay at protocol 17, so major-18 entries do not project yet, and both checks are green. ## The PM's mechanism assumptions, measured 1. **Confirmed.** `dataset.zod.ts:125` (dimension, required) and `:189` (measure, optional) were bare `z.string()` at the base `d6d6e872`. `CUBE_MEMBER_SQL` was a module-private `const` at `analytics.zod.ts:240`. 2. **Exporting `CUBE_MEMBER_SQL` would move the public surface.** `packages/spec/src/data/index.ts` re-exports the whole module with `export * from './analytics.zod'`, so an exported `CUBE_MEMBER_SQL` becomes a new `@objectstack/spec/data` export and needs `gen:api-surface`. I took the non-public module instead. `check:api-surface`, `check:export-origins` and `check:declaration-map` are green with zero changes to their artifacts. 3. **`'*'` on a dimension: measured, and refused.** Readings come from `POST /api/v1/analytics/dataset/query` with today's spec, through the real REST route, a real `AnalyticsService` and a real better-sqlite3 `SqlDriver`, using a temporary probe test that was deleted afterwards (the tree is clean). The ObjectQL-strategy column bridges `executeAggregate` straight to `SqlDriver.aggregate`, not through the ObjectQL engine. | dataset member `field` | native-SQL strategy | ObjectQL strategy | |---|---|---| | dimension `'*'` | 500 `DATABASE_ERROR` (`SELECT * AS ... GROUP BY *`) | 500 `DATABASE_ERROR` (`groupBy: ['*']`) | | dimension `''` | 500 | 500 | | count measure `''` | 200 (SQLite accepts the `COUNT()` it compiled to) | 500 | | count measure `'*'` (control) | 200 | 200 | | sum measure `'*'` | 500 (`SUM(*)`) | 500 | | padded `' amount'` (sum) | 200 | 200 | | padded dimension `' industry'` | 200 | 200, **dimension column silently missing from the rows** | | expression `amount * 2` | 403 `PERMISSION_DENIED` (PR #21190's door) | 403 | A `'*'` dimension is never answered: it compiles to grouping by every column, which is not an axis. So the dataset dimension takes the same path pattern without the `'*'` arm. That is one pattern source with one stated restriction, not a second pattern; the pin proves the dimension's published `pattern` equals the cube's with only the `\*|` arm removed. ⚠ **Flagged, not silently chosen.** The triage line reads "exactly the `CUBE_MEMBER_SQL` accept set" for both keys. This PR narrows the dimension one step further, as the dispatch's mechanism item 3 invited and the card's own pin wording ("`*` (on a measure)") suggests. The cube `DimensionSchema.sql` still admits `'*'`, per ruling D's execution parameters, and is untouched here. 4. **Census, repo-wide, with a lit control.** A scan of every `field:` value in tracked files that mention a dataset and `dimensions`/`measures`, including `packages/**` tests, `content/docs/**` and `skills/**`. - **Lit control.** Column-reference values hit in every area, and the scanner sees them: `examples` 116, `content` 88, `skills` 15, `platform-objects` 6, `service-analytics` 587, `spec` 423, `lint` 282, `rest` 92. - **Non-column dataset `field`s found:** only the fixtures that PR #21190 wrote on purpose to drive its door: `rest` `analytics-16019-driver-declared-fault.test.ts` (`translate(...)`, `lower(name)`) and `service-analytics` `inline-dataset-field-admission-door.test.ts` (an expression constant and a template). Both were re-pinned (next section). - **Zero** in the examples, `platform-objects`, the hand-written docs and the published skills. Every other non-column literal the scan caught is not a dataset field (driver-sql and protocol prose, filter paths, `$field` prose in a skill). - **The build as judge.** The full suites of `spec`, `lint`, `service-analytics`, `metadata-protocol` and `rest` are green on the narrowed contract. - Nothing in `skills/**` teaches an expression `field`, so no Tier H follow-up is owed. 5. **D2 for one sub-shape, D3 for the rest** (corrected in round 2; the first round said "D3 only"). - **Expressions: D3 only.** A stored dataset with an expression `field` already answered 403 at the dataset door. On the REST route it is now refused one step earlier, at the route's own `DatasetSchema` parse, which the route runs on the inline and the saved branch alike. An expression has no mechanical rewrite. - **Correction.** This item said that no stored row worked before. That is false by this PR's own probe table: a `count` measure with `field: ''` answered 200 on SQLite's native path, and the door never judged it. - **The repair.** That sub-shape gets the D2 conversion `dataset-count-measure-empty-field-removed`, which every stored-row rehydration seam replays (`applyConversionsToStoredItem`, e.g. `metadata-protocol`'s `convertStoredItemDetailed`). The runtime door is still reachable for a dataset handed to `queryDataset` unparsed: the build probe's dashboard-widget path (`metadata-protocol` `build-probes.ts`) passes the stored row as read. ## Fixture triage (two consumer tests the narrowing turns red; both re-pinned, not loosened) - **`service-analytics` `inline-dataset-field-admission-door.test.ts`** built its expression fixtures with `DatasetSchema.parse`, which now refuses them. The fixtures are now built UNPARSED, the shape a pre-narrowing stored row has, through `storedDatasetWith`. The controls still parse. One new case asserts that the contract refuses both fixtures at `dimensions.0.field` / `measures.0.field`. All 4 provider tiers x 2 strategies of the 403 door pins are unchanged and green. - **`rest` `analytics-16019-driver-declared-fault.test.ts`.** The route parses every dataset first, so its inline and saved expression cases now answer `400 VALIDATION_FAILED`, where they answered `403 PERMISSION_DENIED`. - Both cases are re-pinned to the `400`, plus `invalid_format` at the path inside `detail`, the driver never called, and no expression text echoed. - The statement-leak check now reads SQL keywords as the strategies emit them (upper case), because the prescription itself says "Group by the column itself" in prose. It was case-insensitive before, when the body carried no prose. - The docblocks state the new layering and the reverse-verification direction (measured below). ## Tests All runs are at head `0d5e446e` (after merging `origin/main` at `3ddd3d0c`) unless stated otherwise. Filter direction: each package's own suite, no consumer sweep. - **`@objectstack/spec`** - `vitest run --project local`: 597 files, 17468 passed, 1 todo. - The new `src/ui/dataset-field-column-reference.test.ts` has 11 cases. - The cube precedent pin `cube-member-sql-column-reference.test.ts` stays green, with its `'*'`-on-a-cube-dimension case unchanged. - **`@objectstack/service-analytics`** `vitest run`: 162 files, 3741 passed, 45 skipped. - **`@objectstack/lint`** `vitest run`: 119 files, 5502 passed. - **`@objectstack/metadata-protocol`** `vitest run`: 200 files passed, 3 skipped; 2973 tests passed, 19 skipped. - **`@objectstack/rest`** `vitest run --project local`: 257 files, 4858 passed, 316 skipped. - **Typecheck:** `pnpm --filter PKG typecheck` exit 0 for `@objectstack/spec` (`tsc` + `check:scripts-typecheck` + `check:test-typecheck`), `@objectstack/service-analytics` (its `tsconfig` includes all of `src`, so the edited `__tests__` file is in the program) and `@objectstack/rest` (`tsc` + `check:test-typecheck`). - **`@objectstack/spec` `--project repo`, the relevant files:** `step18-rationale-merge`, `conversions-major18-merge`, `liveness/evidence`, `liveness/proof-registry`, `retired-key-migrate-sentence`, `file-description`, `root-index`, `export-list`, `category-title`, `schema-tree-freshness`, `escape-mdx` and `references-banner`. 12 files, 294 passed. - **Lint, a declared narrowing.** `eslint --no-inline-config --format json` over the 8 changed lintable files at `0d5e446e` gave 8 files, 0 errors, 0 warnings. - **Population:** from `eslint.config.mjs`, the `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block. The other changed files are `.md`, `.mdx` and `.json`. - **File count:** read from the JSON output. - **Invariance:** the config never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move a verdict on an untouched file. - The whole-repo `pnpm lint` is CI's. ## Round 2 readings (final head `fc4e91c0`; `origin/main` `3dc33b2d` merged through `os-regen-merge.sh`) These readings were taken after a container restart. The restart cut a first round-2 gate run short at `2e57fa29`. Every reading below was re-taken at `fc4e91c0`, the pushed head. - **Build.** `turbo run build` over the closures of spec, cli, service-automation, metadata-protocol, rest and client-react: 59/59 tasks. - **`@objectstack/spec`** - `vitest run --project local`: 597 files, 17465 passed, 1 todo. The counts moved with `main`'s merge. - That includes the new `src/conversions/dataset-count-measure-empty-field-removed.test.ts`, the table-wide fixture replay in `conversions.test.ts` and `retired-after.census.test.ts`. - `--project repo`: the same 12 relevant files as round 1 (`step18-rationale-merge` and `conversions-major18-merge` among them), 294 passed. - **Consumers that read the conversion table.** - `@objectstack/cli` `meta.report-order.test.ts` (unit tier): 16 passed. - `@objectstack/service-automation` `decision-overlapping-edge-conditions.pin.test.ts`: 22 passed. - `@objectstack/metadata-protocol` full suite (it hosts the stored-row seam): 200 files passed and 3 skipped; 2973 tests passed and 19 skipped. - **Not re-run this round.** `rest`, `service-analytics` and `lint`: this round's diff does not reach them (spec only), and their round-1 readings stand. - **Lint, a declared narrowing.** `eslint --no-inline-config --format json` over the 10 changed lintable files at `fc4e91c0` gave 10 files, 0 errors, 0 warnings. The population and invariance are as in round 1. - **Gates.** `dispatch-gates --commands` at `fc4e91c0` derived the same 115 families. All were run with exit codes recorded, and `--ran` reconciled them as "115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN". - `check:generated`: 15/15 up to date. - `check:migration-registry`: exit 0. - `check:adr-0087-registration`: it reads `registered dataset-member-field-expression-refused, dataset-count-measure-empty-field-removed`, both new here. - `check:skill-examples` and `check:dual-build-cjs-loads` both exited 0 this time. Both had been NOT MEASURED in round 1 for want of built packages. ## Ablations Each ablation ran from committed state, disk-verified through `scripts/ablation-replace.mjs`. Each restore was proven by blob hash equal to HEAD, an empty `git diff HEAD`, and a clean status. The predicted direction was "turns red" in all four, and that is what was observed. | leg | mutation | under mutation | restored | |---|---|---|---| | A1 | measure `field` pattern admits anything | new pin: 7 failed / 4 passed (every measure refusal, door, pattern and defineStack case red; the dimension and accept cases green) | 11 / 11 | | A2 | dimension takes `ANALYTICS_COLUMN_REFERENCE` (admits `'*'`) | 3 failed / 8 passed (the dimension refusal case on `'*'` and the two pattern pins) | 11 / 11 | | A3 | `ANALYTICS_COLUMN_PATH` admits anything, then `@objectstack/spec` rebuilt | `ablation-dist-preflight`: marker in 18 built files. `rest`: the 2 re-pinned cases red, `expected 403 to be 400` (the route's parse passes the expression to the service door, the direction its docblock predicts); 6 green. `service-analytics` door test: the contract case red, 20 green | rebuilt; marker absent from all 230 built files; tree clean | | A4 (round 2, at `fc4e91c0`) | the new conversion matches nothing (its `field !== ''` guard reads a value no row carries) | 2 failed / 239 passed: the `conversions.test.ts` fixture pin `dataset-count-measure-empty-field-removed: before → after, emits 2 notice(s)` and the stored-row pin are red; the controls are green | blob `75f4166c` == HEAD; `git diff HEAD` empty; status clean | No ablation file is left in the tree. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, run at `0d5e446e` with no paths, derived 115 families. - All 115 were run, each with its exit code recorded before any pipe. - `--ran` reconciled them: "115 derived, 114 run, 1 NOT-MEASURED, 0 UNRUN", every row carrying its recorded exit code. - **NOT MEASURED: `check:dual-build-cjs-loads`.** Reason: it exited 3 (`PREREQUISITE NOT MET`) because 44 packages outside this diff's build closure have no `dist/`, and only a full monorepo build supplies them. This diff changes no package entry, export map or build config. CI runs it on the full build. - **`check:skill-examples`** first exited 3 for want of a built `@objectstack/client-react`. After building that package it exited 0 at `0d5e446e`: 259 examples type-check. - Every other family exited 0. That includes: - `check:generated`: all 15 artifacts up to date against a stamp-matched dist. - `check:adr-0087-registration`: at the first round's head it read `registered dataset-member-field-expression-refused (new here)`. - `check:changeset-no-major` and `check:empty-changeset`. - `check:liveness`, `check:migration-registry` and `check:doc-authoring`. - `check:cross-package-test-inputs` and `check:nul-bytes`. ## Acceptance notes - **File surface, declared.** The claim named `dataset.zod.ts`, `analytics.zod.ts` "only as far as sharing needs", the ADR-0087 entry and registry, the retirement kit, pins and one changeset. Four paths go beyond that, each for the stated reason: - The new non-public module `data/analytics-column-reference.ts`: the sharing change itself, which avoids a public export. - The two consumer test files the narrowing turned red: fixture triage, re-pinned rather than loosened. - One bullet in `content/docs/data-modeling/analytics.mdx`: the skill's docs row. - **The REST door's answer moves from 403 to 400 for an expression `field`.** The route's existing `DatasetSchema.parse` refuses it first, as `VALIDATION_FAILED` naming the path. The changeset says so. The service door's 403 is unchanged. - **Studio producer (objectui, outside this repo).** - **What happens.** `DatasetDefaultInspector.tsx` at the pinned `31971ff1e` seeds a new dimension row as `{ name: '', field: '', type: 'string' }` and a new measure row as `{ name: '', aggregate: 'sum', field: '' }`. A plain count measure left with a blank Field box is saved as `field: ''`. That parsed before. Its query answered 500 on the ObjectQL path (the SQLite native path happened to accept `COUNT()`). - **After this PR** a new save of that shape is refused at `measures.N.field`, with the prescription to omit the key. A row already stored that way is repaired on load by the D2 conversion `dataset-count-measure-empty-field-removed`. - **The producer half** is to omit `field` when the box is blank. It is reported to the seat, not edited here. - **Measured, not acted on.** Neither item is filed from here; both are in the report. - A `sum` (or any non-count aggregate) over `'*'` parses on a dataset measure and answers 500 on both strategies. That is the count-only `'*'` boundary the `analytics_cube` ledger already assigns to #21000's family. - A padded dimension `field` answered 200 with the dimension column missing on the ObjectQL bridge. It is now refused at parse. A stored padded row would still reach that path through the build probe; no producer of one is known. Authored by `session_01UtnxvdiN376GF3sgXwAw4d` (rounds 1 and 2). --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a23be74 commit 434c6c7

14 files changed

Lines changed: 949 additions & 38 deletions
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an analytics dataset dimension's and measure's `field` is a column reference — a SQL expression there is refused at parse, as it already is on the cube members a dataset compiles to (#21220)
6+
7+
Clause-②: yes (narrowing)
8+
9+
**BREAKING** — shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner, the `(narrowing)` arm above and the ADR-0087 disposition below,
12+
never by the level).
13+
14+
`DatasetDimensionSchema.field` and `DatasetMeasureSchema.field` — the `field` of
15+
every entry in an ADR-0021 dataset's `dimensions` and `measures` — admit a column
16+
reference only: a field of the dataset's object (`amount`), or a relationship path
17+
of bare identifiers ending in one (`account.amount`, `account.owner.region`); a
18+
measure also admits `'*'` for a count, and a count may still omit `field`. Any
19+
other value — an arithmetic, an aggregate, a `CASE`, a subquery, a function call,
20+
a quoted or `$`-prefixed spelling, a padded or empty string, a broken path — is
21+
refused at `dimensions.N.field` / `measures.N.field` with a prescription, and so
22+
is `'*'` on a dimension.
23+
24+
Why: the dataset layer was declared to take no raw SQL (ADR-0021 "zero raw SQL /
25+
zero raw expressions") and `field` was documented as a field or a relationship
26+
path, but it was a bare string and parsed anything. The analytics dataset door
27+
already refused an expression `field` on every query (`PERMISSION_DENIED` / 403,
28+
inline or saved), so such a dataset could be saved and never answered — declared,
29+
never enforced (ADR-0049). That door never judged an empty `field`: it skips one,
30+
which is how a `count` measure with `field: ''` kept counting rows on SQLite's
31+
native-SQL path (the D2 repair below). The accept set is the one the cube members a dataset
32+
compiles to already hold: the dataset compiler copies `field` into the member's
33+
`sql` verbatim, and both now read one shared declaration. `'*'` is refused on a
34+
dimension because grouping by every column is no axis — both analytics strategies
35+
answered such a dimension `500`. The rule is a `pattern` in the published JSON
36+
Schema too, so a document validated against `json-schema/**` is judged as the
37+
parse judges it.
38+
39+
## FROM → TO
40+
41+
```
42+
FROM defineDataset({ name: 'task_metrics', label: 'Task Metrics', object: 'task',
43+
dimensions: [{ name: 'priority', field: 'priority' }],
44+
measures: [
45+
{ name: 'task_count', aggregate: 'count', field: '' },
46+
{ name: 'done_points', aggregate: 'sum',
47+
field: "CASE WHEN status = 'done' THEN points ELSE 0 END" },
48+
] })
49+
-> parsed; the dataset door refused the expression on every query
50+
TO -> ZodError at measures.0.field and measures.1.field (invalid_format):
51+
`measures[].field` is a column reference: a field of the dataset's object …
52+
53+
defineDataset({ name: 'task_metrics', label: 'Task Metrics', object: 'task',
54+
dimensions: [{ name: 'priority', field: 'priority' }],
55+
measures: [
56+
{ name: 'task_count', aggregate: 'count' },
57+
{ name: 'done_points', aggregate: 'sum', field: 'points', filter: { status: 'done' } },
58+
] })
59+
```
60+
61+
A conditional count or sum is a measure with its own structured `filter`; a
62+
ratio, sum, difference or product of measures is `derived: { op, of: [...] }`
63+
over measures named in the same dataset. **Mind the scale:** a `derived` ratio is
64+
a 0–1 fraction, so an expression that multiplied by 100 returned percentage
65+
points — pair the ratio with a `%` numeral pattern. A dimension that bucketed a
66+
column with an expression has no expression form: group by the column itself, or
67+
keep the bucket as a field of the object and name that field.
68+
69+
**The one-line fix:** parse each dataset; every refusal at `…field` is one member
70+
to change — name the column, omit `field` on a plain count (never `field: ''`),
71+
or move the computation to a measure `filter` or a `derived` measure. The one
72+
mechanical case is done for you: `os migrate meta --from 17` lists, and every
73+
stored-row rehydration replays, the D2 conversion
74+
`dataset-count-measure-empty-field-removed`, which drops a `count` measure's empty
75+
`field` (it still counts rows). Nothing else has a mechanical rewrite.
76+
77+
**What an author who still writes it sees.** `DatasetSchema`, `defineStack({
78+
datasets })` (`STACK_SCHEMA_INVALID` / 422), the `dataset` write door and
79+
`POST /api/v1/analytics/dataset/query` (which parses every dataset it is handed,
80+
inline or saved, and now answers `400 VALIDATION_FAILED` at the path where it
81+
answered `403 PERMISSION_DENIED` before) refuse the member at its `field` path
82+
with the prescription. `tsc` does not: the key's type is still `string`.
83+
84+
## The retirement kit
85+
86+
- **Schema.** `ui/dataset.zod.ts` holds both keys to the pattern; the pattern is
87+
declared once, in the non-public `data/analytics-column-reference.ts`, and the
88+
cube layer's `CUBE_MEMBER_SQL` is that same `RegExp`. A dimension's pattern is
89+
the same column path without the `'*'` arm. A column reference parses
90+
byte-identically to before.
91+
- **ADR-0087.** D2 carries the one lossless repair: the conversion
92+
`dataset-count-measure-empty-field-removed` (`retiredFromLoadPath`, so an author
93+
is refused at parse while stored rows and `os migrate meta` replay it) drops a
94+
`count` measure's `field: ''`, which compiles to `COUNT(*)` without it. The D3
95+
entry `dataset-member-field-expression-refused`, linked to that conversion and
96+
with its step-18 rationale fragment, carries the rest — a non-count measure or a
97+
dimension with `''` and every expression have no mechanical rewrite into a
98+
column. No `RETIRED_KEYS_BY_MAJOR` row: no key left the shape, so the
99+
authorable-surface, api-surface and JSON-schema manifest ratchets are
100+
unchanged.
101+
- **Liveness.** The `dataset` ledger rows `dimensions.field` and
102+
`measures.field` stay `live`, re-verified, with the narrowing recorded.
103+
- **Docs.** The `ui/dataset` reference page is regenerated.
104+
- **Runtime.** Unchanged: the analytics dataset door's refusal stays as defence
105+
in depth for a dataset that reaches the service without meeting the parse — a
106+
row stored before this change, which the build probe hands over as read.
107+
108+
## Reach, measured
109+
110+
- This repository: no authored dataset carries a non-column `field` — the
111+
examples, `platform-objects`, the hand-written docs and the published skills
112+
were read. Two test fixtures that sent an expression `field` on purpose were
113+
re-pinned: the service door's test builds them unparsed, and the REST route's
114+
test now expects the route's `400`.
115+
- Studio's dataset inspector (objectui) seeds a new dimension or measure row with
116+
`field: ''`. A plain count saved that way parsed before; its query answered
117+
`500` on the ObjectQL path, while SQLite's native-SQL path accepted the
118+
`COUNT()` it compiled to. A row already stored that way is repaired on load by
119+
the D2 conversion above. A NEW save of that shape is refused at the save door
120+
with the prescription to omit the key, because the write path parses with the
121+
current schema and replays no conversion; the producer-side change is
122+
objectui's.
123+
- Out-of-repo authored datasets: NOT MEASURED.
124+
125+
<!-- adr-0087: registered dataset-member-field-expression-refused, dataset-count-measure-empty-field-removed -->

‎content/docs/data-modeling/analytics.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export default defineStack({
8181

8282
- **No raw SQL, no hand-authored joins.** The author declares *which*
8383
relationships to include; the compiler derives the join from the object graph.
84+
- **`field` is a column reference.** A dimension's `field` names a field of the
85+
base object or a `relationship.field` path; a measure's may also be `'*'` (or
86+
be omitted) for a count. A SQL expression there is refused at parse: a
87+
conditional count or sum is a measure with its own `filter`, and a ratio is a
88+
`derived` measure.
8489
- **Metric certification** — a `certified` flag that marks a measure as a
8590
human-blessed governance checkpoint — is a design goal of ADR-0021 but is **not
8691
yet implemented**; `DatasetMeasureSchema` has no `certified` field today.

‎content/docs/references/ui/dataset.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const result = DatasetSchema.parse(data);
7272
| :--- | :--- | :--- | :--- |
7373
| **name** | `string` | ✅ | Dimension name — referenced by presentations |
7474
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
75-
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path |
75+
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path. A column reference, never a SQL expression. |
7676
| **type** | `Enum<'string' \| 'number' \| 'date' \| 'boolean' \| 'lookup'>` | optional | |
7777
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
7878

@@ -83,7 +83,7 @@ const result = DatasetSchema.parse(data);
8383
| **name** | `string` | ✅ | Measure name — e.g. "revenue"; defined once |
8484
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
8585
| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | optional | Aggregation (sum/avg/count/...); omit when `derived` is set |
86-
| **field** | `string` | optional | Aggregated field; optional for count(*) |
86+
| **field** | `string` | optional | Aggregated field: a base field, a relationship path, or "*"; optional for count(*). Never a SQL expression. |
8787
| **filter** | `any` | optional | |
8888
| **format** | `string` | optional | Numeral pattern for a NUMERIC measure — grouping, decimals, percent; e.g. "0,0.00", "0.0%". An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued measure never reads a date pattern: `"YYYY-MM-DD"` renders that arm's default face. A date or datetime value reads `format` as a display style — `short` or `relative`, honoured on both. |
8989
| **currency** | `string` | optional | Display currency code (ISO 4217) |
@@ -108,7 +108,7 @@ const result = DatasetSchema.parse(data);
108108
| :--- | :--- | :--- | :--- |
109109
| **name** | `string` | ✅ | Dimension name — referenced by presentations |
110110
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
111-
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path |
111+
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path. A column reference, never a SQL expression. |
112112
| **type** | `Enum<'string' \| 'number' \| 'date' \| 'boolean' \| 'lookup'>` | optional | |
113113
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
114114

@@ -124,7 +124,7 @@ const result = DatasetSchema.parse(data);
124124
| **name** | `string` | ✅ | Measure name — e.g. "revenue"; defined once |
125125
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
126126
| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | optional | Aggregation (sum/avg/count/...); omit when `derived` is set |
127-
| **field** | `string` | optional | Aggregated field; optional for count(*) |
127+
| **field** | `string` | optional | Aggregated field: a base field, a relationship path, or "*"; optional for count(*). Never a SQL expression. |
128128
| **filter** | `any` | optional | |
129129
| **format** | `string` | optional | Numeral pattern for a NUMERIC measure — grouping, decimals, percent; e.g. "0,0.00", "0.0%". An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued measure never reads a date pattern: `"YYYY-MM-DD"` renders that arm's default face. A date or datetime value reads `format` as a display style — `short` or `relative`, honoured on both. |
130130
| **currency** | `string` | optional | Display currency code (ISO 4217) |

‎packages/rest/src/analytics-16019-driver-declared-fault.test.ts‎

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@
3131
* dataset whose `field` is not a column reference is refused too, and a saved
3232
* plain-column dataset is still served.
3333
*
34+
* [#21220] The contract now refuses that `field` text one step earlier, at parse:
35+
* `DatasetSchema` holds a dimension's and measure's `field` to a column reference,
36+
* and this route parses every dataset it is handed — inline and saved alike —
37+
* before calling `queryDataset`. So on THIS route both cases are answered by the
38+
* route's own validation, `400 VALIDATION_FAILED` naming the path
39+
* (`dimensions.N.field`), still before any strategy or driver runs and still
40+
* without echoing the expression. The service door's `403 PERMISSION_DENIED`
41+
* stays as defence in depth for a dataset that reaches `queryDataset` without
42+
* that parse, and is pinned where it is reachable: in `service-analytics`'s
43+
* `inline-dataset-field-admission-door.test.ts`.
44+
*
3445
* The second block pins the ordering the ruling's execution notes name. A
3546
* DECLARED fault is withheld even when its text is one the heuristic does not
3647
* know (declared wins); an UNDECLARED knex-shaped fault still falls to the
@@ -51,9 +62,11 @@
5162
* first) and only that case goes RED (`ANALYTICS_QUERY_FAILED` in place of the
5263
* producer's code); the neighbouring "phrase the heuristic does not know"
5364
* case stays GREEN, which is precisely why it could not stand in for this one.
54-
* The first block's leg: remove the caller-content gate and the expression
55-
* reaches the real driver again — the refusal flips from `403 PERMISSION_DENIED`
56-
* to the `500 DATABASE_ERROR` relay this file once asserted.
65+
* The first block's leg (since #21220): admit anything in the contract's
66+
* column-reference pattern and the route's parse passes the expression on — the
67+
* refusal flips from `400 VALIDATION_FAILED` to the service door's
68+
* `403 PERMISSION_DENIED`; remove that door as well and it reaches the real driver,
69+
* the `500 DATABASE_ERROR` relay this file once asserted.
5770
*/
5871

5972
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
@@ -222,20 +235,28 @@ describe('[#16019] a driver fault on the raw-SQL path reaches the caller by decl
222235
await driver.disconnect();
223236
});
224237

225-
it('[#21177] a caller-supplied dimension-field expression is refused 403 PERMISSION_DENIED at the door — before any strategy or driver runs', async () => {
238+
it('[#21177 / #21220] a caller-supplied dimension-field expression is refused 400 VALIDATION_FAILED at the route\'s parse — before any strategy or driver runs', async () => {
239+
const execute = vi.spyOn(driver, 'execute');
226240
const route = buildRoute(async () => realAnalytics(driver));
227241
const res = await post(route, { dataset: expressionDataset, selection: { measures: ['account_count'], dimensions: ['folded_name'] } });
228242

229-
expect(res.statusCode).toBe(403);
230-
expect(res.body.code).toBe('PERMISSION_DENIED');
231-
// Caller text that names no attributable field is refused at the door,
232-
// not evaluated — the driver never ran, so there is no driver fault line.
243+
expect(res.statusCode).toBe(400);
244+
expect(res.body.code).toBe('VALIDATION_FAILED');
245+
// The contract's refusal, at the expression's own path (`detail` is the
246+
// parse's issue list, cut at 1000 characters — read, not re-parsed).
247+
expect(res.body.detail).toMatch(/"code":\s*"invalid_format"/);
248+
expect(res.body.detail).toMatch(/"path":\s*\[\s*"dimensions",\s*1,\s*"field"\s*\]/);
249+
// Caller text that names no column is refused before it is evaluated — the
250+
// driver never ran, so there is no driver fault line.
251+
expect(execute).not.toHaveBeenCalled();
233252
expect(warned.filter((m) => m.includes('[sql-driver] DATABASE_ERROR'))).toHaveLength(0);
234-
// ⛔ The refusal names the member and its object (both the caller's own
235-
// input), never the caller's `field` expression text or the compiled statement.
253+
// ⛔ The refusal names the path, never the caller's `field` expression text
254+
// or a compiled statement. The statement check reads the keywords as the
255+
// strategies emit them (upper case): the prescription itself tells the
256+
// author, in prose, to "Group by the column itself".
236257
const body = JSON.stringify(res.body);
237258
expect(body).not.toMatch(/translate/i);
238-
expect(body).not.toMatch(/SELECT|GROUP BY/i);
259+
expect(body).not.toMatch(/\bSELECT\b|\bGROUP BY\b/);
239260
});
240261

241262
it('POSITIVE CONTROL: a legitimate dataset on declared fields → 200 with rows', async () => {
@@ -248,10 +269,11 @@ describe('[#16019] a driver fault on the raw-SQL path reaches the caller by decl
248269
});
249270

250271
// [#21177] The route's SAVED branch: `body.datasetName` loads the dataset from
251-
// metadata and calls the same `queryDataset`, so the door judges a saved
252-
// dataset's own `field` text exactly as it judges an inline one. The expression
253-
// here is one SQLite can run, so without the door it would be served (200).
254-
it('[#21177] a SAVED dataset (body.datasetName) whose dimension field is not a column reference is refused 403 PERMISSION_DENIED — nothing executed', async () => {
272+
// metadata and calls the same `queryDataset`. [#21220] It parses the loaded
273+
// row through `DatasetSchema` first, exactly as it parses an inline one, so a
274+
// row stored before the contract narrowed is refused there. The expression
275+
// here is one SQLite can run, so without a refusal it would be served (200).
276+
it('[#21177 / #21220] a SAVED dataset (body.datasetName) whose dimension field is not a column reference is refused 400 VALIDATION_FAILED — nothing executed', async () => {
255277
const saved = {
256278
...dataset,
257279
name: 'account_metrics_saved_expr',
@@ -261,11 +283,12 @@ describe('[#16019] a driver fault on the raw-SQL path reaches the caller by decl
261283
const route = buildRoute(async () => realAnalytics(driver), [saved]);
262284
const res = await post(route, { datasetName: saved.name, selection: { measures: ['account_count'], dimensions: ['lowered_name'] } });
263285

264-
expect(res.statusCode).toBe(403);
265-
expect(res.body.code).toBe('PERMISSION_DENIED');
286+
expect(res.statusCode).toBe(400);
287+
expect(res.body.code).toBe('VALIDATION_FAILED');
288+
expect(res.body.detail).toMatch(/"code":\s*"invalid_format"/);
289+
expect(res.body.detail).toMatch(/"path":\s*\[\s*"dimensions",\s*0,\s*"field"\s*\]/);
266290
expect(execute).not.toHaveBeenCalled();
267291
const body = JSON.stringify(res.body);
268-
expect(body).toContain('lowered_name');
269292
expect(body).not.toMatch(/lower\(name\)/i);
270293
});
271294

0 commit comments

Comments
 (0)