Repository navigation
feat(spec)!: an object-grid page block types the seven members the grid reads, and resizableColumns retires for resizable (#21445) - #21463
Conversation
…tombstones resizableColumns Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…key and D3 entries, step-18 rationale Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…lumns tombstone, plus the tree-scoped absence pin Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…he typed bulkActionDefs and the tombstone Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…ess-ledger counts Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…umns retirement Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…ject-grid-typed-members
…heck:objectui-pin-citations Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…ng computed, or no number Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 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 60e9e03a3d51e898eeaa90f514205fea20de1f86 && git checkout 60e9e03a3d51e898eeaa90f514205fea20de1f86
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 713b0fa76dbb6c0f60472cbbefd7690eedb1286e 38159d1362f38beff333f798c34530ad821598c1 && git checkout -B drift-repro 713b0fa76dbb6c0f60472cbbefd7690eedb1286e && git merge --no-ff 38159d1362f38beff333f798c34530ad821598c1
node scripts/docs-audit/affected-docs.mjs --json 713b0fa76dbb6c0f60472cbbefd7690eedb1286e
|
Contract reviewServed-tier: Isolated contract reviewer for PR #21463 (card #21445), written at 2026-10-02T20:50Z. Inputs: the card body and its four comments (triage ① Derived judgmentsThe seven typed members of
The census, re-checked. objectstack Generated artifacts.
② Semver level
Changeset, sentence by sentence: the ③ Boundary flagsDev deviations:
Implemented-by: VERDICT: PASS |
…ject-grid-typed-members
|
Regen-provenance: 5961282483 ·
|
…reference page (objectstack-ai#21478) Fixes objectstack-ai#21466 Clause-②: no ## What was wrong A generated reference page gave two optionality answers for one shared def. The card measured it on PR objectstack-ai#21463's regeneration of `content/docs/references/ui/component.mdx`: `ObjectGridProps.grouping.fields[]` rendered `order?` / `collapsed?`, and `data[provider='api'].read` / `.write` rendered `method?`, while the same defs on the `ObjectKanbanProps`, `ObjectGanttProps`, `ObjectMapProps` and `ObjectTreeProps` rows rendered them required. ## Mechanism: measured, and it is not evaluation order The card's working hypothesis was an evaluation-order dependence: a memoised conversion, a `.shape` getter or a def cache decided by whichever caller came first. Measured, there is none: - **Two-order harness.** The five props schemas go through the generator's own `projectPublishedJsonSchema` and its output-to-input fallback, under 2 import orders (`ui/view.zod` first or `ui/component.zod` first) times 2 emission orders (grid first or grid last), with `OS_EAGER_SCHEMAS=1` as `gen:schema` runs. All four orders print byte-identical output, with 1 distinct output per tree. That holds on `main` at `6210f8870a`, on objectstack-ai#21463's head `38159d1362`, and on this branch's final head. - **What actually flips is the document's io mode.** On objectstack-ai#21463's head, `ui/ObjectGridProps.json` is the one props schema emitted "(input shape)". Its output projection throws "Transforms cannot be represented in JSON Schema", because one newly typed member carries a transform. So `build-schemas.ts` falls back to `io: 'input'` for the whole document. Input mode leaves `.default()` members out of `required`, output mode lists them, and both keep `default`. - **The renderer leaked that mode.** The Type column's `{ … }` shape summary marked a key optional from `required` alone (`scripts/lib/format-type.ts`). The Required column already read `default` (`renderRequiredCell`, objectstack-ai#8703's rule). Before this change, objectstack-ai#8703's rule covered only one of the two places a page states optionality. Corpus reading on `main` `6210f8870a`, with the old renderer: 1398 exports project in both io modes. When each section is rendered from both projections, 215 of them differ, over 483 lines. Each of the 483 lines differs only by this marker. ## Fix There is one predicate now, `isAuthorOmittable(prop, required)` in `format-type.ts`: `default` decides, and `required` breaks the tie. Both optionality positions read it: the shape summary's `key?:` marker and `renderRequiredCell`, whose output is unchanged byte for byte. What does not change: - No emitted JSON Schema. Under objectstack-ai#8703's design, the published files keep describing the post-parse shape. - No `packages/spec/src/**` change. - No per-row patch, and no hand edit of a generated page. Afterwards the same corpus measurement reads 0 of 1398 differing (0 lines) on `6210f8870a`, and 0 of 1392 on the final head `d5d88f55a5`. The population is smaller on the final head because `ObjectGridProps` is input-only there and `main`'s agent.lifecycle retirement removed some exports. ## Regenerated pages (the second pin) `check:generated --fix` on the base changed 459 rows on 86 pages. Each changed row only adds `?` to defaulted members (932 markers in total). No row removes one, and no other byte moved. `main` was merged in twice, and each time the colliding generated page was regenerated on the merged tree rather than text-merged: - The first merge brought objectstack-ai#21463 and regenerated `component.mdx`. - The second brought `main` at `0b8239111f` (the agent.lifecycle retirement plus one non-docs commit) and regenerated `agent.mdx`. `automation/state-machine.mdx` stays deleted, as `main` has it. Against `main` at `0b8239111f` (and identically against `6e33b67912`), the references delta is 453 rows on 85 pages, 925 markers added. With every `?:` normalised to `:`, each of the 85 pages is byte-identical to main's copy. No row removes a marker. On the final `component.mdx`: - `method?:` appears 8 times and `method:` 0 times; `collapsed?:` 3 times and `collapsed:` 0 times. - The grid's and the kanban's `grouping.fields` rows are byte-identical. - The four `data[provider='api']` `read` rows are byte-identical, and so are the four `write` rows. The same JSON from objectstack-ai#21463's head, rendered with the old renderer, gives the card's exact rows (`order?` on the grid and `order` on the kanban). Rendered with this branch's renderer, every shared-def row is identical. ## Pin `packages/spec/scripts/schema-section.test.ts` has a new block, "one shared def renders one face, whichever io mode its document took". Two parent rows share a grouping def and a `{ url, method }` def through the generator's own projection. One parent projects in output mode; the other projects only in input mode, because it has a transform member. - Two precondition cases prove the fixture really is one output-mode and one input-mode document, and that their `required` arrays disagree. So the identical rendering comes from the renderer, not the fixture. - The render case requires the shared defs' rows to be byte-identical and on the input face. - A fourth case pins the marker on hand-written nodes. Reverse verification used the committed fix at `3a62f4a391`. The mutation went through `scripts/ablation-replace.mjs`: anchor count 1 to 0, blob changed. The restore was proven: blob equals HEAD and `git diff HEAD` is empty. Putting back the old `required`-only marker turns 2 of 137 cases red: the render case and the hand-written summary case. The two precondition cases stay green, as designed. The subject resolves to source through a relative import, so no `dist/` leg applies. ## Gates At the final head `d5d88f55a5`, after the second merge: - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: 602 files, 17769 passed, 1 todo. - `check:generated` ("All 15 generated artifacts are up to date"), `check:docs` and `check:nul-bytes`: exit 0. At `ce1d8ceaf8`, before the second merge, which brought only main's commits and one regenerated page: - `pnpm --filter @objectstack/spec typecheck` (the build program, `tsconfig.scripts.json` and the test program): exit 0. The three touched TS files are in the `tsconfig.scripts.json` program (checked with `--listFilesOnly`). - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran`: 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN, every one exit 0. That includes `check:generated`, `check:docs` and `check:nul-bytes`. This full union was not re-run at `d5d88f55a5`. - Lint was narrowed to the 3 touched TS files: `eslint --no-inline-config --format json` reads 3 files, 0 errors, 0 warnings. The `.mdx` pages match no lint `files` glob in `eslint.config.mjs`. That config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. ## Changeset `skip-changeset`, measured: - `@objectstack/spec`'s `files[]` is `dist`, `json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`, `src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface` and `spec-changes.json`. - This diff touches only `packages/spec/scripts/**` and `content/docs/references/**`. The docs site, `apps/docs`, is private. - After a build, `isAuthorOmittable` and `carriesDefault` match 0 files under those paths. The positive control, `lazySchema`, matches 240. - `check:generated` moved no published artifact. ## Acceptance notes - The two-order harness and the corpus io-face measurement were one-time readings from scratch scripts and are not committed. The committed pin is the two-parent-row block above. - Open edge, with no instances today: the predicate reads a member's own `default`. A member spelled as a bare `$ref` to a defaulted def would carry `default` only on the def, not on the property node. The corpus reads 0 such members (0 differing lines in either column after the change). Carrier: none. - The io fallback is per document by design: one transform anywhere moves the whole document to the input projection. After this change that no longer shows on the page. It does still decide which JSON Schema face `json-schema/` publishes for that document, and that is out of this card's scope. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… by reference, and one pin enumerates every z.unknown() member of ComponentPropsMap (objectstack-ai#21464, stage 1) (objectstack-ai#21502) Part of objectstack-ai#21464 Clause-②: yes (narrowing) ## What this does This is the first stage of the `ComponentPropsMap` `z.unknown()` close-out (triage `5961300594`, claim `5962381903`). It does two things. 1. **`navigation` is typed on `object-map`, `object-gantt` and `object-tree`.** Each row took `z.unknown()`, although each renderer hands the member to the console's shared navigation hook, which reads `navigation.mode` and falls back to `page`. Any value passed the component-props gate, and an off-shape one opened the record page in silence. Each row now takes the list view's `NavigationConfigSchema` by reference. That is the block `object-grid`, `object-kanban`, `object-calendar` and `object-timeline` already take. 2. **One enumeration pin holds the whole map.** `packages/spec/src/ui/component-props-unknown-members.pin.test.ts` walks every `ComponentPropsMap` row, into arrays, record values, union arms, lazy schemas and catchalls. It fails on any `z.unknown()` member that is neither typed nor listed with a recorded reason. It also fails on a ledger line whose member is no longer `z.unknown()`, so the ledger can only shrink as later stages type members. **Why `Part of`, not a closing line:** the census found more than one reviewable PR's worth (the dispatch's size rule, A7). There are 31 renderer-read members. The precedent, PR objectstack-ai#21463, spent about 1,100 lines on 7 members. The other 28 are recorded in the pin as `staged`, each with its reader at the pin and a named stage. The staging proposal below is in the report for a ruling. objectstack-ai#21464 remains open for those stages. ### `navigation`, measured at the `.objectui-sha` pin `89cad75d55` | row | was | now | read point at the pin | |:--|:--|:--|:--| | `object-map` | `z.unknown()` | `NavigationConfigSchema`, by reference | `plugin-map/src/ObjectMap.tsx:1189` hands it to `useNavigationOverlay`, which reads `navigation?.mode ?? 'page'` (`react/src/hooks/useNavigationOverlay.ts:364`) | | `object-gantt` | `z.unknown()` | `NavigationConfigSchema`, by reference | `plugin-gantt/src/ObjectGantt.tsx:1960` reads `schema.navigation ?? { mode: 'drawer' }` and hands it to the hook (`:2025-2026`) | | `object-tree` | `z.unknown()` | `NavigationConfigSchema`, by reference | `plugin-tree/src/ObjectTree.tsx:1054` hands it to the hook (`:1046`) | objectui's own node types and Zod mirrors already declare this member as `ViewNavigationConfig` or the spec's `NavigationConfigSchema` (`types/src/objectql.ts`, `types/src/zod/objectql.zod.ts`). The spec row now agrees with its sibling. **No measured writer is refused (A3).** - **objectstack, on the merged head:** 5 files per row name the type. None authors `navigation` on one of these blocks. The one co-mention writes app navigation arrays. Control: `objectName` is found in 4 of the 5 files per row. - **objectui, at the pin:** 88 / 98 / 59 files name `object-map` / `object-gantt` / `object-tree`. Control: `objectName` is found in 55 / 70 / 40 of them. They contain 77 `navigation:` lines. Every distinct authored literal parses on all three rows, 36 of 36: `{ mode }` with each of the seven modes, plus `size: 'lg'` and `openNewTab`. - The values that do not parse are probes that already expect a refusal. `navigation: 'anything'` is a both-faces parity probe. `navigation: 'drawer'` sits under a `@ts-expect-error`. `{ mode: 'sideways' }` is an expected `navigation.mode` issue. - The `{ mode: 'drawer', width: '600px' }` example belongs to `object-view` (`plugin-view/src/ObjectView.tsx:1060`), not these rows. ## The census (A1), whole The census covers 107 `z.unknown()` members across 56 rows, on the merged head. It is unchanged from the base minus the three this PR types. Writer counts are file co-mentions (a file naming the type and authoring `KEY:`), with a control key. They are an upper bound, not a value census: each later stage runs its own value-level census first. **31 members a renderer reads with a fixed shape.** Citations are objectui at `89cad75d55`. Writers are "os N/M (control)" for objectstack and "ou N/M (control)" for objectui. | row | key | reader at the pin | by-reference candidate | writers | decision | |:--|:--|:--|:--|:--|:--| | object-map / object-gantt / object-tree | `navigation` | see the table above | `NavigationConfigSchema` | os 0 on the row · ou 36/36 literals parse | **typed here** | | object-metric | `aggregate` | `plugin-dashboard/src/ObjectMetricWidget.tsx:250`, `.field` / `.function` at :404 | chart `aggregate` (`chart.zod.ts:644`) | os 7/8 (7) · ou 17/36 (20) | stage `object-metric` | | object-metric | `trend` | `ObjectMetricWidget.tsx:254` (typed :176) | none: to the renderer's read | os 0/8 · ou 1/36 | stage `object-metric` | | object-metric | `drillDown` | `ObjectMetricWidget.tsx:265` (`ObjectMetricDrillDownConfig` :218) | chart `drillDown` (`chart.zod.ts:663`) | os 0/8 · ou 7/36 | stage `object-metric` | | object-metric | `compareTo` | `ObjectMetricWidget.tsx:267` (`CompareToConfig` :241) | dashboard widget `compareTo` (`dashboard.zod.ts:1205`) | os 1/8 · ou 4/36 | stage `object-metric` | | object-form | `fields[]` | `plugin-form/src/ObjectForm.tsx:961` | none | os 11/14 (12) · ou 178/210 (188) | stage `object-form` | | object-form | `customFields` | `ObjectForm.tsx:755`, :1180 | none | os 0/14 · ou 21/210 | stage `object-form` | | object-form | `sections[]` | `ObjectForm.tsx:364`, :1518 | `FormViewSchema.sections` | os 3/14 · ou 78/210 | stage `object-form` | | object-form | `contentLayout` | `plugin-form/src/ModalForm.tsx:854` (`'simple'` or `'tabbed'`, :151) | none | os 0/14 · ou 7/210 | stage `object-form` | | object-form | `submitBehavior` | `ObjectForm.tsx:1312-1313` | `FormViewSchema.submitBehavior` | os 2/14 · ou 12/210 | stage `object-form` | | object-form | `navigateOnSuccess` | `ObjectForm.tsx:1373`, :1412 | none | os 0/14 · ou 5/210 | stage `object-form` | | object-form | `mobile` | `ObjectForm.tsx:1857` | none | os 0/14 · ou 7/210 | stage `object-form` | | object-master-detail-form | `sections[]`, `fields[]` | `plugin-form/src/MasterDetailForm.tsx:1692-1693`, into the parent form | as `object-form` | os 2/4, 4/4 (4) · ou 9/29, 18/29 (21) | stage `object-form` | | object-grid | `columns[]` | `plugin-grid/src/ObjectGrid.tsx:2158` (`normalizeColumns`, string or `ListColumn`) | `ListViewSchema.columns` | os 7/21 (16) · ou 198/292 (248) | stage `list-family` | | object-grid | `fields[]` | `ObjectGrid.tsx:1946` | none | os 7/21 · ou 191/292 | stage `list-family` | | object-grid | `selection` | `ObjectGrid.tsx:4799-4810` (`.type`) | `ListViewSchema.selection` | os 1/21 · ou 19/292 | stage `list-family` | | object-grid | `selectable` | `ObjectGrid.tsx:4813-4815` | none | os 0/21 · ou 7/292 | stage `list-family` | | object-grid | `rowActions[]` | `ObjectGrid.tsx:1834-1835` (`string[]`) | `ListViewSchema.rowActions` | os 1/21 · ou 19/292 | stage `list-family` | | object-grid | `bulkActions[]`, `batchActions[]` | `ObjectGrid.tsx:4763` (`batchActions ?? bulkActions`) | `ListViewSchema.bulkActions` | os 2/21, 0/21 · ou 27/292, 7/292 | stage `list-family` | | object-kanban | `columns[]` | `plugin-kanban/src/KanbanBoardCore.tsx:95` | none | os 4/12 (9) · ou 87/152 (99) | stage `list-family` | | object-calendar | `calendar` | `plugin-calendar/src/ObjectCalendar.tsx:296-297` (`ObjectCalendarConfig`) | `ListViewSchema.calendar` | os 1/6 (6) · ou 50/83 (62) | stage `list-family` | | object-gantt | `markers[]` | `plugin-gantt/src/ObjectGantt.tsx:2497` (`GanttMarker`) | none in the spec | os 0/5 (4) · ou 6/98 (70) | stage `objectui-held` | | object-timeline | `items[]` | `plugin-timeline/src/ObjectTimeline.tsx:587` | none in the spec | os 3/3 (3) · ou 12/45 (34) | stage `objectui-held` | | object-timeline | `mapping` | `ObjectTimeline.tsx:551`, :576-579 (`TimelineMappingSchema`) | none in the spec | os 2/3 · ou 2/45 | stage `objectui-held` | | action:group | `actions[]{}` | `components/src/renderers/action/action-group.tsx:303` (`UIActionSchema[]`) | none in the spec (`UIActionSchema` is objectui's interface) | os 2/3 (label 3) · ou 23/36 (26) | stage `objectui-held` | | action:menu | `actions[]{}` | `components/src/renderers/action/action-menu.tsx:339` (`UIActionSchema[]`) | as above | os 2/4 (3) · ou 23/38 (28) | stage `objectui-held` | | object-kanban | `conditionalFormatting` | `KanbanBoardCore.tsx:114`, evaluated at `KanbanImpl.tsx:179` | `ListViewSchema.conditionalFormatting` | os 1/12 · ou 9/152 | **held for a ruling (A3)** | **76 members with a recorded reason, grouped:** - **slot (7):** child-component lists. Each is a declared slot position (§2 checks this), judged at its own node: `page:tabs` / `page:accordion` `items[].children[]`, `page:card` `children[]` / `footer[]`, `page:footer` / `page:sidebar` / `page:section` `children[]`. - **runner (16):** `action:button` and `action:icon` `params`, `bodyExtra`, `bodyShape`, `operation`, `patch`, `toast`, `resultDialog` and `onSuccess`. Each `.describe()` says "forwarded to the runner" (§2 checks this). - **records (18):** record rows and field values, shaped by the bound object's fields: `staticData[]`, `data[]`, `data.items[]`, `initialValues{}` / `initialData{}`, and the grid bulk action's `patch{}` / `params[].default`. - **shared (35):** members of a schema another file owns. These are the expression AST beside every `source` (14), the HTTP data source's `params{}` / `body` (16), the inline JSON Schema document (4), and the bulk-action option entry, deliberately open (1). **3 one-off reasons:** - `element:definition-list` `items[].description` is shown as-is (`components/src/renderers/basic/data-list.tsx:68`, `toText`). - `object-grid` `pagination.*` is a deliberately open `z.looseObject`, whose named members are typed. - `ai:chat_window` `context{}` has no reader: objectui registers no renderer for the row (`components/src/renderers/placeholders.tsx:109-113`). Per A2 it is an open question, not a verdict (see the report). ## Held for a ruling: `object-kanban` `conditionalFormatting` (A3) The list view's own `conditionalFormatting`, the shape the grid took in PR objectstack-ai#21463, refuses both rule dialects that objectui's own kanban fixtures author: - `{ field, operator, value, backgroundColor }` (`plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx:463`) is refused with `unrecognized_keys` and `invalid_type@style`. - `{ condition, backgroundColor }` (`:479`) is refused with `invalid_type@style` and `unrecognized_keys[backgroundColor]`. - objectui's own `types/src/__tests__/kanban-conditional-formatting.test.ts:29-52` asserts that its kanban schema accepts both, and a mix of the two. So the narrowing is not shipped. It is recorded as `staged('held-for-decision', …)` and reported with options. ## The pin, red then green (A4), and its ablation - **Green on the final head:** `vitest run --project local … component-props-unknown-members.pin.test.ts` reports `Tests 51 passed (51)`. - **Ablation.** The mutation reverts `object-map`'s `navigation` to `z.unknown()`, with no ledger line, through `node scripts/ablation-replace.mjs` in WRAP mode under the verify lock. It landed: `anchor x1 → x0`, `replace x0 → x1`, blob `58b6e27fbc → 7e3b411604`. - The pin went **red**: `Tests 8 failed | 43 passed (51)`. - §1 received `["object-map navigation"]` against an expected `[]`. - §1's census read `expected 108 to be 107`. - The six object-map §4 cases failed (unwrap identity, parse equality, four refusals). - **Restore, proven by blob:** `blob after restore 58b6e27… == blob at HEAD 58b6e27…`, `git diff HEAD empty`. The rerun was green again: `Tests 51 passed (51)`. - The pin imports `./component.zod` from source, so no build sits between the mutation and the run. ## Gates, on `c222ab563b` - **Fix round after review `5963877733`:** merged `origin/main` `85e29b8858` and regenerated `content/docs/references/ui/component.mdx` (the optionality face `fd96a8473d` renders). On `c222ab563b`, `check:generated`, `check:migration-registry`, spec `test`, spec `typecheck` and `check:doc-authoring` all exit 0. The other five files are byte-unchanged since `d3431deed3`, where the readings below were taken. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths; merge base `100c394f6f`, 6 changed paths) derived 114 commands. Every exit code was written to disk before anything read it. **All 114 exit 0**, reconciled with `--ran`. - Six spec gates first answered `PREREQUISITE NOT MET` (exit 3), because `dist/` predated the final pin edit. They were rerun after `pnpm --filter @objectstack/spec build` at the final head, together with the rest of the spec family. - `pnpm --filter @objectstack/spec test`: `Test Files 602 passed | 1 skipped (603)`, `Tests 17832 passed`. - `pnpm --filter @objectstack/spec typecheck`: exit 0, with `check:test-typecheck: OK — … test layer compiles under packages/spec/tsconfig.test.json`. The package `tsconfig.json` excludes `**/*.test.ts`, so it is the test layer that covers the pin. - `scripts/step18-rationale-merge.test.ts` (`--project repo`): `Tests 9 passed (9)`. - `check:generated`: `✓ All 15 generated artifacts are up to date`. - `check:migration-registry`: `✓ src/migrations/registry.ts is current (353 semantic, 246 retired-key, 217 retired-def)`. - `check:liveness`, `check:strictness-ledger` and `check:authorable-surface` are green. `authorable-surface/ui.json` and the strictness counts did not move. `component.mdx` is regenerated. - `check:doc-authoring`: `✓ doc authoring guard: 17287 customer-facing string(s) … clean`. - `check:nul-bytes`: `check-nul-bytes: OK (… no raw ASCII control bytes)`. - **Semver:** `@objectstack/spec` `minor`, BREAKING banner, `Clause-②: yes (narrowing)`, and the ADR-0087 marker `registered ui-object-map-gantt-tree-navigation-typed`. - `check-adr-0087-registration`: `✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition`. - `check-changeset-no-major --base origin/main`: `✓ This diff introduces no major bump`. - The level axis, run with this body as the `--event` payload, is quoted in the report. ## The retirement kit - **D3 entry:** `ui-object-map-gantt-tree-navigation-typed`, a sibling of `ui-object-grid-row-members-typed`. - **Step-18 rationale fragment:** order 64, sorted by id. - **Regenerated:** `migrations/registry.ts` and `content/docs/references/ui/component.mdx`. - **No conversion is registered:** page-component `properties` is not parsed on the save or load path, and an off-shape value has no rewrite that honours what the author meant. ## Acceptance notes - The hot-file check (A8): no open PR touches `ui/component.zod.ts`, `component.mdx`, `migrations/registry.ts`, `authorable-surface/ui.json` or the pin. Seven of the eight open PRs were read in full. The bot Version Packages PR was read only to its first file page, because the proxy refused its pagination. - Deployed metadata was not measured. - The Console Pin Gate is CI's. objectui at the pin already types these members as `NavigationConfig`. - The `ai:chat_window` row has zero authored writers in either repository. objectui excludes it from the palette and treats it as a tombstone. Whether it retires is in the report's open questions, not in this PR. ## Resume A container restart stopped the first run after it pushed `c7bb62ae6f`, `527957de24` and `9a8037abbe`, and before it opened a PR. This run made the following changes: - confirmed the staging decision against the census; - merged `origin/main` through `os-regen-merge.sh` (no regeneration was owed); - re-measured the navigation writers and the kanban refusal; - corrected the pin's two stage reasons to name the real candidates; - finished the changeset; - ran every gate on the final head. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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'sObjectGridreads with a fixed shape, and retiresresizableColumnsto aretiredKey()tombstone namingresizable. Direction: triage 5958164933, accepted; claim 5958653004.Before this,
rowHeight: 42passed 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 ruleos validate/os build/os lintrun) on this branch:Item 1 — seven members, each measured at the
.objectui-shapin89cad75d55(plugin-grid/src/ObjectGrid.tsx)rowHeightz.unknown()RowHeightSchema, by referenceresolveRowHeightMode:1311rowColorz.unknown()RowColorConfigSchema, by referenceuseRowColor:2955 (field,colors)navigationz.unknown()NavigationConfigSchema, by reference (the carrierobject-kanban/object-calendar/object-timelinealready take)useNavigationOverlay:2894conditionalFormattingz.unknown()ListViewSchema.shape.conditionalFormatting, the list view's own memberresolveConditionalFormatting:2964bulkActionDefsz.array(z.unknown())z.array(BulkActionDefSchema), the list view's elementresolveBulkActions:4778aggregationsz.unknown()[{ field, type }],type=AggregationFunction(the query AST vocabulary, by reference)useGroupedData/useServerGroupHeaders:3095 / :3113operationsz.unknown(){ create?, update?, delete?, export? }booleans, strict;read/importrefused with guidancecreate:5468,update/delete:1898-1899,export:4088 / :5340 / :6141aggregationsandoperationshave no list-view counterpart, so they declare the measured shape in two module-private schemas (GridAggregationSchema,GridOperationsSchema). No export is added:check:api-surfaceis unchanged.rowHeight, mechanism assumption 4. The renderer's set and the schema's set match.resolveRowHeightModetests membership againstROW_HEIGHT_TO_DENSITY_MODE, which is typed as a record keyed by the spec'sRowHeight, so it admits exactly the five values. Anything else answerscompact.aggregations. The grid's vocabulary (AggregationTypeinuseGroupedData.ts) is exactlyAggregationFunction'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 declaresreadandimport, which nothing reads. Each is refused with that reason, not accepted as a toggle that toggles nothing.bulkActionDefsmeasured againstBulkActionDefSchema: the gap, reported and not widenedThe renderer reads members the schema lacks. Every one of these gaps already stands on the list view's door, which takes the same schema:
name,label,icon,variant,operation,execution,patch,params,confirmText,confirmLabel,visible,requiredPermissions,maxRecordsandbatchSizeare all declared.actionDefis read too (useBulkExecutor,BulkActionDialog), and the schema refuses it on purpose withguidance, because the renderer attaches it and an author does not write it.params[].bulkParamToFieldspreads every undeclared param key onto the widget's field bag, somin/max/step/accept/lookupFilters… reach a widget that reads them. The schema refuses them (BULK_PARAM_WIDGET_CONFIG_KEYS, the maintainer ruling recorded inbulk-action.zod.ts).visible. objectui'sExpressionWiretakes{ dialect?, source }withdialectoptional. The spec'sEvaluatedExpressionInputSchemaenvelope requiresdialect.params[].type. objectui types itstring, while the spec types itFieldType.conditionalFormatting: one dialect, the list view'sThe shared evaluator (
@object-ui/coreresolveConditionalFormatting) 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 —
resizableColumnsretired (objectui#6152 ruling A)Tombstone.
resizableColumnsis aretiredKey()with the house prescription: "Useresizable. Rename the key; the value (a boolean) is unchanged." plus theos migrate meta --from 17sentence.tscrefuses it, and so does the parse.resizablestill parses.D2 conversion
object-grid-resizable-columns-removed. It is protocol 18,retiredFromLoadPath,retiredAfter17.6.0, order 57, and it followsresizable ?? resizableColumnsexactly:resizableis absent (or null), the value moves toresizableunchanged;resizableholds 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]carriesui/ObjectGridProps:resizableColumns, written bygen:migration-registry.object-grid-resizable-columns-retiredcarriesconversionIds, and it carries the judgment for a grid that authored both keys with different values.STEP18_RATIONALEfragments 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'spropertiesis 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/specminor,!, BREAKING banner, a FROM → TO table, and one ADR-0087registeredmarker naming all three ids.Census of authored writers (Zone 2 assumption 3)
origin/main53fd35e3e3: the examples,packages/platform-objects, the spec tests,content/docsandskills. Zeroobject-gridblocks author any of the seven members orresizableColumns. Control: the same matcher findscolumnson both showcase grids.89cad75d55, over 335object-gridnodes, of which 76 carry apropertiesbag (23 of those in parsed JSON documents). The control:columnsis found in 46 bags.README.md"Data Grid" example,"operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }. Itsread: trueis now refused.content/docs/api/schema-reference.mdalready droppedoperations.readfrom its example as a key "which nothing reads" (objectui#11068).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 declareread/importas 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), thedata-tablecomponent's own prop, and the?? schema.resizableColumnsread itself.Clause-② — the measured arm
check-widening-tells --declaration no --diff(final diff)check-widening-tells --declaration yes --diffyesz.unknown()bags (aggregations[].field, the fouroperationstoggles). The gate's own text rules that shape: it is "a true refusal although it only narrows … declareClause-②: yes". So the arm isyes (narrowing)although no export-listing row is added.import { … } from './view.zod'list, read as members of a closed set. They are a false positive of the matcher, noted below.Tests — at
52c4c42d72component-object-grid-typed-members.pin.test.ts(new,localproject, 52 tests).navigation(its defaults) andconditionalFormatting(the condition envelope).rowHeight: 42isinvalid_valueatrowHeight;operations.readisunrecognized_keysatoperations, with its guidance text; a no-opcustombulk def iscustomatbulkActionDefs.0.execution.AggregationFunctionset and the exactoperationskey set.resizableColumns: true|falseis refused with the prescription, alone and besideresizable.resizable: true|falseis byte-identical. A@ts-expect-errorassignment ofresizableColumnstoObjectGridPropsis consumed, so thetscchannel is proven bycheck:test-typecheckstaying green.RETIRED_KEYS_BY_MAJORwiring.object-grid-resizable-columns-retirement.test.ts(new,repoproject). This is the tree-scoped absence pin over the five roots@objectstack/specalready declares inscripts/cross-package-test-inputs.mjs, registered invitest.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.repoproject. 45 of its 50 files were run green, in four targeted runs. The five not run locally arebuild-schemas-check-mode,dist-freshness,dist-freshness-adoption,publish-smoke-boot-failureandpublish-smoke-port-collision. They are script-machinery tests this diff does not touch;build-schemas-check-modealone 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.mjsto blob == HEAD withgit diff HEADemptyThe pin imports
./component.zodby relative path, so it readssrc/and no build sits between the mutation and the run.rowHeightback toz.unknown()rowHeightrefusals and the identity pinresizableColumnsbecomes an undeclared key)unrecognized_keysis not the tombstone)operationsback toz.unknown()resizableColumns: falseplanted inexamples/app-showcase/src/ui/pages/my-work.page.tsThe 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 --commandsderives 114 families at52c4c42d72, identical to the seat's derivation. All 114 exit 0 on that head; each exit code was captured before any pipe.--ranreconciliation: "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:generatedpasses on the final head. Two artifacts were regenerated with--fixbecause 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).node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config --format jsonran on the changed.tsfiles: 10 files, 0 errors, 0 warnings.eslint.config.mjs's**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}minusNEVER_LINTED.parserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file.pnpm lintis CI's.Acceptance notes (observations, not filed)
dropped-refinements.baseline.jsongains one site.ui/ObjectGridProps'sbulkActionDefs.elementis the list view's ownBulkActionDefSchemarefinement now reaching the grid door, and the gate printed the corrected entry.droppedRefinementSitesmoves 652 → 653.grouping.fields[]nested shape withorder?/collapsed?(their input face). This is generator output, not a hand edit.object-kanban'sconditionalFormattingis stillz.unknown(), and three other rows declarenavigation: z.unknown(). They are named for the family's closure card in the dev report.check-widening-tellsT2 false positive. A name added to a multi-lineimport { … }list is read as a new member of a closed set. It is named in the dev report.resizableColumnsmember becomesnever, the flat mirror gets a tombstone, and the?? schema.resizableColumnsread is dropped;operations.readcomes out of its example;operations.read/.importgo;conditionalFormattingtyping converges on the spec rule.Generated by Claude Code