Skip to content

Commit 9905e61

Browse files
fix(metadata-protocol,spec)!: a saved view stores the parsed value of every key its body carried, and a ViewItem record's top-level options is refused by name (stage iv of #20051) (#20868)
Fixes #20051 Clause-②: no Stage (iv), the last stage, of ruling 甲 (`5856781584`, confirmed `5856866699`), carried out under letter B of ruling `5910106571`. Maintainer verbatim: 「同意 批次 #256」. The stored-row census stays waived: 「20051 不考虑现有的数据」 (`5909085121`). The at-tier contract review is the `domain:spec` seat's, and it is owed before this PR is enqueued. This PR carries no review record of its own. ## What changes **1. A saved view stores the parsed value of every key its body carried.** `saveMetaItem` now stores `projectStorableViewBody(request.item, parsed.data, reparse)` for `view`, where it used to store the request body with two grafts. The rules: - An undeclared key is dropped. Every key the console reads back is declared (`VIEW_CONSOLE_ROUND_TRIP_KEYS`), so nothing it needs is lost. - A declared key keeps its parsed, normalized value. - A moved key is stored under its canonical spelling: `groups` → `sections`, and `visibleOn` → `visibleWhen` (never grafted before). - A schema default the author did not write is not stored. This is ADR-0087's `storable` rule, the one flows follow. Keys that are moved and defaults look alike in the parse output, so the projection re-parses the body it built. A key the parse added is kept only when the re-parse does NOT reproduce it. The loop ends when the stored body re-parses to exactly the save's parse. If it does not converge, the fallback stores the whole parse output: no key is lost, and a warning is logged once per view. Nothing in today's `view` schema reaches that fallback. The projection is gated on `singularType === 'view'`, the same way the flow branch is. Every other type keeps its body verbatim with the two grafts. Both grafts still serve other types: `ViewFilterRuleSchema` is reused by page, component and skill schemas, and `FormViewSchema` by component, sharing and field schemas. A page save stays pinned verbatim here as the control. **2. A ViewItem record's top-level `options` bag is refused by name (Q3 = A).** It is pinned absent on both `ViewItemWireSchema` arms. The response is `422 INVALID_METADATA` at `options`, with the prescription to write `config.KIND`. It uses `z.never()`, not the form overlay's `z.undefined()`, because `ViewItemWire.json` is published on its own and `undefined` has no JSON Schema form. With `z.undefined()` the build refused ("1 previously published schema(s) disappeared"). The ADR-0087 disposition is `registered view-item-options-bag-refused`, a new step-18 semantic entry. **3. ADR citation.** The five "ADR-0005 §Validation" citations in `protocol.ts` now cite ADR-0005 appendix (c), the "Addendum — 2026-05-16 (c): spec validation on overlay save". The same goes for the one in `view.zod.ts`. `scripts/adr-anchors/packages__metadata-protocol__src__protocol.ts.json` gains ADR-0005 and ADR-0087 with the invariant. No `docs/adr/**` file is edited. ## Measured before the change (the console's own writes at `.objectui-sha` `db11afd4967c`, through the real `saveMetaItem`) - **W1 to W5**, the toolbar saves of sort, density, hidden fields, column widths and inline edit: the parse adds `type: 'grid'`. Now none is stored. W1 also stops storing its sort row `id`. - **W6b**, the saved-view toolbar toggle, which writes the whole tab back: `id` and `objectName` are no longer stored. - **W7 to W9**, config save, create, pin, set-default, reorder and rename: stored unchanged. - **Form overlay:** it no longer stores `sharing.enabled: false` or the section `collapsible` / `collapsed` / `columns` defaults. - **Why letter B beats A, measured by ablation 2 below:** storing `parsed.data` whole would have minted toolbar rows that FAIL their own re-save. A column-less patch carrying the `grid` default is refused as "sets `type` but lists no `columns`". ## Pins - **Re-judged in one round (the full-repo sweep):** - metadata-protocol `protocol.graft-folded-form-sections.test.ts`: the two GUARDs and the coexistence case. - The Studio round-trip GUARD moves to the list overlay, where the keys are declared. It now covers every key `VIEW_CONSOLE_ROUND_TRIP_KEYS` maps to `listOverlay`. - The form overlay now drops `isPinned` / `sortOrder` (undeclared there) and keeps `isDefault`. - "A form with canonical `sections` is stored as authored": no section default, no `sharing.enabled`, and no undeclared `isPinned`. - objectql `protocol-meta.test.ts`: the declared round-trip keys are stored, and `objectName` is dropped (`object` carries the binding). - objectql `protocol-publish-rollback.test.ts`: the diff's added key is the declared `rowHeight`, and the undeclared `extra` never reaches a version. - objectql `protocol-view-identity-overlay.test.ts`: the sort row `id` is not stored. - spec `view-union-retirement-prescription.test.ts`: the never-leaf population admits a closed two-entry family of pinned-absent refusals. Each entry must be reached and must match its curated text. - **New**, in the same metadata-protocol file on its pinned engine double: - W1 to W5 store no `type`. - An undeclared key is dropped (`id`, `objectName`, a form-only `layout`). - A declared key keeps its normalized value (`exportOptions`, operator). - A key moved inside a moved key is stored canonical, with no default beside it. - The persisted body parses to exactly what the save parsed. - A record's `options` is refused on both arms, asserting envelope `code` + `status` + path + message, with 0 rows written. - The page control. - Four structural cases for the helper. - **New in spec** `view-overlay-options-bag.test.ts`: the record refusal on both arms, plus a control. - The card body's three pins still stand: - A direct and an `options`-wrapped out-of-contract key get the same refusal (door-half block). - A legal `options.map` round-trips. - The persisted body equals the parse result: the new parse-equivalence pin. ## Evidence (head `41913270bf`) - **Suites, locked runs:** - metadata-protocol full: 191 files, 2819 passed, 19 skipped. - objectql full: 345 files, 6778 passed. - rest full: 237 files, 4641 passed, 99 skipped. - spec `src/ui` + `src/migrations`: 97 files, 3560 passed. The whole spec `local` project also ran before the `origin/main` merge, all green once the retirement-population pin was re-judged. - runtime, the 19 files that save or read a view: 990 passed. - **Typecheck:** spec, metadata-protocol, objectql and lint all exit 0. The spec run chains `check:scripts-typecheck` and `check:test-typecheck`. - **Ablation 1** (the view branch disabled, and the record refusal renamed away, with `dist/` rebuilt and `ablation-dist-preflight` markers present in both packages): - metadata-protocol file: 9 red, 40 green. - objectql's three re-judged files: 3 red. - spec: 4 red. One of them is the population pin reading the renamed leaf. - Restore: blob equals HEAD for both files, markers absent from both `dist/`, and `git status --porcelain` empty. - **Ablation 2** (store `parsed.data` whole, which is option A): 20 red of 49 in the metadata-protocol file, all five toolbar pins among them. Restored to the HEAD blob. - **Gates:** `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 125. - 123 exit 0. `check:engine-split-ratio` first refused on the shallow clone and passed after `fetch --shallow-since`. `check:skill-examples` first refused on a missing client-react `dist` and passed after building it. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 PREREQUISITE NOT MET (they need a whole-repo build; CI runs it). - `--ran` reconciliation: 0 UNRUN. - `check:generated`: all 15 artifacts current; `content/docs/references/ui/view.mdx` was regenerated. - **eslint (narrowed):** - Measured over the 15 changed `.ts` files; each resolves under `eslint --print-config`. - `--no-inline-config --format json`: 15 files, 0 errors, 0 warnings. - `eslint.config.mjs` enables no type-aware linting (its own comment says so), so this diff cannot move a verdict on an untouched file. ## Docs follow-up (Tier H, separate PR for the maintainer's hand) ADR-0005 must change in three places: 1. The Status line clause: "and the storage switch itself is the ruling's last stage, not yet landed — see the amendment note under that addendum's persisted-document bullet". 2. Appendix (c): "⚠️ **Ruled, not yet in force:** the storage switch is the ruling's last stage and has not landed, so today `saveMetaItem` still stores the request body (`request.item`)." 3. Appendix (c)'s bullet "The persisted document is the **parsed body** (`parsed.data`) once every round-trip key is declared". Under letter B it becomes: the parsed value of every key the body carried, with no schema default materialised. ## Acceptance notes - **Other types.** `resolveOverlaySchema` validates every type `getMetadataTypeSchema` registers, not only `view` and `dashboard`, and all of them still store the request body. Widening the projection is its own decision. - **An objectui read-side drop, present today and independent of this PR.** objectui's adapter flattens a ViewItem record in `listViews` and keeps only `config` plus `name` / `label` / `isDefault`. A saved view's toolbar toggle writes that tab back whole, so it rewrites the row without `isPinned` / `sortOrder` / `visibility` / `columnState`. This is a code read at the pin, not measured in a browser. Its carrier is objectui#11013, the objectui card on aligning console reads. - **Other stale citations.** "ADR-0005 §Validation" is still cited outside the files this PR touches (for example `packages/metadata-core/src/injected-system-columns.ts:132` and three objectql object-roundtrip test headers). Those stay for the next change that touches them. --- _Generated by [Claude Code](https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d5fe76 commit 9905e61

