This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
Commit edfcf5a
feat(types): a form field of type grid declares the grid widget's field-level keys (objectui#11070 round 10) (#11381)
Refs #11070
Clause-②: yes (widening). `FormFieldSchema`, a published accept set,
accepts eight more keys on a form field entry, each one a key the `grid`
widget already reads and `grid.mdx` teaches. The tolerant face now also
judges their values, so a wrong-typed value it used to strip is refused.
Both halves are priced in the changeset.
Round 10 of objectui#11070, under claim `5930921428` (`domain:devx` seat
2, maintainer direct dispatch R5). It has three items:
- (i) the grid widget's field-level keys on the form-field face, the
seat's answer A in `5928627070`;
- (ii) one real run of objectui's strict authoring face over objectstack
`examples/app-crm`, for objectstack-ai/objectstack#20960's acceptance
item 3 (measure only, nothing changed for it);
- (iii) `grid.mdx`'s "Integration with Advanced Grid" section (finding 2
of `5929940616`).
## (i) The form-field face mirror
### Measured first, at base `0a3e5409`
- `GridFieldMetadata` declares, beyond `BaseFieldMetadata`, `columns`
(round 7) and exactly the eight keys in the table below.
- Neither form-field face declared any of the eight. `FormField`
resolved each one through its `[key: string]: any` index signature.
`FormFieldSchema`, a plain `z.object`, stripped each one.
- The face is flat, not discriminated by `type`. `FormField` is one
interface with `type?: string`, and `FormFieldSchema` is one
`z.object(...).superRefine(...)`. Round 7 added `columns` to it the same
way.
- No collision:
- no `FormField` member has any of the eight names;
- `@objectstack/spec` 17.5.0's `FieldSchema` declares none of them, so
no spec change is involved;
- in `@object-ui/fields`, `GridField` is the only reader.
`MasterDetailForm` and `LineItemsPanel` write the same keys into the
grid's config with the same meaning, and the `form` renderer reads none
of them.
- Reach through the public door, by a scratch render probe (never
committed) of the new fixture through `SchemaRenderer`, the real `form`
renderer and `GridField`:
- with the keys, the Add button reads "Add line item" and the footer
shows the total 109.97;
- the control, the same fixture without the keys, reads "Add line" and
shows no total;
- with `allow_add: false`, there is no Add button and no Duplicate
action.
### Per key, both faces, before and after
| key | `GridFieldMetadata` | `FormField`: base → head |
`FormFieldSchema`: base → head | strict face, `fields[]` grid entry:
base → head | tolerant face: base → head |
| --- | --- | --- | --- | --- | --- |
| `min_rows` | `number` | index signature (`any`) →
`GridFieldMetadata['min_rows']` | undeclared → `z.number().optional()` |
refused by name at `fields.0.min_rows` → accepted | value stripped →
value kept; `"1"` refused |
| `max_rows` | `number` | index signature (`any`) →
`GridFieldMetadata['max_rows']` | undeclared → `z.number().optional()` |
refused by name at `fields.0.max_rows` → accepted | value stripped →
value kept; `"20"` refused |
| `allow_add` | `boolean` | index signature (`any`) →
`GridFieldMetadata['allow_add']` | undeclared → `z.boolean().optional()`
| refused by name at `fields.0.allow_add` → accepted | value stripped →
value kept; `"false"` refused |
| `allow_delete` | `boolean` | index signature (`any`) →
`GridFieldMetadata['allow_delete']` | undeclared →
`z.boolean().optional()` | refused by name at `fields.0.allow_delete` →
accepted | value stripped → value kept; `"false"` refused |
| `allow_reorder` | `boolean` | index signature (`any`) →
`GridFieldMetadata['allow_reorder']` | undeclared →
`z.boolean().optional()` | refused by name at `fields.0.allow_reorder` →
accepted | value stripped → value kept; `"false"` refused |
| `total_field` | `string` | index signature (`any`) →
`GridFieldMetadata['total_field']` | undeclared →
`z.string().optional()` | refused by name at `fields.0.total_field` →
accepted | value stripped → value kept; `3` refused |
| `add_label` | `string` | index signature (`any`) →
`GridFieldMetadata['add_label']` | undeclared → `z.string().optional()`
| refused by name at `fields.0.add_label` → accepted | value stripped →
value kept; `1` refused |
| `sort_field` | `string` | index signature (`any`) →
`GridFieldMetadata['sort_field']` | undeclared → `z.string().optional()`
| refused by name at `fields.0.sort_field` → accepted | value stripped →
value kept; `0` refused |
The base column was read by a scratch probe on the built
`@object-ui/types` dist at `0a3e5409`, with a lit control: the same
entry without the keys parses on both faces. Each `.describe()` on the
zod side says that only the `grid` widget reads the key.
### Pins
- `strict-face-read-keys-11070.test.ts`:
- block 1 gains one `fields[]` grid-entry row per key, each with a
misspelled sibling still refused by name, plus an all-eight row whose
tolerant output keeps every value;
- block 2 gains one wrong-type row per key, refused on both faces;
- block 4 gains `assertionGridFieldKeysOnTheFormFieldFace`. Every member
`GridFieldMetadata` declares beyond `BaseFieldMetadata` must be a
DECLARED `FormField` member (not the index signature) with the same
type, so a key added to the grid's type and left unmirrored fails to
compile. A lit control shows the key set it ranges over is non-empty.
- `form-field-zod-coverage.test.ts`: the eight keys join
`DECLARED_KEYS`.
- `zod-mirror-parity.test.ts`: `FormFieldSchema` was already a
registered pair. The eight keys are LOCAL on both sides, so no ledger
total moves. Its `SPEC_DERIVED_PAIRS` note now says so.
### The refusal flip and the ablations
Predictions were written before each run. Every mutation was committed
first and restored to HEAD, with blob == HEAD and `git diff HEAD` empty.
A1 and A2 went through objectstack's `scripts/ablation-replace.mjs`
(anchor 1 → 0, blob changed). The subjects resolve from source, since
the tests import `../zod` relatively, so no dist is on the path.
- **R0, the refusal flip:** `form.zod.ts` and `form.ts` were set to base
`0a3e5409`, and this head's pins were run against them.
- Predicted: 18 red in vitest (8 per-key rows, the all-eight row, 8
wrong-type rows, the coverage key set) and tsc red only in the pin file.
- Measured: `Tests 18 failed | 68 passed (86)`, and `tsc -p
tsconfig.test.json` gave 10 errors, all in
`assertionGridFieldKeysOnTheFormFieldFace` (completeness, the mapped
equality, the 8 per-key rows). `zod-mirror-parity` compiled.
- **A1, zod ablation:** the `allow_add` member was deleted from
`FormFieldSchema`.
- Predicted: 4 red (the `allow_add` row, the all-eight row, the
wrong-type `allow_add` row, the coverage key set) and the parity file
red at tsc.
- Measured: `Tests 4 failed | 119 passed (123)` over the pin, coverage
and parity files. tsc gave 1 error, in `zod-mirror-parity.test.ts` at
`assertionUnmirroredMatchesLedger` (`"form.zod.ts#FormFieldSchema"` is
not assignable to `never`).
- **A2, TS ablation:** the `allow_add` member was deleted from
`FormField`.
- Predicted: vitest green, the type-level pin red, and the parity file
red on the fourth direction.
- Measured: `Tests 123 passed (123)`. tsc gave 5 errors: the pin file's
completeness, mapped-equality and per-key `allow_add` rows, and
`zod-mirror-parity.test.ts`'s `MirroredUndeclared` reconciliation, by
pair and by key (`"allow_add"` is not assignable to `never`).
### Fixture and docs
- `examples/schema-catalog/src/schemas/fields-grid/line-items-grid.json`
writes all eight keys, with columns and seeded rows, so the corpus
exercises the declared set. The catalog index is regenerated.
- The three catalog DOM-leak census pins count the new entry, as their
own text asks of an added example: `grid` goes from 26 to 27 in all
three, and in the layout pin `text` goes from 698 to 699 rendered and
from 154 to 155 with no element.
- `grid.mdx` embeds the fixture and says that the keys are accepted on a
form field entry of `type: 'grid'`, and that only the `grid` widget
reads them.
## (ii) The app-crm strict-face run (measure only)
**The instrument, and why it is the real one.** The run uses
`StrictAnyComponentSchema`, exported by `@object-ui/types/zod` (the
built `packages/types/dist/zod/index.zod.js`), called with `.safeParse`.
That is the call PR #11069's `validateAuthoredDocument` makes for
`objectui validate` and `objectui check`.
- It is the shipped, derived face (objectui#8345), not a replica.
- `scripts/measure-strict-authoring-face.mjs` is not the real face. Its
own header says it keeps its own twin walker, PR #11069's M1 measured
that walker behind the shipped face on registered inputs, and it walks
only objectui's own corpora.
- On `main` the CLI still validates through the tolerant face (PR #11069
is parked), and neither the CLI nor that script reads TypeScript
modules. So nothing in this repository walks an objectstack app.
- The walk itself is a scratch driver, never committed. It imports
app-crm's UI modules with `tsx`, resolving `@objectstack/spec` to the
installed 17.5.0, the version objectstack `main` declares. It hands each
document to the face the way objectui's console hands it to a renderer.
**Command and shas.**
- objectui `0a3e5409`, the base, which is `main`. The types dist was
built with `pnpm --filter @object-ui/types build`. The run was repeated
on head `be58a328` with byte-identical output.
- objectstack `f0cc16e8d`, `origin/main`. `399e3aa97b` is its ancestor
(`merge-base --is-ancestor` exits 0), and `git diff 399e3aa97b f0cc16e8d
-- examples/app-crm` is empty.
- app-crm was extracted with `git archive f0cc16e8d examples/app-crm`,
and the run was `pnpm exec tsx appcrm-strict-run.mts OBJECTUI_WORKTREE
APP_CRM_EXTRACT BEFORE_DASHBOARD`.
| app-crm document | projection | tolerant | strict | refusals |
| --- | --- | --- | --- | --- |
| `dashboards/pipeline.dashboard.ts` · `PipelineDashboard` | `{ type:
'dashboard', ...doc }`: `DashboardView` hands the stored document to
`DashboardRenderer`, and objectui's node face for it is the `dashboard`
arm | PASS | **PASS** | none |
| `pages/welcome.page.ts` · `CrmWelcomePage` | `{ ...page, type: 'page',
pageType: page.type }`: the `page` arm, with the kind on `pageType` |
PASS | PASS | none |
| the same page, as `PageView` writes it | `{ ...page, type: page.type,
pageType: page.type }`, so `type: 'home'` | FAIL | FAIL |
`invalid_union` at `type` (no matching discriminator) on both faces.
This is the page-kind / node-type collision documented in
`layout.zod.ts`'s `PAGE_SPEC_EXCLUDED` note (objectui#9642), not an
unknown key. |
| `apps/crm.app.ts` · `CrmApp` | `{ type: 'app', ...doc }` | PASS | PASS
| none |
| **lit control:** `pipeline.dashboard.ts` at `399e3aa97b^`
(`39ab2940e`) | `{ type: 'dashboard', ...doc }` | PASS | FAIL |
`unrecognized_keys` `[format, currency]` at `widgets.0.options`,
`widgets.1.options` and `widgets.2.options` (plus the non-viable
`metric-card` slot arm's own issues) |
- The dashboard pair objectstack#20960 removed (`options.format` /
`options.currency`) does not appear at `f0cc16e8d`, and the control
shows that the face refuses exactly that pair before the removal.
- Nothing in app-crm's UI documents is refused by an unknown key.
- Not judged: objects, views, actions, flows, datasets, datasources,
hooks, security, translations and seed data. These are server metadata,
not objectui node documents, and the console builds its list and form
nodes from views in code, so there is no single stored-document
projection to judge.
## (iii) `grid.mdx` — "Integration with Advanced Grid"
The section offered seven features as `@object-ui/plugin-grid`'s. The
basic grid field has five of them too: inline editing, add and remove,
row drag-reorder, computed columns, and a one-column footer total.
`object-grid` has no computed columns, and it reorders columns, not
rows. It is now a table of what each widget reads, feature by feature,
each cell checked against the reader:
- **inline editing:** `GridField`'s cell controls versus `object-grid`'s
`editable`, ANDed with the principal's write verdict;
- **add and remove:** `allow_add` / `allow_delete` versus
`operations.create` and `operations.delete` with the host's `onDelete`;
- **sorting and filtering:** none in the grid field, versus header sort,
`sortable: false`, `filter` / `sort` and `searchableFields`;
- **drag reorder:** rows through `allow_reorder` and `sort_field`,
versus columns only through `reorderableColumns`;
- **export:** none, versus `exportOptions`;
- **computed columns:** `computed` with `expr` in the grid field, versus
none in `object-grid` (a formula field is shown as served);
- **totals:** `total_field`, versus `columns[].summary` and group
`aggregations`.
## M3 (`scripts/measure-strict-authoring-face.mjs --json`, the types
dist built at each ref)
| reading | base `0a3e5409` | head `be58a328` |
| --- | --: | --: |
| documents | 601 | 602 |
| nodes | 2229 | 2230 |
| nodes strict-refused | 77 | 77 |
| strict-only | 46 | 46 |
| red today | 31 | 31 |
| documents refused, whole tree | 72 | 72 |
| documents red today | 24 | 24 |
| `form`: documents / refused / strict-only / red today | 97 / 13 / 9 /
4 | 98 / 13 / 9 / 4 |
- The only move is the new fixture, one strict-clean `form` document in
the catalog corpus.
- The per-component refusal rows, the undeclared-key occurrences and
`rendererMentionsOfUndeclaredKeys` are identical. `unloadableModules`
moved for an environmental reason: app-shell was unbuilt at base.
- `corpusMatchesMain` is false at head because the corpus gains that
fixture.
## Local verification
Every heavy command went through `os-verify-lock` (slot `issue-11070`),
with `NODE_OPTIONS=--max-old-space-size=4096`, vitest `--maxWorkers=2`,
turbo `--concurrency=2`, and a memory watchdog that kills its own
process group below 2500 MB `MemAvailable`. It never fired.
- **Closure build:** `turbo run build` over `check-doc-snippet-types
--build-filter` ran 35 of 35 tasks at `be58a328`.
- **Type-check census:** `turbo run type-check` ran 48 tasks, with 13 of
13 type-checks green. The set is every package whose sources or tests
reference `FormField` / `FormFieldSchema` (a `git grep` census), plus
`@object-ui/types` and `@object-ui/example-schema-catalog`: `types`,
`fields`, `components`, `core`, `plugin-form`, `app-shell`, `cli`,
`plugin-calendar`, `plugin-detail`, `plugin-grid`, `plugin-kanban`,
`console` and `example-schema-catalog`.
- **vitest at `be58a328`:** `packages/types/`,
`examples/schema-catalog/` and the two GridField pins ran 351 files,
with 348 passed and 3 failed. The three failures were the catalog
DOM-leak census pins, and `261d9c9e` records them.
- **vitest at `261d9c9e`:** `examples/schema-catalog/` plus the eight
catalog- and docs-reading `scripts/__tests__` ran 45 of 45 files and
2601 tests passed. The types sources did not change between the two
heads.
- **Docs and changeset gates, green:** `check:doc-snippets` (769 of 769
blocks, 0 failed), `check:doc-fences`, `docs:check-links`,
`check:doc-example-ids`, `check:doc-types`, `check:doc-examples`,
`check:spec-symbols`, `check:test-path-roots`,
`check-type-check-coverage`, `check:control-bytes`,
`check:new-line-citations` (0 new), the catalog `regenerate:check`,
`check:changeset-claims`, `check:pending-changeset-literals`,
`check-changeset-presence`, `changeset:check`,
`check-changeset-overwrite`, and `check-governed-queue-guard --test`
(NOT GOVERNED).
- **Pending changesets:** `check:changeset-claims` names 31 pending
changesets that describe a touched file. Each paragraph was read, and
none is made false by this round, so no dated note is owed.
- **Narrowed lint:** `eslint --no-inline-config --format json` over the
10 changed `.ts` / `.tsx` files gave 0 errors, with warnings per file
equal to the base blobs.
- The population is the config's `**/*.{ts,tsx}` blocks. The JSON, MDX
and MD files match none.
- The config has no `parserOptions.project` and no `projectService`, and
no rule in `eslint-rules/` reads another file, so this diff cannot move
an untouched file's verdict.
- **Not run locally:** repository-wide `pnpm lint` and `pnpm test`,
which CI runs.
`main` did not move after the base (`0a3e5409`), so there was nothing to
merge before opening.
**Changeset:** `@object-ui/types` **minor**. A published accept set
widens by eight keys, and the tolerant face narrows on their values. The
breaking half is stated in the changeset and priced minor under the
fixed group's policy. Nothing else ships: the `@object-ui/fields` edit
is a test docblock, and the catalog is private.
## Acceptance notes
- **The flat-face boundary.** The eight keys are accepted on every
`fields[]` entry, and only the `grid` widget reads them, exactly as with
`columns`. Moving them to a `type`-discriminated arm would be a ruling,
not this round's work.
- **Value rules.** `min_rows` and `max_rows` are typed `number` on both
faces, as `GridFieldMetadata` types them. No integer or range rule was
added: that would tighten `GridFieldMetadata` too, which is a contract
decision.
- **Two edits beyond the named surface:**
- the docblock of
`packages/fields/src/widgets/GridField.declaredSpelling.test.tsx` said
"the three `fields-grid` catalog examples", which the new fixture made
stale; it now says "every";
- the three DOM-leak census pins, as above.
- **The as-served page projection.** `type: 'home'` has no arm on either
face, which is the collision objectui#9642 documents. It is an
observation and is not filed.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 8001068 commit edfcf5a
13 files changed
Lines changed: 276 additions & 19 deletions
File tree
- .changeset
- content/docs/fields
- examples/schema-catalog
- src
- schemas/fields-grid
- test
- packages
- fields/src/widgets
- types/src
- __tests__
- zod
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
20 | 24 | | |
21 | 25 | | |
22 | 26 | | |
| |||
73 | 77 | | |
74 | 78 | | |
75 | 79 | | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
76 | 86 | | |
77 | 87 | | |
78 | 88 | | |
| |||
308 | 318 | | |
309 | 319 | | |
310 | 320 | | |
311 | | - | |
312 | | - | |
313 | | - | |
314 | | - | |
315 | | - | |
316 | | - | |
317 | | - | |
318 | | - | |
319 | | - | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
336 | 336 | | |
337 | 337 | | |
338 | 338 | | |
| 339 | + | |
339 | 340 | | |
340 | 341 | | |
341 | 342 | | |
| |||
3392 | 3393 | | |
3393 | 3394 | | |
3394 | 3395 | | |
| 3396 | + | |
| 3397 | + | |
| 3398 | + | |
| 3399 | + | |
| 3400 | + | |
| 3401 | + | |
| 3402 | + | |
| 3403 | + | |
| 3404 | + | |
3395 | 3405 | | |
3396 | 3406 | | |
3397 | 3407 | | |
| |||
| 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 | + | |
| 53 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
150 | 150 | | |
151 | 151 | | |
152 | 152 | | |
153 | | - | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
154 | 158 | | |
155 | 159 | | |
156 | 160 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
106 | | - | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
107 | 111 | | |
108 | 112 | | |
109 | 113 | | |
| |||
129 | 133 | | |
130 | 134 | | |
131 | 135 | | |
132 | | - | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
133 | 142 | | |
134 | 143 | | |
135 | 144 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
115 | 115 | | |
116 | 116 | | |
117 | 117 | | |
118 | | - | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
119 | 123 | | |
120 | 124 | | |
121 | 125 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
14 | | - | |
| 13 | + | |
| 14 | + | |
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
100 | 110 | | |
101 | 111 | | |
102 | 112 | | |
| |||
0 commit comments