Repository navigation
feat(spec)!: an analytics cube member's sql is a column reference, and the showcase done rate moves to its dataset (#20943) - #20998
Conversation
…ne rate moves to its dataset Narrow MetricSchema.sql / DimensionSchema.sql to a column reference (a bare identifier or a dotted identifier path, and '*' on a count measure). Any SQL expression is refused at parse with a prescription naming the ADR-0021 dataset form. The showcase cube's done_rate expression moves to the task dataset as a filtered count over the unfiltered count. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…ma carries it as a pattern The accept set follows the ruling's execution parameters: an identifier, a dotted identifier path, and '*', on a measure and a dimension alike. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…be-sql-identifiers
…, and regenerate the registry and reference docs The D3 semantic entry and its step-18 rationale fragment, the metric-filters fragment no longer naming a metric's own sql expression as a live channel, the regenerated migration registry, and the analytics reference page. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…arrowing, and the README stops prescribing a sql expression Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…be-sql-identifiers
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b1178fecb78741ce96360f0cc1c7ecd023cbb108 && git checkout b1178fecb78741ce96360f0cc1c7ecd023cbb108
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9b0de7de73699771b69649bf7b507fbd2a842260 ac70996345fd12a48696af01fbbb319bd593bf13 && git checkout -B drift-repro 9b0de7de73699771b69649bf7b507fbd2a842260 && git merge --no-ff ac70996345fd12a48696af01fbbb319bd593bf13
node scripts/docs-audit/affected-docs.mjs --json 9b0de7de73699771b69649bf7b507fbd2a842260
|
…ne_rate labels at birth The two dataset measures the done rate moved into are declared surface under the i18n coverage ratchet, so they carry en and zh-CN bundle entries; the showcase's untranslated count is back at its baseline. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsRead against Accept set — right. Refusal and prescriptions — right, and every form they name works on Doors — right. Showcase migration — right. ADR-0087 — right, and generator-produced where the generator owns it. Liveness ledger — one row earned, one row's note false. Both rows stay Other text, tested. The schema comment's " Check-runs on ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Adopted and posted by
Generated by Claude Code |
…mits, and the schema comment names the ObjectQL refusal's real partition The dimensions.sql note said '*' is refused at parse; the schema admits it on a dimension, as on any measure, and the measures.sql note no longer limits it to a count. The count-only boundary is #21000's. The schema comment now says the ObjectQL path refuses only the number / string / boolean partition. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsDelta re-review of the cut round the adoption The delta is exactly the cut, and nothing else moved — right. One commit, two files, 6 insertions / 3 deletions: the two
Schema comment — true on Residual over-broad text, unchanged by the delta, the same reading the prior record made — not blocking. Every other Prior judgments the delta cannot reach — re-read, unchanged. Accept set (right: identifier half byte-equal to PR body at this head — every sentence re-read; the bytes are the ones the prior record read (12555, unchanged). "What changes", "The fork clause", "The measure-level filter", "Ablations", "Acceptance notes": true against the tree. "Liveness … stay Check-runs on ② Semver levelUnchanged by the delta, and still right. ③ Boundary flags
Implemented-by: VERDICT: PASS Adopted and posted by
Generated by Claude Code |
… answers measures declared number as numbers (objectstack-ai#20889) (objectstack-ai#21040) Fixes objectstack-ai#20889 Clause-②: yes (widening) ## What changed On PostgreSQL, the analytics native-SQL path answered every measure its response declares `number` as a string: `count: "2"`, `sum: "500.000000000000000000000000000000"`. SQLite answered numbers. `driver-sql`'s own `aggregate()` has presented these answers since objectstack-ai#20335, but through a private table and presenter that `service-analytics` could not reach. The seat's route ruling (`5922780640`) moves that one rule into `@objectstack/core`, and both faces now call it. - **`@objectstack/core`** (new `src/utils/aggregate-answer.ts`, one export line in `index.ts` beside `compensatedSum`): - `AGGREGATE_ANSWER_KIND`, with its precision-policy docblock, moved from `sql-driver.ts`. The text is byte-identical apart from the `export` keyword; the proof is below. - `presentAsNumber(value)`, the body of `presentReadValue`'s `'number'` arm as a named function. A string `Number()` reads as a number becomes that number. Any other value is returned as given. - **`@objectstack/driver-sql`**, `sql-driver.ts`, the two ruled regions only: - the `AGGREGATE_ANSWER_KIND` definition becomes an import, and a pointer comment marks where it was; - `presentReadValue`'s `'number'` arm returns `presentAsNumber(value)`. The import is a separate statement beside the `AggregationFunction` import (inserted after base line 23; line 27 at HEAD). It does NOT join the `@objectstack/core` import block at base lines 94-100, which PR objectstack-ai#20988 rewrites. That hunk starts at base line 91, 67 lines below the insertion. No behaviour change: the full driver-sql suite is green on SQLite and PostgreSQL (below). - **`@objectstack/service-analytics`**, `NativeSQLStrategy.execute` only. After the raw statement runs, each measure column is presented by the measure's DECLARED aggregate function (`lookupMember(cube, m, 'measure').type`), never by whether a value looks numeric: - `count`, `count_distinct`, `sum` and `avg` (the table's `'number'` kind) always take `presentAsNumber`; - `min` / `max` (the `'column'` kind) take it only when `declaredFieldType(object, column)` is in `NUMERIC_VALUE_TYPES`. That is the rule `driver-sql`'s `readPresentationKind` applies to a declared numeric column. A host that cannot answer, or a relationship-path column, leaves the value as given; - expression metric types (`number` / `string` / `boolean`) are left as they are (ruling Q3). One point covers both doors: the cube read (`POST /api/v1/analytics/query`) and the dataset door (`POST /api/v1/analytics/dataset/query`, through `DatasetExecutor`). - **`.changeset/20889-analytics-native-measure-number.md`**: `@objectstack/core` minor, `@objectstack/driver-sql` and `@objectstack/service-analytics` patch, `Clause-②: yes (widening)` for the two new root exports. ## Per-measure answers, before and after Fixture: `text` category; `rating` stars (an integer column); `number` amount and frac (the exact-decimal column); `currency` price. Group `a` holds 2 rows. - **Before, PostgreSQL:** phase 0's reading at base `7fa67dada3`, through `AnalyticsService.query` (what the route relays verbatim). The red pins at `719b98795e` read the same strings. - **After:** read at `e827c237d1` through the real `dispatcher-plugin` mount of `POST /api/v1/analytics/query`, by a scratch probe that was deleted after the run. | function | column (declared type) | SQLite before | SQLite after | PostgreSQL 16.13 before | PostgreSQL 16.13 after | |:--|:--|:--|:--|:--|:--| | `count` | `*` | `2` | `2` | `"2"` | `2` | | `count_distinct` | note (`text`) | `2` | `2` | `"2"` | `2` | | `sum` | stars (`rating`, integer) | `7` | `7` | `"7"` | `7` | | `avg` | stars (`rating`, integer) | `3.5` | `3.5` | `"3.5000000000000000"` | `3.5` | | `sum` | amount (`number`, decimal) | `500` | `500` | `"500.000000000000000000000000000000"` | `500` | | `avg` | amount (`number`, decimal) | `250` | `250` | `"250.000000000000000000000000000000"` | `250` | | `min` | amount (`number`) | `100` | `100` | `"100.000000000000000000000000000000"` | `100` | | `max` | price (`currency`) | `20.5` | `20.5` | `"20.500000000000000000000000000000"` | `20.5` | | `max` | note (`text`), the control | not read at base | `"y"` | not read at base | `"y"` (left as the client gave it) | | `sum` | amount over `9007199254740993` | `9007199254740992` | `9007199254740992` | `"9007199254740993.000000000000000000000000000000"` | `9007199254740992` | | dataset `row_count` (`count`) | `*` | `2` | `2` | `"2"` | `2` | | dataset `filtered_count`, a group the statement answered | `*` | `1` | `1` | `"1"` | `1` | | expression measure, type `number` | `SUM(amount) / 2` | `250` | `250` | `"250.000000000000000000000000000000"` | unchanged, left as is (ruling Q3) | `fields[]` declared `number` for every measure on both dialects, before and after. On the ObjectQL face of the same route, every answer was already a number on both dialects, and it is unchanged. ## Move proof `move-proof.py` (scratch) reads the block from BASE `2f2fa11d75`'s `sql-driver.ts` and from HEAD's `aggregate-answer.ts`: ```text table+docblock: base lines 57, core lines 57; differing lines 1: ["export const AGGREGATE_ANSWER_KIND: Readonly(...) = {"] (type arguments elided in this copy only) sha1 base e899be861c6e5e13cab24558923e41b0ca7b740c sha1 core (export keyword removed) e899be861c6e5e13cab24558923e41b0ca7b740c presenter: base arm body lines 8, core function body lines 8; identical after de-indent: True MOVE PROOF: PASS ``` The table and its docblock are byte-identical apart from `export`. The presenter's eight body lines, comment included, are identical once de-indented. In core, a new file header says why the rule lives there. The moved docblock still speaks of `SqlDriver.aggregate` and `formatOutput` in the driver's voice, and the header says so. ## Pins (red first), the ablation, and the local PostgreSQL run - **Red, at `719b98795e`** (pins only, no fix), on a private PostgreSQL 16.13 (`initdb`, 127.0.0.1, trust): - `@objectstack/core` `aggregate-answer.test.ts`: 1 file failed, `Cannot find module './aggregate-answer'`. - `service-analytics` `native-sql-measure-number-presentation.test.ts`: 5 failed, all PostgreSQL; 5 passed, all SQLite. First failure: `a row_count is a number, never "2": expected 'string' to be 'number'`. - `rest`, the new `analytics-dataset-measure-number-door.test.ts` and the tightened `analytics-dataset-json-dimension-door.test.ts`: 2 failed, both PostgreSQL; 6 passed. Failures: `a row_count is a number, never "2"` and `expected [ [ 'x', '2' ], [ 'y', '1' ] ] to deeply equal [ [ 'x', 2 ], [ 'y', 1 ] ]`. - **Green, at `e827c237d1`:** core 5/5; service-analytics 10/10 (SQLite and PostgreSQL); rest 8/8 (SQLite and PostgreSQL). - **Ablation** (the strategy stops calling the presenter). Predicted before running: exactly the PostgreSQL cells go red, service-analytics 5 and rest 2; every SQLite test and the core pin stay green. - The mutation went through `scripts/ablation-replace.mjs` (WRAP mode, with its restore trap) on the absolute path. The guard `if (numberMeasures.length > 0 && Array.isArray(rows))` gained `&& String(numberMeasures) === 'ABLATION-20889'`, so it never holds. Anchor count went 1 to 0, blob `3bce7bb073` to `8f36f62dfe`. - `service-analytics` was rebuilt, and `ablation-dist-preflight.mjs` found the marker in 2 built files. - Observed: service-analytics 5 failed (all PostgreSQL), 5 passed; rest 2 failed (both PostgreSQL), 6 passed; core 5/5. That is exactly as predicted. - Restore: the blob after restore equals HEAD (`3bce7bb073`), `git diff HEAD` is 0 bytes, and porcelain is 0 lines. Then a rebuild, `ablation-dist-preflight.mjs --absent` (marker absent from all 6 built files, tree clean), and a re-run: all green. - **`driver-sql`'s full suite at `e827c237d1`**, SQLite and live PostgreSQL, `TZ=America/New_York`, with the server at `Asia/Shanghai` as CI runs it: 210 files passed, 3 skipped; 4061 tests passed, 95 skipped. `sql-driver-20335-aggregate-numeric-presentation.test.ts` 15 tests (1 skipped, the MySQL cell), and `sql-driver-20387-aggregate-double-accumulation.test.ts` 11 (1 skipped). A first run on a UTC server failed only the four suites whose precondition asserts a non-UTC server. - **CI:** no CI job provisions `OS_TEST_POSTGRES_URL` for `service-analytics` or `rest`, so their PostgreSQL cells are named skips there (`skipped: set OS_TEST_POSTGRES_URL to run this cell`). The core pin (string to number) and every SQLite cell run in CI. The private cluster was stopped and its data directory removed after the runs. ## Gates All at HEAD `e827c237d1`, as ONE sequential script under the shared verify lock, each exit code captured before any pipe. - **Derived families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` names 67. `--ran` reconciles: `67 derived famil(ies) accounted for — 65 run, 2 NOT-MEASURED`, 0 unrun. All 65 exit 0. - **NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`.** Reason: both exit 3, `PREREQUISITE NOT MET`. They read the built output of every package, and this run built only the dependency closure it tests. CI's `lint.yml` builds the full closure before them. - **The four roster families the derivation marks ⛔ (roster in a directory this diff touches):** `check-changeset-fixed.mjs`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`. All exit 0. - **Tests and typechecks:** - `@objectstack/core` test: 62 files, 1803 tests passed. Typecheck exit 0, and its `check:test-typecheck` covers the new test. - `@objectstack/driver-sql`: typecheck exit 0; the full suite as above. - `@objectstack/service-analytics` full suite with `OS_TEST_POSTGRES_URL` set: 151 files, 3468 tests passed. Typecheck exit 0; `tsc --listFiles` includes the new pin. - `@objectstack/rest`: the two pin files, SQLite and PostgreSQL, 8/8. Typecheck exit 0, with `check:test-typecheck` over `tsconfig.test.json`. - **ESLint, a declared narrowing** (the repo-wide `pnpm lint` is CI's): - Touched files: `eslint --no-inline-config --format json` over the 8 touched `.ts` files. From the JSON: 8 files, 0 errors, 0 warnings, 0 ignored. - Population: `eslint.config.mjs`'s `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`, which contains all 8. - Invariance: the config enables no type-aware linting (`eslint.config.mjs:326-328`, no `parserOptions.project`), and this diff does not touch the config, so no untouched file's verdict can move. ## Acceptance notes - **MySQL: NOT MEASURED** (no MySQL server here). The presenter is not gated by dialect, so a `DECIMAL` string would be presented the same way. Separately, a read-only inference, also unmeasured: `plugin.ts:500` returns `engine.execute`'s result as the rows whenever it is an array, and `knex.raw` on mysql2 resolves to a `[rows, fields]` pair, so on MySQL the native path may read that pair as its rows. - **`formatOutput`'s inline copy of the presenter** (`sql-driver.ts`, the numeric pass at about `:20303`) is not folded onto `presentAsNumber` here, per the ruling. It is the same rule written a second time, against `presentReadValue`'s "never a re-derivation" docblock. For `domain:engine`. - **Prose that now points to the old home:** `sql-driver-20335-aggregate-numeric-presentation.test.ts:31` and `sql-driver-20387-aggregate-double-accumulation.test.ts:7` say `AGGREGATE_ANSWER_KIND` lives in `sql-driver.ts`. These are driver-sql test docblocks outside this card's two regions. For `domain:engine`, or whoever next touches those files. - **Expression measures** (`type: 'number' | 'string' | 'boolean'`) are untouched: `fields[]` still declares `number` while PostgreSQL answers `"250.000…"`, text or a JS boolean. objectstack-ai#20943 (PR objectstack-ai#20998) retires SQL expressions in cube member `sql`. - **`min` / `max` over a relationship-path column** (`account.revenue`) stay as the client gave them. `declaredFieldType` looks a column up on the base object only, the same "cannot answer, do not block" tier `measureResultType` takes. - **Not this card:** the SUM / AVG accumulation divergence on the native statement (`0.1 + 0.2` answers `0.3` on PostgreSQL native, against `0.30000000000000004` elsewhere). It was measured once through the route, as the ruling asked, and is reported to the seat. No pin enshrines either side of it. - The census of `analytics.query(` call sites (`envelope-caller-census.test.ts`) is unchanged: the new pins drive the route or call the service under another name, so no ledger row or declared input moved. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…hboards skill and the cube checklist item (objectstack-ai#21071) Part of objectstack-ai#20943 Clause-②: no Ruling D's governed half (Tier H, docs only). The code half landed as objectstack-ai#20998 (`5d5e679873`). Five files, no package source: - `docs/adr/0021-analytics-dataset-semantic-layer.md`: a dated note at the end of D1 naming `MetricSchema` / `DimensionSchema` `sql` in `packages/spec/src/data/analytics.zod.ts`. - `skills/objectstack-ui/rules/dashboards.md`: the iron rule no longer escalates to a hand-authored Cube with raw SQL, or to a `formula` field (virtual, no stored column); the Level A table drops "any custom-SQL metric". File: 468 lines and 6211 tokens before and after. Package: every `skills/**/SKILL.md` together is 4395 lines before and after (none is touched). - `skills/objectstack-ui/evals/analytics-inline-vs-dataset.json` (eval 2) and `skills/objectstack-ui/evals/README.md`: the same skill's eval fixture and its index no longer name a Cube as an escalation. Eval 2's `must_contain` drops `"Cube"`, which a correct answer need no longer say. Tokens: the eval file goes from 1102 to 1097 (ceiling 1102), and the README from 287 to 286 (ceiling 289). Lines: 57 and 31, unchanged. - `docs/qa/platform-checklist/areas/dashboards.json`: `dashboards.cube-query` revision 3 drops the showcase cube's `done_rate`, which now lives on its dataset. No changeset: none of the five paths ships in a package's `files[]`. ## 维护者速读(草稿) - **改了什么:** 只改五个文档文件,不改代码。 - ADR-0021 在 D1 末尾加一条带日期的说明:分析 cube 的度量和维度,`sql` 只能写列引用;写表达式,解析时就会被拒。这是裁决 D,代码已随 objectstack-ai#20998 落地。 - 看板技能不再教“dataset 表达不了,就去手写带原始 SQL 的 Cube”,也不再把公式字段当出路。改为:存成对象上的字段(汇总字段,或存好计算值、分桶的字段),或者写应用代码。 - 同一技能包里的评测文件和它的说明也一并改了:评测的标准答案不再把 Cube 当出路。 - QA 清单的 cube 条目删掉 `done_rate`,它已经搬到 dataset 上了。 - **为什么改:** 代码那一半落地后,这几处文字都不对了。看板技能是 AI 写元数据时读的教材:按旧教法写出的 cube,解析时就会被拒。 - **风险与代价(含回滚):** 只改文字,不发版。看板技能规则文件的行数和 token 数改前改后一样,两个评测文件略缩短。合并后是一个提交,回滚就是 revert 它。 - **席位意见:** 建议合并。终稿见 PR 上的维护者速读评论。 - **你要做的:** 看一眼 ADR 那条说明和技能里那一段的措辞,同意就批准。 --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…a column reference (objectstack-ai#21220) (objectstack-ai#21240) Fixes objectstack-ai#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 objectstack-ai#20943 (PR objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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>
…eir src trees to the dispatch derivation (objectstack-ai#21271) Fixes objectstack-ai#21011 Clause-②: no ## What `check:i18n-coverage` now declares what it reads to the dispatch derivation, so an edit under an example app derives the gate locally. CI already ran it on the same diff. - `scripts/check-i18n-coverage.mjs`: `const ROOT_DIR_WATCH_HINTS = ['examples/*/objectstack.config.ts', 'examples/*/src/**'];` beside `EXAMPLES_DIR`, plus a new `--self-test` battery that holds the declaration against the live tree (details below). What the gate judges is unchanged. - `scripts/pm/bare-root-worklist.mjs`: the `check-i18n-coverage.mjs EXAMPLES_DIR examples` row is re-decided from `SPELLABLE-UNDECLARED` to `DECLARED-NARROWER`. Its recorded spelling is re-measured as the two hints above, and its old single-hint spelling entry is replaced. - `scripts/pm/dispatch-gates.mjs` is **not touched**. It is frozen under ruling 208 R6, and its self-test stayed green with the declaration in place (see Gates). ## Why this spelling and not `examples/**` The gate runs `os lint` on each example config. `os lint` loads the config through bundle-require: esbuild bundles every relative import, and `@objectstack/*` plus the driver modules stay external (`BUNDLE_REQUIRE_EXTERNALS` in `packages/cli/src/utils/config.ts`). So the count moves with anything in the config's relative import graph. Measured with esbuild and those externals at `30c530e5`: | | tracked files | of which in the 4 configs' import closures | |---|---|---| | closure (the real read) | 153 | 153 | | declared: `examples/*/objectstack.config.ts` + `examples/*/src/**` | 170 | 153 (complete, 90% precise) | | `examples/**` (bare-root spelling) | 256 | 153 (60% precise) | Every closure file is either a config or a file under that same example's `src/`. Here are the 17 declared files that no config imports: - 11 `src/docs/*.md` pages. `os lint` reads them, but only through `docs/*` rules, and the gate counts `i18n/` rules alone. - 2 `*.test.ts` files under `app-todo/src`. - 3 unimported files under `app-showcase/src`. - the `src/` of `embed-objectql`, which has no config. `examples/**` would add the test suites, e2e specs, READMEs, changelogs and manifests. That is 86 more files, none of which a config imports. ## Pins (in the gate's own `--self-test`, new battery, 10 cases) The battery imports the derivation's own `extractWatchHints` and `hintCovers`. The import is dynamic and sits inside `selfTest`, which the extractor masks. So the gate's own extracted hints gain exactly the two declared literals, and its import follow is unchanged: before `[cli-build-prerequisite.mjs, import-prerequisite.mjs]`, after the same. - The derivation reads both declared hints off this source. - Positive: the three showcase source files of PR objectstack-ai#20998 derive the gate, and so does every config `discoverExamples()` finds (4). - Completeness: no relative import in a config or in a non-test file under its `src/` resolves outside that example's `src/`. On this tree that is 177 edges and 0 escapes. A negative control proves the scan still sees an escape. - Precision: these do not derive through this declaration: `examples/app-showcase/test/gap-fill.test.ts`, `examples/README.md`, and the two `packages/` paths of PR objectstack-ai#20998. Controls show that the extractor drops a computed spelling, and that `examples/**` would have named the test file. ## Measured end to end with `dispatch-gates --commands` Before is base `30c530e5`, after is head `61bd05aa`. | change set | before | after | |---|---|---| | PR objectstack-ai#20998's full file list (15 paths) | 116 commands, no `check:i18n-coverage` | 117, the only addition is `pnpm check:i18n-coverage` | | its 4 `examples/app-showcase/**` paths alone | 40, absent | 41, present | | its 2 `packages/` paths alone (`packages/spec/src/data/analytics.zod.ts`, `packages/services/service-analytics/README.md`) | 74, absent | 74, byte-identical list | | `examples/app-showcase/test/gap-fill.test.ts` alone | (not measured) | 38, absent | ## Ablations All four were run through `scripts/ablation-replace.mjs` against the committed head. Each mutation was proven on disk by its anchor count and blob hash, and each restore was proven by `blob == HEAD` and an empty `git diff HEAD`: 1. Declaration replaced by `['examples/**']`: the self-test goes red, 1 failure. The precision case names the test file and `examples/README.md`. 2. Declaration computed from `EXAMPLES_DIR`: red, 4 failures. The extracted hints fall back to `["scripts/i18n-coverage-baseline.json","i18n","node_modules"]`. 3. `src/**` hint dropped: red, 2 failures (the showcase edit and the import targets are no longer derived). 4. Same mutation as 3, run against `bare-root-worklist.mjs --self-test`: red, 1 failure, "NOT DECLARED: examples/*/src/**". The map and the gate cannot drift apart silently. ## Gates (all at head `61bd05aa`) The family list came from `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths, change set from the merge base). It names the same 30 the dispatch listed, and all 30 exited 0. `--ran` reconciliation: "30 derived famil(ies) accounted for — 30 run, 0 NOT-MEASURED". - `node scripts/check-i18n-coverage.mjs --self-test` passed, then the gate itself: `check-i18n-coverage: OK (13 config(s), 621 baselined untranslated string(s), none new)`. It ran after the gate's own closure build under the verify lock (`VERDICT command-exit 0`). - `node scripts/pm/bare-root-worklist.mjs --self-test`: OK. There are now 12 DECLARED-NARROWER records held set-equal to their gates' arrays; there were 11. - `pnpm check:pm-dispatch-gates`: `dispatch-gates self-test: 1976 cases pass`, run detached for 825.6s. No pin moved, and the frozen file is not in the diff. - Not on the derived list, run anyway because it judges declarations: `node scripts/check-declared-population-live.mjs` passed with 266 of 326 families live. - The other 27 derived families were all exit 0: ci-filter-parity, closing-keyword-parity (+self-test), comment-mask-corpus, declaration-mirrors (+self-test), scripts-symbol-anchors (+self-test), self-test-wired (+self-test), self-test-workflow-commands (+self-test), whole-set-label-write (+self-test), agent-test-spelling, bash32-floor, cli-command-ids, cross-package-test-inputs, driver-memory-census, entry-guard, gitlink-declared, nul-bytes, parse-guard, pnpm-filter-targets, ratchet-remedy-authority, refd-timer-probe and watch-hint-literal. - Lint, narrowed and declared as narrowed: `eslint --no-inline-config --format json` over the 2 changed files returned 2 files, 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is left to CI. ## Acceptance notes - **The PACKAGES half is out of scope, on purpose.** `PACKAGES_DIR` stays undeclared, and its worklist row keeps `SPELLABLE-UNDECLARED` (9 extract configs share one spelling with `check:i18n-bundles` and `check:i18n-stale-fill`, deferred together). The gate's closure also reaches every workspace package a config imports by name, so a `packages/spec` edit can move this count too. Declaring that would be the `packages/` root wholesale, the REFUSE-WIDE shape. The CLI half already reaches this gate through `cli-build-prerequisite.mjs`. - **The dispatch premise was off by one verdict.** The dispatch and claim describe the worklist record as REFUSE-UNSPELLABLE, and so does the gate's old header comment. On `main` the row was `SPELLABLE-UNDECLARED` with "Deferred: no consumer has asked". This card is that consumer, and moving the row to DECLARED-NARROWER follows the split criterion of the 2026-08-26 ruling recorded in the worklist header. A paragraph there records the re-decision. The stale header comment in the gate is replaced. - `selfTest` in `check-i18n-coverage.mjs` became `async`, because it needs the dynamic import. The dispatch reads `(await selfTest()) !== SELF_TEST_VERDICT`, a spelling other gates already use. Self-test runtime went from about 0.11s to about 0.2s. - `skip-changeset`: `scripts/**` publishes nothing from any package. --- _Generated by [Claude Code](https://claude.ai/code/session_01FNKm1SmPpuJASnbjxWGtsJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Closes #20943
Clause-②: yes (narrowing)
Ruling D on #20943 (comment
5921156712, execution parameters), dispatched by the claim5921298734: an analytics cube member'ssqlis a column reference, and a SQL expression there is refused at parse with a prescription naming the ADR-0021 dataset form. The showcase's one expression member,done_rate, moves to its dataset in the same PR.What changes
@objectstack/spec,data/analytics.zod.tsMetricSchema.sqlandDimensionSchema.sql(every member of a cube'smeasuresanddimensions) admit exactly the accept set the execution parameters name: a bare identifier (amount), a dotted identifier path (account.amount,account.owner.region), and'*'. Any other value is refused atmeasures.METRIC.sql/dimensions.DIMENSION.sqlwith codeinvalid_format. That covers a CASE expression, an aggregate or a ratio of aggregates, a quoted or$-prefixed spelling, an empty string and a broken path. A column reference parses byte-identically to before.IDENTIFIER_PATHinnative-sql-strategy.ts, and the field gate's bare-identifier / identifier-path pair. So the contract admits exactly the values those readers resolve to fields..regex(), not a refinement, so the published JSON Schema carries it as apattern. A first cut used refinements, and the build's dropped-refinement gate refused it: a JSON-Schema-validated document would have been judged differently from the parse. The pattern closes that gap, anddropped-refinements.baseline.jsonis untouched.filterfor a conditional count or sum, andderived: { op, of: [...] }over named measures for a ratio, sum, difference or product. It also states the ratio's 0–1 scale. The dimension prescription says that a CASE bucket has no expression form in either layer.sqlexpression" as a live channel. This covers the retired metricfiltersguidance, the analytics queryfiltersguidance, themetric-filters-removedconversion summary, its D3 entrycube-metric-filters-retired, and its step-18 rationale fragment. All of them are unreleased major-18 text.ADR-0087. The D3 entry
cube-member-sql-expression-retired(migrations/entries/semantic/) and its step-18 rationale fragment were written after merging amainthat contains #20953, which landed asc6b3a01d5d.registry.tswas regenerated bygen:migration-registry, never edited by hand. There is no D2 conversion, because an expression has no mechanical rewrite into a dataset. There is noRETIRED_KEYS_BY_MAJORrow, because no key left the shape.Liveness. The
analytics_cuberowsmeasures.sqlanddimensions.sqlstaylive, re-verified 2026-09-30, with the narrowing recorded and the field gate'sfieldsOfColumnSqladded to the evidence.Generated. Only
content/docs/references/data/analytics.mdxmoved. Theauthorable-surface,api-surfaceandjson-schema.manifestratchets are byte-identical, as expected for a value narrowing.spec-changes.jsonand the upgrade guide stay at protocol 17, so major-18 entries do not project yet, and both checks are green.Showcase. The
showcase_deliverycube losesdone_rate. Theshowcase_task_metricsdataset gainsdone_count(aggregate: 'count',filter: { status: 'done' }) anddone_rate(derived: { op: 'ratio', of: ['done_count', 'task_count'] },format: '0.0%'), in the same shape as the existingpaid_rate. No dashboard read the cube'sdone_rate, so there was nothing to re-pin there.test/gap-fill.test.tsis re-pinned: the cube's measure list, the parse of the shipped cube, and the dataset's filtered-count-over-count form plus itsDatasetSchemaparse.@objectstack/service-analytics. One test and the README, nothing else:cube-authored-format-granularity.test.tsbuilt its custom-SQL fixture withCubeSchema.parse, which now refuses it. The fixture is built unparsed instead, plus one assertion that the parse refuses it. The engine-path refusal it pins is unchanged.README.mdno longer tells a reader to fold a per-metric condition into the metric's ownsqlexpression. That text ships in the package, so it gets apatchline in the changeset. This file is outside the claim's declared surface; see the Acceptance notes.The gate's stand-down branch in
analytics-service.tsis not touched. #20965 remains open for it.The fork clause
The fork clause did not fire. The one authored expression member in the tree is
done_rate, and its structural equivalent exists and is measured (next section). A shape-level reading for the seat: a dimension CASE bucket has no dataset equivalent, because a dataset dimension names a field and its only bucketing isdateGranularity. No authored cube in the tree carries one. Twoservice-analyticsgate fixtures do (dimension-source-field-gate.test.ts,where-source-field-gate.test.ts), but they are built without the parse to pin the runtime branch #20965 owns. Out-of-repo cubes are NOT MEASURED.The measure-level filter, verified before relying on it
compileDatasetreadsm.filterintomeasureFilters.DatasetExecutorsplits filtered measures off withsplitMeasuresByFilterand runs one supplementary query per filtered measure over the base filter combined with that measure's filter. It then evaluatesderivedon the merged row, withratioasa / band null on a zero denominator.dataset-executor.test.tscases cover a supplementary query for a measure-scoped filter and a derived measure over filtered and unfiltered dependencies, anddataset-compare-measure-filters.test.tscovers the number a reader sees. All are green in theservice-analyticsrun below.pnpm dev -- --fresh, torn down after) queried the task dataset through the analytics dataset door as the seeded admin.done_countanddone_ratereconciled against the rawshowcase_taskrows in every priority bucket and in the ungrouped total.done_rateequalleddone_count / task_count, and the column carriedformat: '0.0%'andpercentScale: 'fraction'. The cube's meta listed its three remaining measures.Tests (final head
fa979c57edunless stated)@objectstack/spec:vitest run --project local src/: 544 files, 16268 passed, 1 todo.--project local scripts/: 41 files, 950 passed, 1 skipped (ata32fd122dc; the second merge brought noscripts/change underpackages/spec).--project repofiles: 10 files, 300 passed. These arestep18-rationale-merge,conversions-major18-merge,liveness/evidence,liveness/proof-registry,cube-member-inner-name-retirement,cube-refresh-key-retirement,retired-key-migrate-sentence,root-index,file-descriptionandcategory-title.typecheckexit 0.@objectstack/service-analytics:testgave 149 files and 3449 passed;typecheckexit 0, and the edited test is in its program (--listFiles).@objectstack/example-showcase:vitest rungave 29 files and 387 passed, after rebuilding its closure (60 tasks);typecheckexit 0, and the three edited files are in its program.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 116 families. All 116 were run, and--ranreconciled them as "116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN", each with an exit code.check:generated,check:liveness,check:adr-0087-registration(reads the new marker),check:changeset-no-major,check:empty-changeset,check:doc-authoringandcheck:nul-bytes.pnpm check:platform-checklist, which exited 1 on an anchor this diff does not touch.areas/identity-auth.jsoncitesplugin-auth/src/auth-plugin.ts#twoFactor, and that symbol is absent. This branch's bytes forpackages/plugins/plugin-auth,docs/qa/platform-checklist,scripts/check-platform-checklist.mjsandscripts/symbol-anchors.mjsare identical to the merge base05be352596, so it ismain's state, not this diff's.eslint --no-inline-config --format jsonover the 10 changed lintable files gave 10 files, 0 errors and 0 warnings.eslint.config.mjs: the**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}block. The other changed files are.md,.mdxand.json.parserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The whole-repopnpm lintis CI's.Ablations (each from the committed state; disk-verified through
scripts/ablation-replace.mjs; restore proven by blob hash equal to HEAD and an emptygit diff HEAD)sqlpattern admits anythingfiltersguidance offers thesqlexpression channel againdone_countloses its filtergap-fill: 1 failed (the dataset-form case)@objectstack/specrebuiltablation-dist-preflightmarker present in 20 built files; the service-analytics parse assertion failed (expected true to be false)The expected direction was "turns red" in all five, and that is what was observed. No ablation file is left in the tree.
Acceptance notes
packages/services/service-analytics/README.mdis outside the claim's file surface. The dev contract's rule says published text this change makes false is fixed in the same round, and the dev contract outranks the dispatch words where they conflict. The edit is one paragraph, and the changeset carries'@objectstack/service-analytics': patchfor it. The seat can drop both if it prefers service-analytics: delete the analytics field gate's stand-down on an authored cube expression member once #20943 retires raw expressions in a cube member'ssql(the branch becomes unreachable) #20965 to carry the fix.skills/objectstack-ui/rules/dashboards.mdstill escalates past a dataset to "a hand-authored Cube (raw SQL / explicit joins)" (≈:78), and lists "any custom-SQL metric" as a dataset gap (≈:70). Both are now false: a cube member takes no SQL expression, and joins were already derived. The natural carrier is the separate Tier H docs PR the ruling names for ADR-0021's dated note. It is not mixed in here.docs/qa/platform-checklist/areas/dashboards.json(the cube meta item, ≈:976,:991,:1037) namesdone_rateamong "exactly its four measures" and as a variant. The cube now declares three. No gate reads that text. Carrier: the checklist's next revision.'*'is admitted on a dimension and on a non-count measure, because the execution parameters admit it on both members. Neither was ever a working query. Separately, thenumber/string/booleanmeasure types existed to carry an expression. With an identifiersql, the raw-SQL path emits the column unaggregated and the ObjectQL path refuses it, so retiring those three types is the natural follow-up. That is a decision for the seat, not something this PR does.defineCube) and pinned by the showcase test. The tree's other expression members are deliberate unparsed runtime fixtures inservice-analytics, owned by service-analytics: delete the analytics field gate's stand-down on an authored cube expression member once #20943 retires raw expressions in a cube member'ssql(the branch becomes unreachable) #20965's cleanup. A text scan would have needed a directory exclusion and a new cross-package radius.tscdoes not catch an expression member, becausesqlis stillstring; the parse is the judge. Storedanalytics_cuberows and built artifacts that carry an expression are NOT MEASURED here.Generated by Claude Code