Repository navigation
feat(spec)!: an analytics dataset dimension's and measure's field is a column reference (#21220) - #21240
Conversation
…ference The dataset layer's field takes the accept set its cube members hold, from one shared declaration (data/analytics-column-reference.ts); a dimension refuses the row wildcard. D3 entry dataset-member-field-expression-refused. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
…rrowed contract; regenerate the dataset reference The route parses every dataset first, so an expression field is now answered 400 VALIDATION_FAILED at its path; the service door's 403 stays pinned on unparsed (stored) fixtures. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
… dataset field narrowing Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
…cription's prose Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
…taset-field-column-reference
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 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 d1d069ec7bdf7b952ec7f0f77412a4734f8445a4 && git checkout d1d069ec7bdf7b952ec7f0f77412a4734f8445a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3dc33b2d13a919db611bc077d42337df51ec626d fc4e91c09b028326af60f9a2cff08c156ffefaad && git checkout -B drift-repro 3dc33b2d13a919db611bc077d42337df51ec626d && git merge --no-ff fc4e91c09b028326af60f9a2cff08c156ffefaad
node scripts/docs-audit/affected-docs.mjs --json 3dc33b2d13a919db611bc077d42337df51ec626d
|
Contract reviewServed-tier: Isolated reviewer for card #21220 / PR #21240. Inputs: the card body and all four of its comments (triage ① Derived judgments
② Semver level
Check-runs on ③ Boundary flags
Implemented-by: VERDICT: FAIL |
…taset-field-column-reference
…conversion dataset-count-measure-empty-field-removed (retiredFromLoadPath, retiredAfter 17.5.0) drops field: '' from a count measure, which then compiles to COUNT(*); the D3 entry links it and states that the dataset door never judged an empty field. Changeset, ledger notes and rationale fragment corrected to match. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
…taset-field-column-reference
…fter the base merge STEP18_RATIONALE fragment 57 -> 58 (57 is allocated to the in-flight ui-record-line-items-props-closed); MAJOR_18_CONVERSIONS entry 53 -> 54 (main landed form-field-public-picker at 53). Neither list requires unique orders. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
Contract reviewServed-tier: Isolated reviewer for card #21220 / PR #21240, second review (the first, ① Derived judgments
② Semver level
Check-runs on ③ Boundary flags
Implemented-by: VERDICT: PASS |
…eld-less dimension holds Save (objectui#11402) (objectstack-ai#11408) Fixes objectstack-ai#11402 Clause-②: no ## What changed `DatasetDefaultInspector` no longer writes `field: ''` for a dataset row whose Field box is blank. - **New rows carry no `field` key.** "Add dimension" seeds `{ name: '', type: 'string' }` and "Add measure" seeds `{ name: '', aggregate: 'sum' }`. Neither writes an `id` default or any other field on the author's behalf. - **A blank Field value removes the key.** Both Field combos write through `writeRowField`, which rebuilds the row without `field` when the value is blank. It does not store `''` or `field: undefined`, so the held draft and the saved body agree. A `count` measure with no field saves the one spelling the spec has for count(*): the key is absent. - **A dimension with a blank Field box is held as incomplete.** The inspector reports each dimension whose `field` is absent or blank on its `onBlockingIssuesChange` channel. That count includes a stored `field: ''`. The host already reads this channel for the default-inspector family (objectui#4527, objectui#6900): it holds the Save button, the autosave timer and the shortcut until a field is picked. While the box is blank, that row's Field label also shows the designer's required marker (`RequiredMarker`, objectui#10948). A complete row shows the plain label. Measures are not held: a measure's `field` is optional, and whether its aggregate may go without one is the spec's verdict at save. ## Rulings → where each lands (triage `5941542449`) | Ruling | Where | |---|---| | New rows are seeded without a `field` key. | the two `onAdd` seeds; pins `seeds a new measure row …`, `seeds a new dimension row …` | | A measure with a blank Field box writes no `field`. | `writeRowField` via `pickMeasureField`; pin `⭐ a measure added and saved with a blank Field box carries no field, and the saved dataset parses under DatasetSchema` | | A dimension with a blank Field box shows as incomplete and is not saved with `''`. | the blocking count plus the state-bound marker; pins under `a blank-Field dimension is held as incomplete` | | ⛔ No `id` default. | the seeds; the dimension seed pin uses `toStrictEqual` | ## Which "incomplete" mechanism this reuses, and why it is licensed here I measured what this inspector family has for "incomplete": the `onBlockingIssuesChange` Save gate, the static `RequiredMarker`, and the host's live Zod pass. The Zod pass does not exist for `dataset`: `clientValidation.ts` registers no loader for it. Nothing new was invented. The hold reuses the Save gate, and the marker reuses `RequiredMarker`. A reviewer should weigh one point. The host's note above `blockingReport` in `ResourceEditPage` describes this channel as carrying faults the server does **not** catch, and it keeps schema verdicts advisory. The reason is that a client gate *stricter* than the server would wedge Save on a body the server accepts. - **A field-less dimension.** The installed spec (`@objectstack/spec` 17.5.0, `DatasetDimensionSchema.field` is a required string) refuses it as well. So the hold is never stricter than the spec. A new pin, `the hold is never stricter than the spec …`, asserts `DatasetSchema` refuses that dimension at `dimensions.0.field` and accepts it once a field is picked. It goes red if the key ever turns optional. - **A stored `field: ''`.** This is the one row the hold refuses that 17.5.0 accepts. The ruling covers it ("not saved with `''`"), and the Field box on screen repairs it, so it cannot wedge. - **Without the hold**, dropping the `''` seed alone would send every freshly added dimension to the server without a field. Autosave would then draw a 422 on every "Add dimension" until a field is picked. This is inferred, not measured against a live server: `DatasetDimensionSchema.field` is required on 17.5.0, and `dataset` has no client validator. ## Inherited vs re-run (this run resumed a killed one) - **Inherited:** `93f85530` (the pin) and `200e91d8` (the fix). I did not take either on trust. - **Re-run, red first:** with the inspector put back to its `7728c67c` bytes and the pin file unchanged, 8 tests failed and 1 passed (9 total) at `44fa23cf`. Each failure is on the asserted fact: the seeds carry `field: ''`, the held row has a `field` key, nothing is reported on the blocking channel, and `writeRowField` is absent. The one pass is the spec control, which asserts the spec rather than the inspector. The restore used `git checkout HEAD -- PATH`. I proved it by comparing hashes: the file's blob equals the HEAD blob `725de67b`, and `git diff HEAD` is empty. - **Found and repaired with new commits** (objectui forbids force-push): - `93c6598c`: the inherited pin did not compile under `tsc -p tsconfig.test.json`, the second leg of the package's `type-check`. A field-less literal passed to `writeRowField` read as a weak type and drew TS2353. The rows are now typed. - `44fa23cf`: the inherited fix put the marker on every dimension's Field label. That changed the label text on complete rows and turned `inspectorStrings.i18n-10696`'s lit control red, because it finds the Field combo by the label's exact text. The marker now renders only while the box is blank. That is also closer to the ruling's "shows as incomplete" than a static "required" mark on complete rows. - `e98eb867`: the spec-control pin described above. - **New here:** the changeset (`c5648ec6`). ## Tests and gates — all read at `44fa23cf` Node v22.22.2 from `/opt/nvm` (the container's v22.22.0 is refused by the engine-strict install). - `pnpm --filter @object-ui/app-shell type-check && pnpm --filter @object-ui/app-shell build` exited 0. Both `tsc` legs echoed, and the build printed `✓ dist completeness: 1 package(s) complete`. `tsc -p tsconfig.test.json --listFiles` lists the pin file. I built the dependency closure first with `turbo run build --filter=@object-ui/app-shell... --concurrency=2`: 29 of 29 tasks succeeded. - `pnpm exec vitest run --maxWorkers=2` over every suite in `packages/app-shell/src/views/metadata-admin/inspectors/`, plus the dataset readers `previews/DatasetPreview*`, `DatasetPreview.dimensionLabels-8187` and `dataPreviews.i18n-10862-s3`: `Test Files 125 passed (125)`, `Tests 1727 passed | 1 skipped (1728)`. The skip is an existing `skipIf` on a spec version in `flow-node-config.spec-reconciliation`. - These gates each exited 0: - `pnpm check:control-bytes` - `pnpm check:new-line-citations` (`VERDICT … 0 new citation(s)`) - `node scripts/check-changeset-presence.mjs` - `pnpm changeset:check` - `check:vi-mock-specifiers`, `check:vi-mock-inherit` and `check:vi-mock-override-shape` (the pin mocks `./useDatasetFields`) - `check:test-path-roots` - `check:i18n-keys` - `check:changeset-claims` - `check:pending-changeset-literals` - **Narrowed lint, a measurement rather than a skip.** 1. The population is the package's `eslint .` under the root `eslint.config.js`. Both changed files are linted, not ignored. 2. `--format json` reports 2 files, 0 errors and 4 warnings. One warning is new: `react-refresh/only-export-components` on the `writeRowField` export, the same rule the file's existing `objectChangePatch` export already draws. `lint.yml` sets no `--max-warnings`. 3. Invariance: `eslint.config.js` enables no type-aware linting, and no rule under `eslint-rules/` reads the disk. So this diff cannot move a verdict on an untouched file. - The diff is not on the governed surface: `check-governed-queue-guard.mjs --test` reports `NOT GOVERNED`. ## Readers of these rows (checked for an absent `field`) `DatasetPreview` reads `String(x?.field ?? '')` and skips an empty one. `ReportView`'s drill fallback reads `dimDef?.field || dim`. `missingRelationship` takes `field: string | undefined`. `useDatasetCatalog` never reads `field`. None of them breaks. ## Acceptance notes (observations, not filed) - **A legacy measure stored with `field: ''`** (written by this inspector before this change) is re-sent unchanged unless the author edits it. The card records that a `count` measure carrying it answered 500 on the ObjectQL strategy (the objectstack dev's measurement, not re-taken here). Under objectstack-ai/objectstack#21240 it is refused at `measures.N.field`. The combo has no "clear" affordance, so the in-UI repair is the Source tab. Repairing stored data was not asked for here. - **Clearing a filled Field box.** `InspectorComboField` offers no way to clear a picked value, so the inspector has no UI path to clear a measure's Field box back to count(*). `writeRowField` covers a blank value if one ever arrives. - **Host comments are now narrower than the channel.** `ResourceEditPage`'s Save-button comment and `inspectorBlocking` note name CEL faults and the objectui#6900 refusal as what the channel carries. The dataset dimension hold is now a third kind. --- _Generated by [Claude Code](https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…export options object, and a bare format array is refused (objectstack-ai#21229) (objectstack-ai#21287) Fixes objectstack-ai#21229 Clause-②: yes Dispatched by the PM claim `5943166878` (PM loop round 1, `domain:spec` seat 1), on the triage ruling `5939380297`. `ComponentPropsMap['object-grid'].exportOptions` was `z.unknown()`. It now takes `ListViewExportOptionsSchema`, the list view's strict five-member export options object, **by identity**. It does not take the list view's union, whose legacy bare-array arm lifts to `{ formats }`. A bare array is refused with the object form named. The changeset carries the `(narrowing)` arm, the BREAKING banner at `minor`, and the ADR-0087 marker `registered ui-object-grid-export-options-closed`. It is D3 only; the reading is below. ## What changed - **New non-barrel module `packages/spec/src/ui/list-view-export-options.ts`.** It holds the export options block, moved verbatim out of `view.zod.ts`: the retired-`'pdf'` prescription, the `csv` / `xlsx` / `json` format enum, and the strict five-member object. - There is one addition. The object's own error map answers an `invalid_type` on an array input with a prescription naming `{ formats: ['csv', 'xlsx'] }`. - It is the object's map because that is the only map a type failure at this position consults. A wrapper's or the enclosing row's map is never reached, and an object-level refinement never runs once a property has failed its type. - The object is built exactly as `strictObject()` builds one: `closedObject(z.object(shape, { error }).strict())` with the same registered `strictObjectError` declaration. The `prime` handle is forwarded, so `closedObject`'s terminal unknown-key contract is kept. - **New non-barrel module `packages/spec/src/ui/view-history.ts`.** `VIEW_HISTORY` moved here, verbatim, so the moved block keeps the refusal sentence it has always carried. `view.zod.ts` imports both modules, and its 53 `VIEW_HISTORY` uses are unchanged. - **`component.zod.ts`:** `exportOptions: ListViewExportOptionsSchema.optional()`. The "Unvalidated here" describe text is gone. A docblock records the ruling and the door reading. - **D3 entry** `18.ui-object-grid-export-options-closed.ts`, regenerated into `migrations/registry.ts` by `gen:migration-registry`. - **`STEP18_RATIONALE` fragment** `ui-object-grid-export-options-closed`, `order: 59`. `main` holds 57 (PR objectstack-ai#21244) and 58 (PR objectstack-ai#21240) at the merge `b91e40bc89`. It is inserted at its sorted position. - **Pin file `component-object-grid-export-options-members.pin.test.ts`, rewritten.** The objectstack-ai#17166 version asserted the key was still unvalidated, so that this change would red there and be decided deliberately; it did. The new file holds the triage pins: - **identity:** the row's inner schema is the very instance the list view's union holds as its object arm, read from the union rather than imported by name, and it is not the union; - **bare array refused:** `invalid_type` at `exportOptions`, with the object form named. The control is the same array on a list view, which still lifts to `{ formats: ['csv'] }`; - **object form accepted:** all five members, `{}` and absent; - **a format outside the enum refused:** `invalid_value` at `exportOptions.formats.1`, and `pdf` with its retirement text; - **an undeclared key refused:** `unrecognized_keys` at `exportOptions`, naming `this export options block` and the `maxRecord` → `maxRecords` rename. - **Regenerated:** `content/docs/references/ui/component.mdx` (the row's type, and a new nested-shape table) and `docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md` (see the strictness-ledger note under Acceptance notes). ## Measured: why a non-public module, not an export (Zone 2 item 1) - `ui/index.ts` is `export * from './view.zod'`, so any export from `view.zod.ts` is public. - **Measured.** I added `export { ListViewExportOptionsSchema }` to `view.zod.ts` through `scripts/ablation-replace.mjs` in wrap mode, then rebuilt spec. The first attempt was refused by the tool as a no-op, because the anchor was inside the replacement; it was re-anchored and re-run. Results: - `check:api-surface` turned red with `./ui + ListViewExportOptionsSchema (const)` ("0 breaking, 1 added"), and `check:export-origins` turned red; - the build also wrote `ui/ListViewExportOptions` into `json-schema.manifest/ui.json` (a new published JSON Schema def) and 5 `ui/ListViewExportOptions:*` rows into `authorable-surface/ui.json`. - The file was restored, blob equal to HEAD and `git diff HEAD` empty. The two dirtied artifacts were restored with `git checkout HEAD`, and after a clean rebuild `check:api-surface` reads "unchanged ✓". - **objectui.** At the pin `31971ff1e28f`, `packages/types/src/__tests__/export-options-spec-parity.test.ts` asserts `expect(specUi.ListViewExportOptionsSchema).toBeUndefined()`. An export would red that test at objectui's next spec bump. - **With the non-barrel module**, `check:api-surface`, `check:export-origins`, `check:declaration-map` and `check:authorable-surface` are unchanged. One declaration; zero public surface. This is the `analytics-column-reference.ts` / `analytics-carrier-filter.ts` / `section-group-reference.ts` precedent. ## Measured: the ADR-0087 disposition is D3 only (Zone 2 item 2) Every pre-PR shape that the pinned renderer handles and the PR refuses was put through every door, before (`f148852752`) and after (`8b2c2bb558`, the schema commit, same `src/ui` as HEAD). The reading uses the built `dist`, `getMetadataTypeSchema('page')` (the save door's per-type parse), `defineStack`, the props gate `validateComponentProps`, and `runtimeAuthoringRulesFor('page')`. | shape (on an `object-grid` node's `properties`) | at the pinned renderer (`ObjectGrid.tsx` at `31971ff1e28f`) | row before → after | save door | `defineStack` | props gate after | |:--|:--|:--|:--|:--|:--| | `['csv']` | `!!exportOptions` is true, so the menu shows the csv/json default; the list is dropped | accept → `invalid_type` | ok | accepted | warning `component-props-invalid` | | `{ formats: ['csv'], foo: 1 }` | renders; `foo` is never read | accept → `unrecognized_keys` | ok | accepted | warning `component-props-unknown-key` | | `{ formats: ['pdf'] }` / `['xml']` | the value is hidden from the menu with a `console.warn` | accept → `invalid_value` | ok | accepted | warning `component-props-invalid` | | `null` | `!!null` is false, so no menu, the same as absent | accept → `invalid_type` | ok | accepted | warning `component-props-invalid` | | `true`, `'csv'`, `{ formats: 'csv' }`, `{ maxRecords: -1 }`, `{ streaming: 'false' }` | renders, with the default or a misread | accept → refused | ok | accepted | warning `component-props-invalid` | - **Lit controls, same run.** An undeclared `object-grid` prop gives the props-gate warning `component-props-unknown-key`. An undeclared page key is REFUSED at the save door and THROWS in `defineStack`. The accepted object forms (`{ formats: ['csv','xlsx'] }`, `{}`, all five, absent) give no finding anywhere. - **The runtime publish gate for `page`** runs one rule, `validatePresetComparands`. The props gate is `tier: 'advisory'`, `surfaces: CLI_ONLY`. - **The renderer.** At the pin, objectui runs its zod mirror only in the `objectui validate`/`check` CLI. `SchemaRenderer` runs a structural `validateSchema` in dev only. - **So nothing on the save or load path refuses any of these shapes.** A stored page saves and loads, and no conversion has a load-path refusal to pre-empt. - **Lossless rewrites.** The undeclared key, `pdf` and `null` have one (delete it), but nothing stops loading. The bare array has none that both keeps today's menu (`{}`) and honours the author's list (`{ formats }`), and that choice is the upgrader's, which the D3 entry states. Triage also ruled out a lift. ## Census (Zone 2 item 3), on `f148852752` - **Zero `object-grid` blocks author `exportOptions`** in `examples/**`, the package fixtures, `content/docs/**` and `skills/**`. - **Control:** the same census finds 10 authored `object-grid` blocks. Nine are TypeScript nodes (two showcase pages and seven package-test fixtures). One is the YAML example in `content/docs/protocol/objectui/layout-dsl.mdx`. The `exportOptions` matcher is lit on the 4 list-view authorings: `app-crm` ×2, the showcase task view, and the lint showcase fixture. - **`skills/**`:** nothing teaches `exportOptions`. `skills/objectstack-ui/rules/pages.md` names `object-grid` in prose only, so no Tier H follow-up is owed. - Nothing needed respelling. ## objectui (Zone 2 item 4, Post-Task Checklist objectstack-ai#4) Nothing the pinned objectui imports moves: - `check:api-surface` is unchanged, and no export was removed or renamed. - `ListViewSchema.shape.exportOptions` is still a two-arm union with one five-key object arm, which is what objectui's `SPEC_EXPORT_OPTIONS_OBJECT_SHAPE` peel reads. - `ListViewExportOptionsSchema` is still not exported, so objectui's floor test holds. - No pinned objectui test parses the row with `exportOptions` and expects a bare array to pass. Six pinned tests mention `exportOptions` near the row; two of them carry only prose that will go stale (see Acceptance notes). ## Changes outside the row, stated - **The list view's nested message.** The list view's `exportOptions` accepts and lifts exactly what it did, and its top-level messages are unchanged. Only when a bare array also fails the array arm (`['docx']`) does the object arm's nested branch message read the new prescription instead of `Invalid input: expected object, received array`. Measured on `dist`. Both carriers read one declaration, and the text is worded to be true on both. The changeset says so. ## Tests and gates: all on `032865c93c` (HEAD, the merge of `origin/main` `b91e40bc89` through `os-regen-merge.sh`) - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: **597 files passed, 17475 passed, 1 todo**. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` is OK (52 files / 246 errors / 135 pinned signatures, unchanged), so the rewritten pin compiles under `tsconfig.test.json`. - **Contract-face fixture triage.** These are consumers of the spec, the downstream (`...@objectstack/spec`) direction, limited to the three the dispatch names: - `@objectstack/lint` (the props gate): **119 files / 5515 tests passed**; - `@objectstack/metadata-protocol`: **200 passed + 3 skipped files / 2973 passed + 19 skipped tests**; - `@objectstack/spec`: as above. - No fixture in any of them needed a change. - `pnpm --filter @objectstack/spec check:generated`: 15 of 15 up to date after `--fix` regenerated the 2 it proved stale (`check:docs`, `check:strictness-ledger`). - **`dispatch-gates.mjs --commands`** (no paths) derived 112 commands; all 112 ran and exited 0. - Six first exited 3 with PREREQUISITE NOT MET, because `lint`, `client-react` and `objectql` had no `dist`: `check:doc-formula-expressions`, `check:doc-security-posture`, `check:skill-examples`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. Each re-ran green once the dists existed. - `--ran` with `cmd :: exit N`: "112 derived, 112 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)". - **Reverse verification, cross-package type.** A scratch `probe.ts` typed against the rebuilt `dist/ui/index.d.mts`, using `ObjectGridProps`, was compiled with `tsc`: - `exportOptions: ['csv']` gives `TS2559`; - `{ formats: ['xml'] }` gives `TS2322`; - `{ formats: ['csv'], maxRecord: 1 }` gives `TS2561`; - the control `{ formats: ['csv','xlsx'], maxRecords: 10 }` compiles. - At the base the key was `unknown` (the reference page rendered `any`). - **NOT MEASURED, declared to CI:** the 6 path-scheduled CI jobs (Test Core shards, Temporal Conformance, Dogfood Regression and Verify, Build Core, Build Docs), the 4 workspace type-check lanes, and the 54 artifact-roster families the derivation lists outside its total. ## Acceptance notes - **The strictness ledger's scope.** The ledger (`check:strictness-ledger`) counts `.zod.ts` files only. The moved block is one CLOSED site and now lives in a non-`.zod.ts` module, like the other non-barrel helpers, so the regenerated `ui/` counts read 189 → 188 sites and 179 → 178 strict. The strip count, which is the ratchet's target, is unchanged at 7. Naming the module `.zod.ts` would have kept the site counted, at the price of a hand-written ledger row and of opting a non-public module into the `.zod.ts` generators' discovery. - **Stale prose in objectui.** At the pin, two objectui test files say the spec row is `z.unknown()`: the `ObjectGrid.exportOptionsKeys.test.ts` docblock, and the `object-grid.exportOptions` row in `registry-inputs-spec-parity.test.ts`'s `MEMBER_PINS`. Neither asserts it, so nothing goes red. Once the spec version carrying this lands, both describe a past state. Carrier: objectui's next spec pin-bump PR. Noted, not filed. - **objectui's `properties` bag.** On objectui `origin/main`, the bag arm from objectui#11399 judges the bag by `ComponentPropsMap['object-grid']` by reference. Once objectui installs this spec, its `objectui validate` refuses a bare array in the bag, as its flat mirror has since objectui#7762. No objectui change is owed beyond the pin bump. - **A public export, if objectui later wants one.** objectui's export-options parity test says "When upstream exports the symbol, derive from it and delete both the mirror". Publishing `ListViewExportOptionsSchema` stays possible as its own decision. It moves `api-surface` (+1 const), `json-schema.manifest` (+1 def) and `authorable-surface` (+5 rows), as measured above. It is not done here. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…bjectstack-ai#21290) ## What this is A pre-cut draft of the curated release page for **17.6.0**, `content/docs/releases/v17/17-6.mdx`, plus its entry in `content/docs/releases/v17/meta.json` and in `scripts/docs-audit/handwritten-docs.json`. It also appends one dated correction to `content/docs/releases/v17/17-5.mdx` (details below). Docs-only. Everything is under `content/docs/releases/` (release-owned) or the docs-audit list, so this is the dedicated docs-only PR `AGENTS.md` sanctions for that tree. It publishes nothing from any package, hence `skip-changeset`. Clause-②: no It follows the 17.5.0 page's structure: 1. Highlights 2. What's new, including the "read this before treating the version number as a safety guarantee" list of silent runtime changes 3. Breaking changes & migration (triaged, not exhaustive) 4. New capabilities 5. Notable fixes 6. New in Console (Studio) 7. Shipped in 17.5.0, listed again in 17.6.0's CHANGELOG 8. Upgrade checklist (every line marked *not exercised*) ## How it was compiled - **Source:** all 338 changesets pending on `main` at `748b2407`. Every changeset was read and triaged into breaking, feature, fix or internal. PR numbers and short SHAs are taken from the commit that added each changeset. - **Already shipped in 17.5.0:** 16 of the 338 changesets (from 15 commits) describe code that 17.5.0 already published. I checked each commit with `git merge-base --is-ancestor` against the version commit `8c87d26a` and the publish commit `0f6dcac5`. - Eight follow the version commit in first-parent order. The 17.5.0 page already covers them. - Seven are ancestors of the version commit but were not in the version PR when it merged. The 17.5.0 page does not mention them. Two of them are breaking (`e73ee2d` objectstack-ai#20567, `c876a74` objectstack-ai#20504), and `7a1faf1` (objectstack-ai#20579) makes `os validate --strict` fail on a live conversion. - The new section matches the list in `.changeset/20622-release-aftercare-upgrade-and-unannounced-notes.md`. - **Cross-check:** each of the nine ADR-0087 conversions added since 17.5.0 is covered by a migration entry on the page: - `action-block-endpoint-to-target` - `connector-sync-keys-removed` - `connector-triggers-removed` - `cube-refresh-key-removed` - `dataset-count-measure-empty-field-removed` - `form-field-public-picker-removed` - `form-view-subform-columns-canonicalized` - `page-header-breadcrumb-removed` - `time-default-utc-suffix-dropped` - **Console section:** built from the three pin-bump changesets (`dd3f7e1be356 → db11afd4967c → e420df310f5b → 31971ff1e28f`), which carry 232 releasing objectui changesets, 23 of them declared breaking. The range was also read with `scripts/objectui-range.mjs`. - **Fact-check pass:** a second pass checked every cited SHA's changeset against the page, in three slices. It found 31 claims that were wrong or overstated, and the three follow-up commits correct them. Some examples: - An RLS `contains` verdict that `d2bc644` (objectstack-ai#21253) later reversed in this same release. - The `manage_platform_settings` scope: reads of both types, but writes of `datasource` only. - The dataset door's status after `434c6c7` (objectstack-ai#21240). - Filter positions that differ per change. - `7a1faf1`, which is not a breaking change. - **Other corrections made while compiling:** - The connector migration table does not claim that a `job` schedules a `connectorSource` pull. `0efbdc3` says nothing schedules a pull until the `job` stage lands, and none landed in this release. - The public-form picker search-key change (`bafb8c9`, objectstack-ai#21136) is noted as moot, because the route it changed is retired later in the same release (`3dc33b2`, objectstack-ai#21222). ## Open items for the cut These are recorded in the page's RELEASE-TIME TODO comment. - **Anonymous endpoints.** Under the deny baseline (objectstack-ai#21217), an app-declared anonymous endpoint (`authRequired: false`) can no longer read or write objects. objectstack-ai#21158 is still open, and until it lands no supported channel grants anonymous callers a permission set. The page says so instead of prescribing a grant. - **Console pin.** Three 17.6.0 server changes refuse a body that the pinned Studio (`31971ff1e28f`) still sends. objectui fixed each one after the pin: | Studio action | Server change that refuses it | objectui fix (not in the pin) | |:--|:--|:--| | A dataset count measure saved with the Field box blank (`field: ''`) | objectstack-ai#21240 | `0858267e` | | An External / Validate-only datasource saved without a credential | objectstack-ai#21133 | `8001068b` | | Republishing an html page that gained a plugin component | objectstack-ai#20852 | `3ae91930` | The page lists these under "Known console issues". If the pin moves before the cut, the lines it fixes come out. Otherwise the accepted-for-GA waiver is recorded. ## The correction to 17.5.0 One dated correction line is appended in place to the "Also shipped in 17.5.0" paragraph of `17-5.mdx`, in the form objectstack-ai#20985 used. It names the seven commits that shipped in 17.5.0 with no CHANGELOG entry and links to the 17.6.0 section. The original text is unchanged. ## Deliberately not in this PR - `content/docs/releases/v17/index.mdx` is **untouched**. Its status blockquote and per-release list may only claim 17.6.0 after the release ships. - An MDX comment at the top of `17-6.mdx` lists the release-time edits: 1. the publish date; 2. changesets landed after `748b2407`; 3. the per-package CHANGELOG entry count; 4. the console-pin outcome; 5. objectstack-ai#21158 if it lands first; 6. the `v17/index.mdx` update. ## Verification Run locally on the head commit, with the workspace installed. All of these pass: - `check-issue-citations --base origin/main`: every added citation resolves. - `check:doc-anchors`, `check:role-word`, `check:docs-audit-scope`, `check:nul-bytes`, `check:doc-authoring`, `check:docs-single-h1`, `check:docs-redirects`, `check:docs-locale-catch-all` - `check:corpus-claim-drift`, `check:docs-spec-enumerations`, `check:published-readme-links` - `check:release-notes`, `check:release-page-status`, `check:release-index-currency-sync`, `check:release-body` - `check-release-section-coverage` (plain and `--strict`), `check-doc-frontmatter`, `check-docs-nav-label`, `check-docs-section-name`, `check-section-landing-index`, `check-doc-route-spelling --advisory` Both pages compile as MDX with `@mdx-js/mdx` 3 + `remark-gfm`, and the three tables parse with no ragged rows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL --------- Co-authored-by: Claude <noreply@anthropic.com>
…count consumes it (objectstack-ai#21409) (objectstack-ai#21431) Fixes objectstack-ai#21409 Clause-②: no (narrowing) Dispatched by the PM claim `5953981594` (PM loop round 1, `domain:spec` seat 1), on the triage direction in the card body (the B answer `5952826307` to `5952467081`). Session `session_01UtnxvdiN376GF3sgXwAw4d`. The row wildcard `'*'` is what a `count` aggregates (`COUNT(*)`). It is now admitted in exactly one place, a measure that counts. Everywhere else it is refused at the authoring parse, naming the slot and prescribing a `count` or a column. The measured `500 DATABASE_ERROR` at `POST /api/v1/analytics/dataset/query` is now a `400 VALIDATION_FAILED`. ## What changes (`@objectstack/spec`) | position | slot | refusal | spelled as | |---|---|---|---| | 1 | cube measure `MetricSchema.sql`, under any `type` but `count` | `custom` at `sql` | refinement asking the ONE predicate | | 2 | cube dimension `DimensionSchema.sql` | `invalid_format` at `sql` | pattern `ANALYTICS_COLUMN_PATH` (the dataset dimension's own) | | 3 | dataset measure `DatasetMeasureSchema.field`, under any `aggregate` but `count` (or none: a `derived` measure) | `custom` at `field` | refinement asking the ONE predicate | | control | dataset dimension `DatasetDimensionSchema.field` | `invalid_format` (since PR objectstack-ai#21240) | unchanged | - **One predicate.** `rowWildcardOutsideCount(reference, aggregate)` and its refusal `rowWildcardOutsideCountRefusal(slot, aggregateKey, aggregate)` live in `packages/spec/src/data/analytics-column-reference.ts`, beside the column-reference grammar, outside the `data` barrel (not published API). Both measure refinements call them. Neither restates the rule. The pin asserts each issue IS the builder's output for its slot. - **Dropped refinements.** The two measure refinements are cross-field, so they cannot be a JSON-Schema `pattern`. They are declared in `dropped-refinements.baseline.json`: the roots `data/Metric` and `ui/DatasetMeasure`, plus the embedded sites the build printed (`data/Cube`, `ui/Dataset`, and the four installed-package API schemas). The measured counts move to 217 schemas / 652 sites. Position 2 is a `pattern`, so the published JSON Schema states it. - **ADR-0087.** One D3 entry: `migrations/entries/semantic/18.analytics-row-wildcard-outside-count-refused.ts`. `registry.ts` was regenerated by `gen:migration-registry`, never edited between markers. There is no D2 conversion: rewriting to `count` changes the figure the author asked for, and only the author can name a column. There is no `RETIRED_KEYS_BY_MAJOR` row, and no `STEP18_RATIONALE` fragment. That fragment is optional, and adding it would be a hand edit to `registry.ts` outside the claimed generated region. `spec-changes.json` and the upgrade guide stay at protocol 17, as for every major-18 entry, and both checks are green. - **Why an entry.** I judged this against `cube-member-sql-expression-retired` and `dataset-member-field-expression-refused`, the two accept-set narrowings of the same slots. Both register a D3 entry because a stored document needs a prescription and has no mechanical rewrite. The same holds here. - **Liveness.** `analytics_cube` `measures.sql` and `dimensions.sql` were the notes that pointed the count-only boundary at objectstack-ai#21000. They are re-pointed here. `dataset` `measures.field` states the narrowing. All three stay `live`, re-verified 2026-10-02. - **Docs.** `content/docs/references/ui/dataset.mdx` is regenerated: the measure `field` describe now says `"*"` is for a count. - **Changeset.** `@objectstack/spec` `minor`, with the BREAKING banner, the `(narrowing)` arm, FROM → TO and one ADR-0087 marker (`registered analytics-row-wildcard-outside-count-refused`). - **Runtime.** Unchanged. No strategy code in `service-analytics` was touched. ## Zone 1, read as written — one point flagged, not silently chosen The direction says "one cross-field rule per position, sharing one predicate". Position 2, a cube dimension, has no aggregate, so no rule there can be cross-field. Zone 2 item 2 says to find how the control (the dataset dimension, PR objectstack-ai#21240) spells its `'*'` refusal and follow it. The control spells it as a `pattern`, `ANALYTICS_COLUMN_PATH`, not as a refinement. So position 2 takes that same pattern, and the two dimension slots now publish one identical pattern. The cross-field predicate covers the two measure slots, where an aggregate exists. This is strictly stronger than a third refinement would be. The published JSON Schema carries this half, and no dropped-refinement row is needed for it. There is still one rule source (`COLUMN_PATH`, read twice) and one cross-field predicate (read twice). Nothing has a second spelling. ## The PM's mechanism assumptions, measured 1. **Confirmed.** On `ceb4a939b4`, `analytics-column-reference.ts` declared the shared grammar, and `ANALYTICS_COLUMN_REFERENCE` admitted `'*'` for every member. `cube-member-sql-column-reference.test.ts` pinned `'*'` on a cube dimension. That pin is now replaced by the refusal, because it pinned exactly the branch removed. 2. **Partly falsified.** The control is a pattern, not a refinement (see above). The measure positions are refinements (`superRefine` chained on the strict objects, the `DatasetSchema` precedent), because only they are cross-field. 3. **Measured. What a stored document meets now:** - **At `/meta` reads.** It is served as stored, with the refusal on `_diagnostics`. Probe through `computeMetadataDiagnostics` on the built spec: a stored dataset with `{ aggregate: 'sum', field: '*' }` reads back as `valid: false` with `measures.1.field` / `custom`. A stored cube reads back as `measures.total.sql` / `custom` and `dimensions.everything.sql` / `invalid_format`. A re-save through the write door is refused at the slot. - **At the dataset query door.** The route parses every dataset it is handed, inline or saved. A stored dataset carrying such a measure is refused `400 VALIDATION_FAILED` on every query. That includes a query that selects only its healthy `count`, which answered `200` before. It fails closed, never a stand-down, and the blast radius is the dataset. The door test pins both selections. - **Stored `analytics_cube` rows.** Read from code: these never reach the analytics registry. `serve.ts` feeds it from the stack definition's `analyticsCubes` only, and that parse (`defineStack`) refuses such a cube. ## Census: no producer (triage's "no producer is known", measured) - **This repo at `ceb4a939b4`.** `git grep` of every `field` / `sql` value spelled `'*'` over `examples`, `packages` (fixtures included), `content`, `skills`, `apps`, `scripts` and `docs` found 173 hits. Each was read in its enclosing object literal: 154 under a `count`. The other 19 are QueryAST aggregations (`function: 'sum', field: '*'` in objectql conformance tests, which is not one of the three positions), comments, and strategy-level `method: 'count'` literals. Zero sit at a non-count cube measure, a cube dimension or a non-count dataset measure. - One more author was found through a loop variable: the cube-dimension accept pin above. - **objectui at the `.objectui-sha` pin `89cad75d55`.** Read-only `git grep` at the pin found zero `field` / `sql` values spelled `'*'`. Lit controls: 51 `aggregate: 'sum'`, 438 `field: 'amount'`. A `'*'` scan of the 302 files mentioning `aggregate` found only `objectName: '*'` bus events, query-builder `'*'` and i18n required marks. None is a dataset or cube slot. - **Deployed metadata.** NOT MEASURED. ## The door cell: 500 → 400 `packages/rest/src/analytics-dataset-row-wildcard-door.test.ts` drives the real route over a real `ObjectQL` engine with a better-sqlite3 `SqlDriver`. It uses `AnalyticsServicePlugin`'s own composition, once per strategy, with read counters proving which strategy answered. - **Before.** I ran this test against the BASE spec build (`ceb4a939b4`, dist verified free of the new predicate): `Tests 20 failed | 4 passed (24)`. - Every inline cell answered `{"error":"Internal server error","code":"DATABASE_ERROR"}: expected 500 to be 400`. That held for `sum`, `avg`, `min`, `max` and `count_distinct` over `'*'`, on both strategies. - The saved dataset, querying its healthy count, answered `200`. - The four count controls were green. - **After.** On the fixed spec build: `Tests 42 passed (42)` (this file's 34 plus the neighbouring `analytics-16019-driver-declared-fault.test.ts`'s 8). - Every refused cell answers 400 `VALIDATION_FAILED` with the issue at `measures.2.field` (`custom`). - Raw-SQL and engine-aggregate counters stay at 0. - The `count`-over-`'*'` controls answer the row counts, `[{a,2,2},{b,1,1}]`, on native SQL (raw-SQL counter ≥ 1) and on ObjectQL (aggregate counter ≥ 1). - The cells for a saved dataset selecting the wildcard measure itself were added after the base run, so their base answer is NOT MEASURED. They compile the same measure the inline cells do. ## Tests (final union at `60644d73d9`, after the `main` merge) - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2` gave `Test Files 601 passed (601)`, `Tests 17647 passed | 1 todo`. - It includes the new `src/data/analytics-row-wildcard-count-only.test.ts` (31 cases: every non-count `AggregationMetricType` and `AggregationFunction` option, every `DimensionType`, the `derived` case, the controls, `CubeSchema` and the `analytics_cube` door, `defineCube`, `DatasetSchema` and the `dataset` door, `defineStack` with STACK_SCHEMA_INVALID / 422, the JSON-Schema halves with the ledger rows, and the D3 entry). - `pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/analytics-dataset-row-wildcard-door.test.ts src/analytics-16019-driver-declared-fault.test.ts` gave `Tests 42 passed (42)`. - Also run before the merge (`934b70a2db`; the incoming `main` commits touch neither package): - `rest --project local`: `256 passed (256)` files, 4848 tests. - `service-analytics`, as the main consumer of both shapes: `168 passed (168)` files, 3794 tests. - spec repo-project subset (`cube-member-inner-name-retirement`, `cube-refresh-key-retirement`, `step18-rationale-merge`, `liveness/evidence`, `liveness/proof-registry`): `131 passed`. - `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter @objectstack/rest typecheck`: exit 0. - The whole spec repo project (48 files) is NOT MEASURED locally. One run exceeded the foreground cap, so it is declared to CI. ## Ablation (one-shot, not kept) From the committed fix, through `scripts/ablation-replace.mjs`, `rowWildcardOutsideCount` was made to answer `false` (anchor 1 → 0, marker 0 → 1, blob `2bb692602dc8` → `4bdfba658269`). The new spec file went `19 failed | 12 passed (31)`. Red: the predicate table, every position-1 and position-3 cell, and the four door cases. Green: position 2 (a pattern, untouched by the predicate), every control, the JSON-Schema halves and the D3 pin. That is the predicted direction. Restored with `git checkout HEAD --`: blob equals the HEAD blob and `git diff HEAD` is empty, under a `trap` on EXIT/INT/TERM. The spec suite imports the source by relative path, so no `dist/` sits on its resolution path. ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths, merge base `39a912ea7`) derived 115 families. I ran all 115, and `--ran` reconciles **113 run green, 2 NOT MEASURED (exit 3, prerequisite), 0 UNRUN**: - `pnpm check:dual-build-cjs-loads`: needs every package's `dist`, which means a whole-repo build. - `pnpm check:type-check-debt`: its `--re-measure` builds the ledgered packages' closure itself, and that build passed the 300 s per-gate cap. - Both are whole-tree, and CI's `Lint & Repo Gates` runs them. - `check:skill-examples` first exited 3 (client-react unbuilt). After building `client` and `client-react` it exited 0 (`259 prose examples type-check`). - `pnpm --filter @objectstack/spec check:generated` passes all 15 artifacts after the merge. The only stale artifact before was `content/docs/references/**`, regenerated with `gen:docs`. - **Clause-② measured.** `node scripts/pm/check-widening-tells.mjs --declaration no --diff` (merge-base diff) exited 0 with no widening tell. It stated two silences: the `rowWildcardOutsideCountRefusal(` lines name an imported factory it does not resolve. The predicate is not exported from any published entry (`check:api-surface` green, artifacts byte-identical), so the arm is `no (narrowing)`, as triage wrote. - **ESLint (narrowed, a measurement).** - Population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`, and all 8 changed `.ts` files fall inside it. - Count: `--format json` read back 8 files, 0 errors, 0 warnings. - 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 repo-wide `pnpm lint` is CI's. ## Serial - PR objectstack-ai#21413 landed while this branch was in flight and touched `dropped-refinements.baseline.json` and `registry.ts`. I merged `main` through `scripts/pm/os-regen-merge.sh`. - The baseline conflicted on its `measured` counts only. It was not text-merged: I took `main`'s file and re-declared this branch's 12 sites, and the counts were recomputed from the entries. `gen:schema` then validated the ledger against the tree. - `gen:migration-registry` reproduced the auto-merged region byte-identically. objectstack-ai#21413's `agent-memory-store-retired-and-limits-required` entry is present at HEAD. - objectstack-ai#21365 had not landed at merge time. Whichever lands later merges `main`. ## Acceptance notes (not filed) - **Runtime inference mints the same shape, unreached by this parse.** `service-analytics` `inferMeasure` turns a caller-named measure with an empty prefix (`_sum`, `_avg`, `_min`, `_max`, `_count_distinct`) into `{ type: 'sum' …, sql: '*' }` (`key.slice(0, -suffix.length) || '*'`). The caller-measure gate admits `inferredSql === '*'`. Read from code only, NOT MEASURED at a door, and outside this card's no-strategy-edit surface. Carrier: the `domain:services` lane; no carrier named. - **A `derived` dataset measure's `field` is read by nothing.** The compiler skips it. This card now refuses `'*'` there, but any column value still parses inert. Observed while reading the compiler; no producer found. Carrier: none named. - **objectstack-ai#21000's enum retirement is untouched.** `AggregationMetricType` `number` / `string` / `boolean` are still covered by the predicate's "anything but count" for as long as they exist. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21220
Clause-②: yes
Dispatched by the PM claim
5938507454(PM loop round 1,domain:specseat 1), on the triage direction5938409101. An ADR-0021 dataset'sdimensions[].fieldandmeasures[].fieldnow 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, atdimensions.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 atminor, and the ADR-0087 markerregistered dataset-member-field-expression-refused, dataset-count-measure-empty-field-removed.Patch round 2 — contract review
5940617829, item ①.6The 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 toCOUNT()and answered 200. Its producer is Studio's dataset inspector. This round:New D2 conversion
dataset-count-measure-empty-field-removedinMAJOR_18_CONVERSIONS(order: 54, inserted at its identifier's sort position, defined directly aboveelementFilterRemoved).mainlandedform-field-public-picker-removedat 53, so this entry takes the next free number.fieldfrom acountmeasure whosefieldis exactly''. Without the key,compileDatasetemitssql: m.field ?? '*', which isCOUNT(*): the row count.time-default-utc-suffix-dropped, not recalled:toMajor: 18,retiredFromLoadPath: true(ADR-0087's ratified pre-GA policy for a lossless repair), andretiredAfter: '17.5.0'(the spec's current version, the same value the precedent carries).stripKeyshelper and emits one notice per removed key.'', asumover'', a count over'*', a count with nofield, 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 way18.time-default-zone-refused.tslinks its conversion. Itsreasonnow says what is true: the door refuses an expression, and it never judged''. It also says that D2 carries thecount+''repair and D3 the rest.acceptanceCriteria, theSTEP18_RATIONALEfragment, the changeset, the two ledger notes and thedataset.zod.tscomment are corrected to match.registry.tswas regenerated bygen:migration-registry.Pins. The new
src/conversions/dataset-count-measure-empty-field-removed.test.tscovers four things on a STORED row, throughapplyConversionsToStoredItem('dataset', row):The table-wide fixture replay in
conversions.test.tscovers 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/specpackages/spec/src/data/analytics-column-reference.tsdeclares the column path once (a bare identifier, then zero or more.identifierhops). It sits outside thedatabarrel, likeui/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.data/analytics.zod.ts,CUBE_MEMBER_SQLis now that sameRegExpobject:const CUBE_MEMBER_SQL = ANALYTICS_COLUMN_REFERENCE. The declaration stays in this file on purpose. ADR-0021's 2026-10-01 note links toanalytics.zod.ts#CUBE_MEMBER_SQL, and ADRs are a governed surface this PR does not edit. The cube members' JSON-Schemapatternis byte-identical; the new pin asserts it.ui/dataset.zod.ts:DatasetMeasureSchema.fielduses.regex(ANALYTICS_COLUMN_REFERENCE). Admitted: a column, a relationship path, or'*'. A count may still omitfield.DatasetDimensionSchema.fielduses.regex(ANALYTICS_COLUMN_PATH), so a dimension also refuses'*'(see measurement 3)..regex(), not refinements, so the published JSON Schema carries each as apattern.dropped-refinements.baseline.jsonis untouched.invalid_format. Each prescription opens with the contract sentence and names ADR-0021.filterform andderived: { op, of: [...] }, with the 0–1 ratio scale.describe()texts now say "never a SQL expression".content/docs/references/ui/dataset.mdxis regenerated from them.migrations/entries/semantic/18.dataset-member-field-expression-refused.ts.registry.tswas regenerated bygen:migration-registryand never hand-edited inside the markers.STEP18_RATIONALEfragment, inserted at the id's sort position withorder: 58. Round 1 used 57. After the round-2 base merge it takes 58, because 57 is allocated to the in-flight PR feat(spec)!: record:line_items gets its ComponentPropsMap row, its columns are the inline grid column contract, and the showcase Tasks grid binds by name (#21142) #21244's fragment. Neither list requires unique orders:step18-rationale-merge.test.tsmodels two fragments sharing oneorderand only asserts a positive integer, andmainalready holds two 56s. So whichever of the two PRs lands first, neither re-orders.dataset-count-measure-empty-field-removed, for the one lossless sub-shape (acountmeasure'sfield: ''; see Patch round 2). The D3 entry carries the rest: an expression has no mechanical rewrite into a column.RETIRED_KEYS_BY_MAJORrow: no key left the shape.datasetledger rowsdimensions.fieldandmeasures.fieldstaylive. Each is re-verified on 2026-10-01, with the narrowing recorded in itsnote.content/docs/data-modeling/analytics.mdxgains one "Key rules" bullet saying thatfieldis a column reference.Ratchets, as expected for a value narrowing. The
api-surface,authorable-surface,json-schema.manifest,export-originsanddeclaration-mapartifacts are byte-identical.spec-changes.jsonand 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
Confirmed.
dataset.zod.ts:125(dimension, required) and:189(measure, optional) were barez.string()at the based6d6e872.CUBE_MEMBER_SQLwas a module-privateconstatanalytics.zod.ts:240.Exporting
CUBE_MEMBER_SQLwould move the public surface.packages/spec/src/data/index.tsre-exports the whole module withexport * from './analytics.zod', so an exportedCUBE_MEMBER_SQLbecomes a new@objectstack/spec/dataexport and needsgen:api-surface. I took the non-public module instead.check:api-surface,check:export-originsandcheck:declaration-mapare green with zero changes to their artifacts.'*'on a dimension: measured, and refused. Readings come fromPOST /api/v1/analytics/dataset/querywith today's spec, through the real REST route, a realAnalyticsServiceand a real better-sqlite3SqlDriver, using a temporary probe test that was deleted afterwards (the tree is clean). The ObjectQL-strategy column bridgesexecuteAggregatestraight toSqlDriver.aggregate, not through the ObjectQL engine.field'*'DATABASE_ERROR(SELECT * AS ... GROUP BY *)DATABASE_ERROR(groupBy: ['*'])''''COUNT()it compiled to)'*'(control)'*'SUM(*))' amount'(sum)' industry'amount * 2PERMISSION_DENIED(PR fix(service-analytics)!: refuse a caller-supplied analytics member that is not a column reference at the door #21190's door)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 publishedpatternequals the cube's with only the\*|arm removed.⚠ Flagged, not silently chosen. The triage line reads "exactly the
CUBE_MEMBER_SQLaccept 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 cubeDimensionSchema.sqlstill admits'*', per ruling D's execution parameters, and is untouched here.Census, repo-wide, with a lit control. A scan of every
field:value in tracked files that mention a dataset anddimensions/measures, includingpackages/**tests,content/docs/**andskills/**.examples116,content88,skills15,platform-objects6,service-analytics587,spec423,lint282,rest92.fields found: only the fixtures that PR fix(service-analytics)!: refuse a caller-supplied analytics member that is not a column reference at the door #21190 wrote on purpose to drive its door:restanalytics-16019-driver-declared-fault.test.ts(translate(...),lower(name)) andservice-analyticsinline-dataset-field-admission-door.test.ts(an expression constant and a template). Both were re-pinned (next section).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,$fieldprose in a skill).spec,lint,service-analytics,metadata-protocolandrestare green on the narrowed contract.skills/**teaches an expressionfield, so no Tier H follow-up is owed.D2 for one sub-shape, D3 for the rest (corrected in round 2; the first round said "D3 only").
fieldalready answered 403 at the dataset door. On the REST route it is now refused one step earlier, at the route's ownDatasetSchemaparse, which the route runs on the inline and the saved branch alike. An expression has no mechanical rewrite.countmeasure withfield: ''answered 200 on SQLite's native path, and the door never judged it.dataset-count-measure-empty-field-removed, which every stored-row rehydration seam replays (applyConversionsToStoredItem, e.g.metadata-protocol'sconvertStoredItemDetailed). The runtime door is still reachable for a dataset handed toqueryDatasetunparsed: the build probe's dashboard-widget path (metadata-protocolbuild-probes.ts) passes the stored row as read.Fixture triage (two consumer tests the narrowing turns red; both re-pinned, not loosened)
service-analyticsinline-dataset-field-admission-door.test.tsbuilt its expression fixtures withDatasetSchema.parse, which now refuses them. The fixtures are now built UNPARSED, the shape a pre-narrowing stored row has, throughstoredDatasetWith. The controls still parse. One new case asserts that the contract refuses both fixtures atdimensions.0.field/measures.0.field. All 4 provider tiers x 2 strategies of the 403 door pins are unchanged and green.restanalytics-16019-driver-declared-fault.test.ts. The route parses every dataset first, so its inline and saved expression cases now answer400 VALIDATION_FAILED, where they answered403 PERMISSION_DENIED.400, plusinvalid_formatat the path insidedetail, the driver never called, and no expression text echoed.Tests
All runs are at head
0d5e446e(after mergingorigin/mainat3ddd3d0c) unless stated otherwise. Filter direction: each package's own suite, no consumer sweep.@objectstack/specvitest run --project local: 597 files, 17468 passed, 1 todo.src/ui/dataset-field-column-reference.test.tshas 11 cases.cube-member-sql-column-reference.test.tsstays green, with its'*'-on-a-cube-dimension case unchanged.@objectstack/service-analyticsvitest run: 162 files, 3741 passed, 45 skipped.@objectstack/lintvitest run: 119 files, 5502 passed.@objectstack/metadata-protocolvitest run: 200 files passed, 3 skipped; 2973 tests passed, 19 skipped.@objectstack/restvitest run --project local: 257 files, 4858 passed, 316 skipped.pnpm --filter PKG typecheckexit 0 for@objectstack/spec(tsc+check:scripts-typecheck+check:test-typecheck),@objectstack/service-analytics(itstsconfigincludes all ofsrc, 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-mdxandreferences-banner. 12 files, 294 passed.eslint --no-inline-config --format jsonover the 8 changed lintable files at0d5e446egave 8 files, 0 errors, 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.pnpm lintis CI's.Round 2 readings (final head
fc4e91c0;origin/main3dc33b2dmerged throughos-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 atfc4e91c0, the pushed head.turbo run buildover the closures of spec, cli, service-automation, metadata-protocol, rest and client-react: 59/59 tasks.@objectstack/specvitest run --project local: 597 files, 17465 passed, 1 todo. The counts moved withmain's merge.src/conversions/dataset-count-measure-empty-field-removed.test.ts, the table-wide fixture replay inconversions.test.tsandretired-after.census.test.ts.--project repo: the same 12 relevant files as round 1 (step18-rationale-mergeandconversions-major18-mergeamong them), 294 passed.@objectstack/climeta.report-order.test.ts(unit tier): 16 passed.@objectstack/service-automationdecision-overlapping-edge-conditions.pin.test.ts: 22 passed.@objectstack/metadata-protocolfull suite (it hosts the stored-row seam): 200 files passed and 3 skipped; 2973 tests passed and 19 skipped.rest,service-analyticsandlint: this round's diff does not reach them (spec only), and their round-1 readings stand.eslint --no-inline-config --format jsonover the 10 changed lintable files atfc4e91c0gave 10 files, 0 errors, 0 warnings. The population and invariance are as in round 1.dispatch-gates --commandsatfc4e91c0derived the same 115 families. All were run with exit codes recorded, and--ranreconciled 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 readsregistered dataset-member-field-expression-refused, dataset-count-measure-empty-field-removed, both new here.check:skill-examplesandcheck:dual-build-cjs-loadsboth 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 emptygit diff HEAD, and a clean status. The predicted direction was "turns red" in all four, and that is what was observed.fieldpattern admits anythingANALYTICS_COLUMN_REFERENCE(admits'*')'*'and the two pattern pins)ANALYTICS_COLUMN_PATHadmits anything, then@objectstack/specrebuiltablation-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-analyticsdoor test: the contract case red, 20 greenfc4e91c0)field !== ''guard reads a value no row carries)conversions.test.tsfixture pindataset-count-measure-empty-field-removed: before → after, emits 2 notice(s)and the stored-row pin are red; the controls are green75f4166c== HEAD;git diff HEADempty; status cleanNo ablation file is left in the tree.
Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, run at0d5e446ewith no paths, derived 115 families.--ranreconciled them: "115 derived, 114 run, 1 NOT-MEASURED, 0 UNRUN", every row carrying its recorded exit code.check:dual-build-cjs-loads. Reason: it exited 3 (PREREQUISITE NOT MET) because 44 packages outside this diff's build closure have nodist/, 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-examplesfirst exited 3 for want of a built@objectstack/client-react. After building that package it exited 0 at0d5e446e: 259 examples type-check.check:generated: all 15 artifacts up to date against a stamp-matched dist.check:adr-0087-registration: at the first round's head it readregistered dataset-member-field-expression-refused (new here).check:changeset-no-majorandcheck:empty-changeset.check:liveness,check:migration-registryandcheck:doc-authoring.check:cross-package-test-inputsandcheck:nul-bytes.Acceptance notes
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:data/analytics-column-reference.ts: the sharing change itself, which avoids a public export.content/docs/data-modeling/analytics.mdx: the skill's docs row.field. The route's existingDatasetSchema.parserefuses it first, asVALIDATION_FAILEDnaming the path. The changeset says so. The service door's 403 is unchanged.DatasetDefaultInspector.tsxat the pinned31971ff1eseeds 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 asfield: ''. That parsed before. Its query answered 500 on the ObjectQL path (the SQLite native path happened to acceptCOUNT()).measures.N.field, with the prescription to omit the key. A row already stored that way is repaired on load by the D2 conversiondataset-count-measure-empty-field-removed.fieldwhen the box is blank. It is reported to the seat, not edited here.sum(or any non-count aggregate) over'*'parses on a dataset measure and answers 500 on both strategies. That is the count-only'*'boundary theanalytics_cubeledger already assigns to spec+service-analytics: retire the cube metric typesnumber/string/boolean— they existed to carry a SQL expression, which #20943 retired from a member'ssql#21000's family.fieldanswered 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).