Commit aa46322
Fixes #21445
Clause-②: yes (narrowing)
## What this does
`ComponentPropsMap['object-grid']` (`ObjectGridPropsSchema`,
`packages/spec/src/ui/component.zod.ts`) now types the seven members
objectui's `ObjectGrid` reads with a fixed shape, and retires
`resizableColumns` to a `retiredKey()` tombstone naming `resizable`.
Direction: triage 5958164933, accepted; claim 5958653004.
Before this, `rowHeight: 42` passed every door and rendered as a compact
grid, and every other off-shape value was substituted or dropped with no
report. The public door now answers it. Measured through the
component-props gate (`validateComponentProps`, the rule `os validate` /
`os build` / `os lint` run) on this branch:
```
component-props-invalid properties.rowHeight rowHeight: Invalid option: expected one of "compact"|"short"|"medium"|"tall"|"extra_tall" (received 42)
component-props-invalid properties.resizableColumns `object-grid` property `resizableColumns` was removed in @objectstack/spec 17.7.0 (ADR-0049) — ...
component-props-unknown-key properties.operations.read Unrecognized key(s) on this `object-grid` operations block: `read`.
```
### Item 1 — seven members, each measured at the `.objectui-sha` pin
`89cad75d55` (`plugin-grid/src/ObjectGrid.tsx`)
| member | was | now | read point at the pin |
|:--|:--|:--|:--|
| `rowHeight` | `z.unknown()` | `RowHeightSchema`, by reference |
`resolveRowHeightMode` :1311 |
| `rowColor` | `z.unknown()` | `RowColorConfigSchema`, by reference |
`useRowColor` :2955 (`field`, `colors`) |
| `navigation` | `z.unknown()` | `NavigationConfigSchema`, by reference
(the carrier `object-kanban` / `object-calendar` / `object-timeline`
already take) | `useNavigationOverlay` :2894 |
| `conditionalFormatting` | `z.unknown()` |
`ListViewSchema.shape.conditionalFormatting`, the list view's own member
| `resolveConditionalFormatting` :2964 |
| `bulkActionDefs` | `z.array(z.unknown())` |
`z.array(BulkActionDefSchema)`, the list view's element |
`resolveBulkActions` :4778 |
| `aggregations` | `z.unknown()` | `[{ field, type }]`, `type` =
`AggregationFunction` (the query AST vocabulary, by reference) |
`useGroupedData` / `useServerGroupHeaders` :3095 / :3113 |
| `operations` | `z.unknown()` | `{ create?, update?, delete?, export?
}` booleans, strict; `read` / `import` refused with guidance | `create`
:5468, `update` / `delete` :1898-1899, `export` :4088 / :5340 / :6141 |
- `aggregations` and `operations` have no list-view counterpart, so they
declare the measured shape in two module-private schemas
(`GridAggregationSchema`, `GridOperationsSchema`). No export is added:
`check:api-surface` is unchanged.
- **`rowHeight`, mechanism assumption 4.** The renderer's set and the
schema's set match. `resolveRowHeightMode` tests membership against
`ROW_HEIGHT_TO_DENSITY_MODE`, which is typed as a record keyed by the
spec's `RowHeight`, so it admits exactly the five values. Anything else
answers `compact`.
- **`aggregations`.** The grid's vocabulary (`AggregationType` in
`useGroupedData.ts`) is exactly `AggregationFunction`'s six members. The
pin holds that set, so a member added to the query AST enum reds here
before it widens this door past the grid's reader.
- **`operations`.** These are the four members any read point names.
objectui's TypeScript twin also declares `read` and `import`, which
nothing reads. Each is refused with that reason, not accepted as a
toggle that toggles nothing.
### `bulkActionDefs` measured against `BulkActionDefSchema`: the gap,
reported and not widened
The renderer reads members the schema lacks. Every one of these gaps
already stands on the list view's door, which takes the same schema:
- **Def level.** `name`, `label`, `icon`, `variant`, `operation`,
`execution`, `patch`, `params`, `confirmText`, `confirmLabel`,
`visible`, `requiredPermissions`, `maxRecords` and `batchSize` are all
declared. `actionDef` is read too (`useBulkExecutor`,
`BulkActionDialog`), and the schema refuses it on purpose with
`guidance`, because the renderer attaches it and an author does not
write it.
- **`params[]`.** `bulkParamToField` spreads every undeclared param key
onto the widget's field bag, so `min` / `max` / `step` / `accept` /
`lookupFilters` … reach a widget that reads them. The schema refuses
them (`BULK_PARAM_WIDGET_CONFIG_KEYS`, the maintainer ruling recorded in
`bulk-action.zod.ts`).
- **`visible`.** objectui's `ExpressionWire` takes `{ dialect?, source
}` with `dialect` optional. The spec's `EvaluatedExpressionInputSchema`
envelope requires `dialect`.
- **`params[].type`.** objectui types it `string`, while the spec types
it `FieldType`.
### `conditionalFormatting`: one dialect, the list view's
The shared evaluator (`@object-ui/core` `resolveConditionalFormatting`)
also tolerates two objectui-native rule spellings, `{ field, operator,
value, backgroundColor, … }` and `{ expression, … }`. The list view
declares neither, so the grid door follows the list view and refuses
both. The census found no bag that writes either.
### Item 2 — `resizableColumns` retired (objectui#6152 ruling A)
- **Tombstone.** `resizableColumns` is a `retiredKey()` with the house
prescription: "Use `resizable`. Rename the key; the value (a boolean) is
unchanged." plus the `os migrate meta --from 17` sentence. `tsc` refuses
it, and so does the parse. `resizable` still parses.
- **D2 conversion `object-grid-resizable-columns-removed`.** It is
protocol 18, `retiredFromLoadPath`, `retiredAfter` `17.6.0`, order 57,
and it follows `resizable ?? resizableColumns` exactly:
- when `resizable` is absent (or null), the value moves to `resizable`
unchanged;
- when `resizable` holds a value, the key strips as a lossless delete.
It is scoped by component `type`. The fixture covers a region grid, a
both-keys grid with different values, a kanban sibling and a grid
without the key (both untouched), a nested grid and a slotted grid: 4
notices.
- **Registration.**
- `RETIRED_KEYS_BY_MAJOR[18]` carries
`ui/ObjectGridProps:resizableColumns`, written by
`gen:migration-registry`.
- The D3 entry `object-grid-resizable-columns-retired` carries
`conversionIds`, and it carries the judgment for a grid that authored
both keys with different values.
- Two `STEP18_RATIONALE` fragments are added at orders 62 / 63.
- **The typed members' D3 entry** is `ui-object-grid-row-members-typed`.
It has no conversion: a page component's `properties` is not parsed on
the save or load path, and an off-shape value has no rewrite that keeps
what the grid shows while honouring what the author wrote.
- **Changeset.** `@objectstack/spec` `minor`, `!`, **BREAKING** banner,
a FROM → TO table, and one ADR-0087 `registered` marker naming all three
ids.
## Census of authored writers (Zone 2 assumption 3)
- **objectstack** `origin/main` `53fd35e3e3`: the examples,
`packages/platform-objects`, the spec tests, `content/docs` and
`skills`. Zero `object-grid` blocks author any of the seven members or
`resizableColumns`. Control: the same matcher finds `columns` on both
showcase grids.
- **objectui** at the pin `89cad75d55`, over 335 `object-grid` nodes, of
which 76 carry a `properties` bag (23 of those in parsed JSON
documents). The control: `columns` is found in 46 bags.
- The one value a new shape refuses is objectui's root `README.md` "Data
Grid" example, `"operations": { "create": true, "read": true, "update":
true, "delete": true, "export": true }`. Its `read: true` is now
refused.
- objectui's own `content/docs/api/schema-reference.md` already dropped
`operations.read` from its example as a key "which nothing reads"
(objectui#11068).
- **This producer is named in the dev report as a `needs_decision`.**
The recommendation is to keep the refusal as implemented and correct the
README in the objectui#6152 follow-up. The alternative is to declare
`read` / `import` as two toggles that toggle nothing.
- `resizableColumns`: zero writers in either repository. objectui's own
mentions at the pin are its TypeScript twin and flat mirror (deprecated
/ tombstoned), the `data-table` component's own prop, and the `??
schema.resizableColumns` read itself.
## Clause-② — the measured arm
| run | exit | reading |
|:--|:--|:--|
| `check-widening-tells --declaration no --diff` (final diff) | 4 | 8
tells, below |
| `check-widening-tells --declaration yes --diff` | 0 | the gate never
blocks a `yes` |
- **T1, the bulk of the tells.** These fire on the new keys inside the
two former `z.unknown()` bags (`aggregations[].field`, the four
`operations` toggles). The gate's own text rules that shape: it is "a
true refusal although it only narrows … declare `Clause-②: yes`". So the
arm is `yes (narrowing)` although no export-listing row is added.
- **T2, three tells.** These fire on the three new names in the
multi-line `import { … } from './view.zod'` list, read as members of a
closed set. They are a false positive of the matcher, noted below.
## Tests — at `52c4c42d72`
- **`component-object-grid-typed-members.pin.test.ts`** (new, `local`
project, 52 tests).
- §1: each declared shape parses. It parses byte-identical, or to
exactly what the shared schema answers for `navigation` (its defaults)
and `conditionalFormatting` (the condition envelope).
- §2: each off-shape value is refused, with the code AND the path.
`rowHeight: 42` is `invalid_value` at `rowHeight`; `operations.read` is
`unrecognized_keys` at `operations`, with its guidance text; a no-op
`custom` bulk def is `custom` at `bulkActionDefs.0.execution`.
- §3: identity of each by-reference def, plus the exact
`AggregationFunction` set and the exact `operations` key set.
- §4: the tombstone. `resizableColumns: true|false` is refused with the
prescription, alone and beside `resizable`. `resizable: true|false` is
byte-identical. A `@ts-expect-error` assignment of `resizableColumns` to
`ObjectGridProps` is consumed, so the `tsc` channel is proven by
`check:test-typecheck` staying green.
- §5: the conversion's two arms and the untouched grid, and the step-18
/ `RETIRED_KEYS_BY_MAJOR` wiring.
- **`object-grid-resizable-columns-retirement.test.ts`** (new, `repo`
project). This is the tree-scoped absence pin over the five roots
`@objectstack/spec` already declares in
`scripts/cross-package-test-inputs.mjs`, registered in
`vitest.repo-tests.json`.
- `pnpm --filter @objectstack/spec exec vitest run --project local
--maxWorkers=2`: **Test Files 602 passed (602), Tests 17769 passed | 1
todo**.
- `pnpm --filter @objectstack/spec typecheck`: exit 0.
`check:test-typecheck: OK — 52 file(s) / 246 error(s) / 135 pinned
signature(s)`, unchanged.
- **`repo` project.** 45 of its 50 files were run green, in four
targeted runs. The five not run locally are `build-schemas-check-mode`,
`dist-freshness`, `dist-freshness-adoption`,
`publish-smoke-boot-failure` and `publish-smoke-port-collision`. They
are script-machinery tests this diff does not touch;
`build-schemas-check-mode` alone runs 7-18 s per case and the project
overruns the ~10-minute foreground cap. **NOT MEASURED locally; declared
to CI.**
### Ablations: one-time proofs, each restored by
`scripts/ablation-replace.mjs` to blob == HEAD with `git diff HEAD`
empty
The pin imports `./component.zod` by relative path, so it reads `src/`
and no build sits between the mutation and the run.
| mutation | result |
|:--|:--|
| `rowHeight` back to `z.unknown()` | 3 failed / 49 passed: both
`rowHeight` refusals and the identity pin |
| tombstone key renamed away (`resizableColumns` becomes an undeclared
key) | 3 failed: all three prescription pins (a bare `unrecognized_keys`
is not the tombstone) |
| `operations` back to `z.unknown()` | 6 failed: four refusals, the
guidance-text pin and the key-set pin |
| a `resizableColumns: false` planted in
`examples/app-showcase/src/ui/pages/my-work.page.ts` | the absence pin
fails, naming that file |
The first attempt at the plant was refused by the tool, because the
replacement contained its own anchor. It wrote nothing, and it was
re-run with a non-overlapping anchor.
## Gates
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derives **114** families at `52c4c42d72`, identical to the
seat's derivation. **All 114 exit 0** on that head; each exit code was
captured before any pipe.
- `--ran` reconciliation: "114 derived famil(ies) accounted for — 114
run, 0 NOT-MEASURED (a DERIVED zero — all 114 recorded an exit code and
none of them is 3)".
- `pnpm --filter @objectstack/spec check:generated` passes on the final
head. Two artifacts were regenerated with `--fix` because the check
proved them stale:
- `content/docs/references/ui/component.mdx`;
- `docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md` (+2
strict sites).
- **Lint, a declared narrowing.** `node --stack-size=4000
node_modules/eslint/bin/eslint.js --no-inline-config --format json` ran
on the changed `.ts` files: **10 files, 0 errors, 0 warnings**.
- Population: `eslint.config.mjs`'s
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`.
- 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 full `pnpm lint` is CI's.
## Acceptance notes (observations, not filed)
- **`dropped-refinements.baseline.json` gains one site.**
`ui/ObjectGridProps`'s `bulkActionDefs.element` is the list view's own
`BulkActionDefSchema` refinement now reaching the grid door, and the
gate printed the corrected entry. `droppedRefinementSites` moves 652 →
653.
- **The regenerated reference page** now renders the shared
`grouping.fields[]` nested shape with `order?` / `collapsed?` (their
input face). This is generator output, not a hand edit.
- **Same family, other rows, not touched here.** `object-kanban`'s
`conditionalFormatting` is still `z.unknown()`, and three other rows
declare `navigation: z.unknown()`. They are named for the family's
closure card in the dev report.
- **`check-widening-tells` T2 false positive.** A name added to a
multi-line `import { … }` list is read as a new member of a closed set.
It is named in the dev report.
- **objectui follow-up (objectui#6152), once a release carrying this is
installable:**
- the TypeScript `resizableColumns` member becomes `never`, the flat
mirror gets a tombstone, and the `?? schema.resizableColumns` read is
dropped;
- the README's `operations.read` comes out of its example;
- the twin's `operations.read` / `.import` go;
- the grid's two-dialect `conditionalFormatting` typing converges on the
spec rule.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49524f6 commit aa46322
14 files changed
Lines changed: 1167 additions & 28 deletions
File tree
- .changeset
- content/docs/references/ui
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages/spec
- authorable-surface
- src
- conversions
- migrations
- entries
- retired-keys
- semantic
- ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
0 commit comments