Repository navigation
Commit a91d12a
Fixes #20186
Clause-②: yes (narrowing)
A flattened `view` overlay is now judged by the overlay member its
`viewKind` names. A column-less list patch, which the console writes on
every toolbar save, has its list keys judged, instead of being accepted
by the form member with those keys stripped unread. This is route
C-prime as ruled in seat answers `5855433719` and `5855548706` on
#20186.
## What was wrong (measured)
Both flattened overlay members of `ViewMetadataSchema` shared one
`viewKind: 'list' | 'form'` enum. The list member required `columns`, so
it refused a column-less `viewKind: 'list'` body. The union then tried
the form member, which requires no list key and `.strip()`s every one,
and accepted the body: `diagnoseViewMetadata` answered `formOverlay` and
the parse output was `type: 'simple'`, a form.
I measured this through the real `saveMetaItem` on `origin/main`
`4df101c3`, and again at the spec level on `ce70876e` after #20183
landed; the verdicts were byte-identical. Each of these bodies answered
`success: true`, and the row held it exactly as sent:
- `{ name, object, viewKind: 'list', sort: 'name desc' }`, a retired
`sort` string;
- the same body with `timeline: { …, metaFields: [...] }`;
- the same body with `searchableFields: 'name'`.
The mirror also held: the list member accepted `{ viewKind: 'form',
columns: ['name'] }`.
The column-less list body is not malformed; the console writes it.
objectui's `buildPersistedViewBody` returns `{ ...patch, viewKind }` for
an overlay, and `updateViewConfig` stamps `object`, `name` and
`_isOverride`. That is the maintainer ruling on #7494 (comment
`5261754173`): 「`persistViewPatch` 只存 patch,不存 merged base」. So refusing
it (route A) was ruled out.
## What changed — `packages/spec/src/ui/view.zod.ts`, the
flattened-overlay region only
- **`flattenedViewOverlayFields(kind)`: one `viewKind` per member.** The
list member admits `'list'` only and the form member `'form'` only. An
absent `viewKind` keeps the #7741 binding prescription.
- **The flattened LIST member judges a patch.** `columns` is optional on
this member only; the authoring `ListViewSchema` keeps it required.
`type` is declared here without its `.default('grid')`, so the member's
checks can see whether the body named one. Three checks follow on the
same object schema, in this order:
1. `checkListOverlayTypeNeedsColumns` refuses a column-less body that
names a `type`, at `columns`. The issue is aborting, so the union
envelope stays `invalid_union`.
2. The existing calendar check.
3. `applyListOverlayTypeDefault` (an `.overwrite()`) puts `type: 'grid'`
back, in the key position the default took. `FormViewSchema` already
uses the same `.overwrite()` shape for its `groups` → `sections` fold.
**No pipe:** both overlay members are still `def.type === 'object'`, and
a test pins it.
- **The FORM member's `columns`** is a clone of `FormViewSchema`'s own
count schema with an error map added. Its constraints are that schema's,
and a field list gets a prescription. The member is built with
`.safeExtend()`, because overriding a key on `FormViewSchema`, which
carries refinements, is refused by `.extend()`. The two differ in that
throw only.
- `diagnoseViewMetadata` and `selectViewMetadataBranch` needed no
change. Each body is now accepted by at most one overlay member, so the
branch they name is the member that judged it. The conversions walk
(`mapViewPayloads`) already picked an overlay's family from `viewKind`;
the parse now agrees with it.
### Refusal and diagnosis texts (new)
```text
LIST_OVERLAY_TYPE_NEEDS_COLUMNS (at `columns`, code custom)
This list view overlay sets `type` but lists no `columns`. A body that sets `type` is a full inline list config, and a full config lists its columns: add `columns: ["field_a", "field_b"]`. A patch on the view it shadows (`sort`, `hiddenFields`, `columnState`, `inlineEdit`, …) sets no `type` and needs no `columns`: remove `type` to save this body as a patch on the view it shadows.
FORM_OVERLAY_COLUMNS_IS_A_COUNT (at `columns`, code invalid_type)
On a form view `columns` is the NUMBER of body columns (an integer, 1 or more), not a list of fields, and this body says `viewKind: "form"`. A list of fields is a list view's `columns`: if this is a list view, set `viewKind: "list"`; if it is a form, list its fields in `sections: [{ fields: [...] }]`.
overlayViewKindArmMismatch(kind) (at `viewKind`; seen only on a direct member parse, the union mutes the unclaimed member)
This is the flattened FORM overlay member, which judges `viewKind: "form"` only. A `viewKind: "list"` body is judged by the list overlay member (`VIEW_METADATA_MEMBERS.listOverlay`), and that member's issues are its diagnosis.
```
### Served JSON Schema (`/api/v1/meta/types/view`)
The schema is still an `anyOf` of four members in both directions.
Members 0 and 1 are byte-identical. I diffed it against `ce70876e`, and
it moves only by the contract:
- member 2 (list overlay): `viewKind.enum` is `["list"]`, and `columns`
is no longer in `required`. In the output direction `type` is no longer
in `required`. The `type` default `grid` stays byte-identical in both
directions, via `.optional().meta({ default })`.
- member 3 (form overlay): `viewKind.enum` is `["form"]`. `columns` is
unchanged.
## Pins: `ViewMetadataSchema` AND `saveMetaItem`
- New: `packages/spec/src/ui/view-overlay-viewkind-arm.test.ts` (schema
+ `diagnoseViewMetadata`).
- Added to
`packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts`
(its ledgered stub engine, the real `saveMetaItem`, and the stored row
read back). Every refusal asserts `INVALID_METADATA`, `422`, nothing
stored, and the issue's path, code and prescription.
| body (flattened, `object` bound) | was | now |
|:--|:--|:--|
| `viewKind: 'list'`, `sort: [{ field, order }]` (the headline) | ACCEPT
on formOverlay, `type: 'simple'` | ACCEPT on listOverlay, `type:
'grid'`, stored verbatim |
| the objectui sort / hiddenFields / inlineEdit / columnState /
rowHeight patches | ACCEPT on formOverlay | ACCEPT on listOverlay,
stored verbatim |
| `viewKind: 'list'`, `sort: 'name desc'` | ACCEPT, stored | REFUSE
`invalid_type` at `sort` (the 17.5.0 prescription) |
| `viewKind: 'list'`, `timeline.metaFields` | ACCEPT, stored | REFUSE
`unrecognized_keys` at `timeline` |
| `viewKind: 'list'`, `searchableFields: 'name'` | ACCEPT, stored |
REFUSE `invalid_type` at `searchableFields` |
| `viewKind: 'list'`, `sharing: { enabled: true }` (form block) |
ACCEPT, stored | REFUSE `unrecognized_keys` at `sharing` |
| `viewKind: 'form'`, `columns: ['name']` (mirror) | ACCEPT on
listOverlay | REFUSE on formOverlay, `invalid_type` at `columns`, count
prescription |
| `viewKind: 'list'`, `type: 'kanban'`, no `columns`
(`overlay.list.identity`) | REFUSE (`invalid_union`, bare `Invalid
input` at `columns`) | REFUSE (`invalid_union`), `custom` at `columns`
with the prescription |
| control: real form overlay | ACCEPT | ACCEPT, parse output
byte-identical |
| control: list overlay with `columns` | ACCEPT | ACCEPT, parse output
byte-identical |
### Pin sweep
- **Flipped on purpose:** `view-union-diagnostics.test.ts`
`put.isPinned`, `put.sortOrder` and `put.pinAndOrder`. The verdict is
still ACCEPT, but the pinned parse output moves from `{ type: 'simple',
… }` to `{ type: 'grid', … }`. The seat named these three as the
intended change.
- **Not flipped:** `overlay.list.identity`, which stays REFUSE and stays
`invalid_union`.
- **No pin anywhere asserted a W1 or W2 body refused.** The swept suites
all ran green unchanged, apart from the three `put.*` pins above
(numbers under Verification).
- The #20183 pin in `view-overlay-options-bag.test.ts` (a column-less
body with a BAD `options` bag stays refused) holds: the list member
judges the bag.
### Readers keyed on the old form parse of these rows
- **`graftNormalizedOperators`** (the `saveMetaItem` operator graft). On
`805af4f2` the form member stripped a column-less list patch's `filter`,
so an alias operator (`eq`) was stored as written. Now the list parse
normalises it, and the graft writes the canonical operator into the row,
as it already did for a list overlay with `columns`. Measured through
the real save on this branch: `filter: [{ field: 'name', operator: 'eq',
value: 'x' }]` is stored as `operator: 'equals'`.
- **`graftFoldedFormSections`**. On `805af4f2` a list patch carrying the
form key `groups` was parsed as a form (parse output `sections: [...]`),
and the graft stored it as `sections`. Now the list member drops
`groups` unread, and the row keeps `groups` as authored (measured
through the real save). This is a W1-class body; see the Acceptance
notes.
- **objectui**, at the pin and at `main`, has no reader of
`diagnoseViewMetadata` or of the parse output's `type` for these rows.
Its only hits are docblocks naming `VIEW_METADATA_MEMBERS.formOverlay`,
for real form overlays.
## Declared widening (W1, W2) — why `Clause-②: yes`
Both classes are column-less, type-less `viewKind: 'list'` bodies that
were refused and are now accepted. All readings below are on `805af4f2`
and on this branch.
- **W2**: a list-legal value under a key both members declare with
different schemas.
- Measured body: `{ name: 'crm_lead.all', object: 'crm_lead', viewKind:
'list', aria: { ariaLabel: 'Leads' } }`. It was REFUSED and is now
ACCEPTED on listOverlay.
- The class also covers an i18n `description`, the list `sharing` block
and a valid legacy `options` bag. Each of those is pinned ACCEPT at both
doors, and `aria` is pinned too.
- The divergent keys, measured off the members: `type`, `columns`,
`description`, `sharing`, `aria`, `options` (and `viewKind` itself).
- This is the ruling working: a list body is judged by list rules.
- **W1**: an invalid value under one of the 19 form-only keys.
- Measured body: `{ name: 'crm_lead.all', object: 'crm_lead', viewKind:
'list', isPinned: true, layout: 'diagonal' }`. It was REFUSED, because
the form member judged `layout`. It is now ACCEPTED, with `layout`
dropped from the parse.
- ⛔ It is not pinned as desired behaviour; see the Acceptance notes.
- The 19 keys: `layout`, `title`, `defaultTab`, `tabPosition`,
`allowSkip`, `showStepIndicator`, `splitDirection`, `splitSize`,
`splitResizable`, `drawerSide`, `drawerWidth`, `modalSize`, `sections`,
`groups`, `subforms`, `defaultSort`, `submitBehavior`, `buttons`,
`defaults`.
## Census (literal and dynamic producers)
- **objectstack** `4df101c3`: 236 literal `viewKind: 'list'` occurrences
in 94 files under `packages/**`. Of those, 56 are column-less flattened:
41 tests, 4 changelog lines, and 11 comments or docstrings. Control: 105
flattened-with-columns and 70 `config` records were classified by the
same scanner.
- **`examples/**`**: 0 `viewKind` at all. Control: 15 files with
`listViews` and 14 with `defineView`.
- **objectui**: at the pin `f8a9d0fb`, 90 occurrences in 39 files; at
`main` `25c7d584e`, 95 in 42. The column-less ones are tests plus 4 type
declarations or docstrings.
- **cloud** `main` `48d7066`: 60 occurrences in 13 files; all 14
column-less ones are tests. Its two producers emit `config` records.
- **Literal source producers: 0 in every tree.** The one real producer
is dynamic and was found by reading the code: objectui's
`buildPersistedViewBody` plus `updateViewConfig`, called from the
`sort`, `hiddenFields`, `inlineEdit` and `columnState` toolbar handlers
on objectui `main`. It keeps saving.
- No body that was accepted is refused for that producer, so **no
ADR-0087 D2 conversion** is needed.
- The semantic entry `view-overlay-judged-by-viewkind-arm` records the
write-time refusals, and the registry is regenerated.
- Stored rows in deployments: NOT MEASURED (no deployment store is
reachable).
## Verification
All heavy runs went through `os-verify-lock`. The lock is shared, so
every timing is a shared-box reading.
**Pre-merge, at `750d0c82`** (the branch merged with `805af4f2`), in one
locked batch (`VERDICT command-exit 0`, held 984s):
- Build of the `metadata-protocol`, `objectql`, `lint` and `rest`
dependency closures (the `^...` filter): exit 0.
- `spec check:generated`: exit 0, all 15 artefacts current,
`api-surface/` included, so no generated artefact moves.
- `spec` vitest `--project local`: 545 files passed, 16059 tests passed,
2 todo.
- `metadata-protocol` vitest: 189 files passed, 3 skipped; 2715 tests
passed, 19 skipped. `metadata-protocol` typecheck: exit 0.
- Consumer suites, every test file in the package that names `viewKind`:
`objectql` 24 files / 380 tests, `lint` 8 files / 349 tests, `rest` 11
files / 153 tests. All passed.
- `spec` vitest `--project repo`: 32 files / 586 tests, measured at
`87f9d156` in an earlier locked batch. After that, `packages/spec`
changed only by a type annotation (`e272672a`). `spec typecheck` exited
0 on those bytes.
**Post-merge, at `859730ef`** (merged with `c02fa127`):
- The merge brought #20227 (view-item `owner`/`hidden` retirement, a
disjoint region of `view.zod.ts`) and others. It merged clean, and the
migration registry regenerates byte-identical.
- The locked rebuild plus `check:generated`, `spec` local vitest and
`metadata-protocol` vitest is queued. Until it runs it is NOT MEASURED
here, and the dev report carries its reading.
**Gates.** I re-derived the list on the actual paths
(`dispatch-gates.mjs --commands`: 88 families) and ran every line, plus
the 5 artefact-roster gates whose roster sits in a directory this diff
touches. Exit codes went to disk before anything was read.
- `--ran` reconciliation at `859730ef`: 88 derived, 81 run green, 7
NOT-MEASURED (exit 3, PREREQUISITE NOT MET), 0 unrun.
- Five of the seven (`spec` `check:api-surface`,
`check:browser-reachable-entries`, `check:dual-source-exports`,
`check:entry-nameability`, `check:exported-any`) read the built `dist`.
They were green at `750d0c82`, and the post-merge rebuild re-reads them.
- The other two (`check:dual-build-cjs-loads`, `check:type-check-debt`)
need the whole-workspace build and are declared to CI.
- The 5 roster gates also ran green.
- `check:engine-double-contract` and `check:objectql-double-limit`
flagged the stub engine my first save-door test file declared. I moved
those pins into the existing, ledgered double in
`protocol.graft-folded-form-sections.test.ts`, and both gates are green:
no ledger change.
**Consumer notes.**
- objectui's own suites run against its pinned spec version and were not
run here.
- `examples/**` carries no `viewKind` at all, so it has no
objectui-facing overlay fixture to sweep.
## Acceptance notes
- **W1, a same-family residual, not filed here.** A column-less list
patch carrying an invalid form-only key is now accepted with the key
dropped unread.
- `reach:` measured at the public door, `saveMetaItem` (the `PUT
/api/v1/meta/view` path), on this branch: `{ name: 'crm_lead.all',
object: 'crm_lead', viewKind: 'list', isPinned: true, layout: 'diagonal'
}` answers `success: true`, and the row holds `layout: 'diagonal'` as
sent (a throwaway probe, never committed). The same save with `columns:
['name']` added answers identically.
- Control on `origin/main` `805af4f2`: `{ name: 'crm_lead.all', object:
'crm_lead', viewKind: 'list', columns: ['name'], layout: 'diagonal' }`
is ACCEPTED on listOverlay, with output
`{"name":"crm_lead.all","type":"grid","columns":["name"],"object":"crm_lead","viewKind":"list"}`
(`layout` dropped). So this change routes more bodies to the list
member's existing handling of undeclared keys; it does not create that
handling.
- F2 (refusing the 19 form-only keys by name on the list member) was not
taken on this card, per seat answer `5855548706`. The seat decides
filing at ACCEPT.
- Dedupe words: `list overlay form-only key stripped` · `flattened list
overlay layout dropped unread` · `W1 form key on list patch`.
- The `graftFoldedFormSections` behaviour change above (a list body's
`groups` is no longer folded to `sections`) is the same residual seen
from the save path.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d3ec47 commit a91d12a
7 files changed
Lines changed: 718 additions & 20 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- spec/src
- migrations
- entries/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 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
410 | 410 | | |
411 | 411 | | |
412 | 412 | | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 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 | + | |
0 commit comments