19 files changed

Lines changed: 1021 additions & 118 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(metadata-protocol,spec)!: a saved view stores the parsed value of every key its body carried, and a ViewItem record's top-level `options` bag is refused by name (#20051)
7+
8+
**BREAKING** — two narrowings on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save). They ship as `minor` under the repo's launch-window convention for breaking changes. This is stage (iv), the last stage, of ruling 甲 on #20051. The storage half follows the maintainer's ruling on its Q2, letter B (「同意 批次 #256」).
9+
10+
Clause-②: no (narrowing)
11+
12+
## What changes
13+
14+
**1. What a saved view stores.** `saveMetaItem` used to validate a `view` body and then store the request body as sent, with two normalizations grafted back (filter operator spellings, and form `groups` → `sections`). It now stores the parsed value of every key the body carried, and nothing else:
15+
16+
- **An undeclared key is dropped.** The members' top-level `.strip()` used to drop it from the parse only, so it lived in the store and nowhere in the contract. Every key the console reads back off a stored row is declared (`VIEW_CONSOLE_ROUND_TRIP_KEYS`), so nothing the console relies on is lost. The keys the console writes that are dropped are the ones mapped rather than declared in that record: a sort row's `id` (the builders mint a fresh one), a top-level `id` (read only when a row has no `name`), and `objectName` (every reader falls back to `object`, which both writers stamp).
17+
- **A declared key keeps its normalized value.** Examples: `notEquals` → `not_equals`, `exportOptions: ['csv']` → `{ formats: ['csv'] }`, and a CEL string → its expression object.
18+
- **A moved key is stored under its canonical spelling.** Examples: `groups` → `sections`, and `visibleOn` → `visibleWhen`. The second was never grafted before, so a form stored with `visibleOn` kept the legacy spelling.
19+
- **A schema default the author did not write is NOT stored.** This is ADR-0087's `storable` rule, the one flows already follow: a stored row never pins the day's default. A console toolbar save (sort, density, hidden fields, column widths, inline edit) stores no `type: 'grid'`. A form stores no `sharing.enabled` and no section `collapsible` / `collapsed` / `columns` it did not write. Storing the whole parse output instead would also have minted rows that fail their own re-save: a column-less toolbar patch carrying the `grid` default is refused as "sets `type` but lists no `columns`".
20+
21+
The stored row re-parses to exactly what the save accepted, so a GET → PUT of it is judged the same. Every other metadata type keeps its request body, with the two grafts, as before.
22+
23+
**2. A ViewItem record's top-level `options` bag is refused.** On a record (`{ name, object, viewKind, config }`), the member's top-level strip used to drop `options` from the parse unread while the save stored it. The interface page then rendered it and the object page did not. It is now refused by name with `422 INVALID_METADATA` at `options`, and the message prescribes `config.KIND`. No console write puts the bag on a record. The flattened list overlay's legacy `options` bag is unchanged: it is judged key by key, and objectui pins it.
24+
25+
## FROM → TO
26+
27+
| you wrote | the stored row / the door now |
28+
|:--|:--|
29+
| a view body with a key its member does not declare (`objectName`, a form-only `layout` on a list view, `isPinned` on a form) | the key is not stored: write only declared keys (`object`, not `objectName`) |
30+
| a view body relying on a schema default being written into the row | the row carries only what you wrote; the parse applies the default on every read |
31+
| `sort: [{ id, field, order }]` | stored as `sort: [{ field, order }]` |
32+
| `groups: [...]` / `visibleOn: '…'` on a form | stored as `sections: [...]` / `visibleWhen: { dialect: 'cel', source: '…' }` |
33+
| a ViewItem record with `options: { kanban: {...} }` | `422` at `options`: move it to `config: { kanban: {...} }` (`config.KIND`), or remove it |
34+
35+
**The one-line fix:** write the declared spelling. A stored view you read back is what the contract accepts, and a record's per-kind blocks live under `config`.
36+
37+
## Existing rows
38+
39+
Stored rows are not migrated and not re-read differently. The maintainer's word on this card is 「20051 不考虑现有的数据」. A row keeps its bytes until its next save, and that save stores it as described above. A record carrying a top-level `options` is refused on its next save and served as stored until then.
40+
41+
<!-- adr-0087: registered view-item-options-bag-refused -->

‎content/docs/references/ui/view.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2260,6 +2260,7 @@ This schema accepts one of the following structures:
22602260
| **sortOrder** | `integer` | optional | Console round-trip: the view's position among the object's saved views in the switcher, 0-based and counted over saved views only (a code-defined view carries none). Written by the console's drag-reorder and read back to order the tabs. Not authored: `order` is the authored default position. |
22612261
| **visibility** | `Enum<'private' \| 'team' \| 'organization' \| 'public'>` | optional | Console round-trip: the group the switcher files this view's tab under (private, team, organization or public). Display grouping only, NOT access control: nothing restricts who can list or open the view by this value. No console control sets it; the console carries a stored value forward when it re-saves the row. Not authored. |
22622262
| **columnState** | `{ order?: string[]; widths?: Record<string, number> }` | optional | Studio round-trip: column order/widths (runtime-only state, written by the console grid and stored on the view's row, which has no per-user scope — not authored) |
2263+
| **options** | `never` | optional | Refused: a view item record carries no top-level `options` bag. Its per-kind blocks live under `config` (`config.kanban`, `config.timeline`, …); the legacy `options.KIND` bag belongs to the flattened list overlay only. |
22632264

22642265
### Nested Shape: `ViewItemWire[viewKind='list'].config`
22652266

@@ -2361,6 +2362,7 @@ This schema accepts one of the following structures:
23612362
| **sortOrder** | `integer` | optional | Console round-trip: the view's position among the object's saved views in the switcher, 0-based and counted over saved views only (a code-defined view carries none). Written by the console's drag-reorder and read back to order the tabs. Not authored: `order` is the authored default position. |
23622363
| **visibility** | `Enum<'private' \| 'team' \| 'organization' \| 'public'>` | optional | Console round-trip: the group the switcher files this view's tab under (private, team, organization or public). Display grouping only, NOT access control: nothing restricts who can list or open the view by this value. No console control sets it; the console carries a stored value forward when it re-saves the row. Not authored. |
23632364
| **columnState** | `{ order?: string[]; widths?: Record<string, number> }` | optional | Studio round-trip: column order/widths (runtime-only state, written by the console grid and stored on the view's row, which has no per-user scope — not authored) |
2365+
| **options** | `never` | optional | Refused: a view item record carries no top-level `options` bag. Its per-kind blocks live under `config` (`config.kanban`, `config.timeline`, …); the legacy `options.KIND` bag belongs to the flattened list overlay only. |
23642366

23652367
### Nested Shape: `ViewItemWire[viewKind='form'].config`
23662368

‎packages/lint/src/runtime-gate.view-writes.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ const objects = [
4848
/**
4949
* A flattened standalone list overlay, as the wire carries it: a raw ListView
5050
* config at the TOP level, `object` + `viewKind` required (#7741), plus the
51-
* console decorations a personalization PUT persists — `sort[].id` is
52-
* objectui's `crypto.randomUUID()` row id (#5074), stored verbatim because
53-
* `saveMetaItem` persists the original body.
51+
* console decorations a personalization PUT carries — `sort[].id` is
52+
* objectui's `crypto.randomUUID()` row id (#5074). It was stored verbatim
53+
* until #20051 made a view store its parsed body; rows stored before that
54+
* still carry it, so the rules keep being judged on it.
5455
*/
5556
const overlay = (patch: Record<string, unknown>) => ({
5657
name: 'crm_case.custom',
@@ -264,7 +265,7 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
264265
// (`view.zod.ts`'s #5074 trace), so every pin/reorder toggle round-trips the
265266
// whole record, `config` included.
266267

267-
/** A ViewItem record as `saveMetaItem` stores it (original body, verbatim). */
268+
/** A ViewItem record as the console PUTs it back (the stored record plus the patch). */
268269
const record = (
269270
configPatch: Record<string, unknown>,
270271
patch: Record<string, unknown> = {},

0 commit comments

Comments
 (0)