From 3ee095b758256d4ba3a7b0e9ae8bcc735d129271 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:38:37 +0000 Subject: [PATCH 1/6] feat(spec): declare the console's round-trip keys on the stored view wire The stored view overlay's `.strip()` dropped the keys objectui's console writes onto a stored `view` row and reads back: `isPinned` / `sortOrder` on the flattened list overlay, `visibility` on both the list overlay and the ViewItem record, and the settings-overlay marker `_isOverride`. `saveMetaItem` stores the request body verbatim, so they lived in the store and nowhere in the contract. - `viewSwitcherRowStateFields()` declares `isPinned`, `sortOrder` and `visibility` once, with their meaning, for the ViewItem wire member and the flattened list overlay. - The list overlay also declares `_isOverride: true`, and its existing `isDefault` gains its meaning. - `VIEW_CONSOLE_ROUND_TRIP_KEYS` records the census: each round-trip key and the members whose rows carry it. - The authoring door names `visibility` in its refusal guidance. What is persisted does not change: the save still stores the request body. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../ui/view-console-round-trip-keys.test.ts | 169 +++++++++++++++++ packages/spec/src/ui/view.zod.ts | 170 ++++++++++++++++-- 2 files changed, 327 insertions(+), 12 deletions(-) create mode 100644 packages/spec/src/ui/view-console-round-trip-keys.test.ts diff --git a/packages/spec/src/ui/view-console-round-trip-keys.test.ts b/packages/spec/src/ui/view-console-round-trip-keys.test.ts new file mode 100644 index 00000000000..2a6d8cf50f9 --- /dev/null +++ b/packages/spec/src/ui/view-console-round-trip-keys.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20456 — the console's round-trip keys on a stored `view` row are declared on + * the wire members that judge that row, with their meaning, so a parse keeps + * them (stage ii of the ruling on #20051). + * + * ## Why this is a closure pin + * + * `saveMetaItem` stores the request body verbatim (ADR-0005 appendix (c)), so a + * key the console writes and reads back lived in the store while the members' + * `.strip()` dropped it from the parse. Nothing failed: the parse output was + * simply never persisted. The end state the ruling names is the parsed body + * becoming the stored one, and it may land only once every round-trip key is + * declared. This file is what "every" means. + * + * `CENSUS` below is the measurement, written down independently of the spec's + * own record: the keys objectui's console writes onto a stored `view` row and + * reads back, at the objectui `.objectui-sha` pin, per row shape. The pin holds + * {@link VIEW_CONSOLE_ROUND_TRIP_KEYS} equal to it, and each (key, member) pair + * declared, described and KEPT by a parse. Removing one declaration from a + * member turns the pair red; adding a key to the record without the census + * (or the reverse) turns the equality red. + * + * ## What the census deliberately maps elsewhere + * + * The alias spellings it found (`objectName`, a top-level `id`) have declared + * spellings already (`object`, `name`); declaring the alias too would be the + * second spelling this contract refuses. They are pinned ABSENT below, so the + * choice is visible rather than an oversight. + */ +import { describe, expect, it } from 'vitest'; +import { + VIEW_CONSOLE_ROUND_TRIP_KEYS, + VIEW_METADATA_MEMBERS, + ViewItemSchema, + ViewMetadataSchema, + type ViewMetadataBranch, +} from './view.zod'; + +/** + * The measured census (objectui at the `.objectui-sha` pin): row-shape + * branches the console writes each key on. `viewItem` = a ViewItem record + * (`{ name, object, viewKind, config }`), `listOverlay` = a flattened list row. + */ +const CENSUS: Record = { + isDefault: ['viewItem', 'listOverlay'], + isPinned: ['viewItem', 'listOverlay'], + sortOrder: ['viewItem', 'listOverlay'], + visibility: ['viewItem', 'listOverlay'], + columnState: ['viewItem', 'listOverlay'], + _isOverride: ['listOverlay'], +}; + +/** A representative value the console writes for each key. */ +const VALUE: Record = { + isDefault: true, + isPinned: true, + sortOrder: 3, + visibility: 'team', + columnState: { order: ['name'], widths: { name: 120 } }, + _isOverride: true, +}; + +const DATA = { provider: 'object', object: 'crm_lead' } as const; + +/** One row per branch, in the shape the console stores it. */ +const ROW: Record<'viewItem' | 'listOverlay', Record> = { + viewItem: { + name: 'crm_lead.mine', + object: 'crm_lead', + viewKind: 'list', + label: 'Mine', + config: { type: 'grid', data: DATA, columns: ['name'] }, + }, + // A toolbar patch on a code-defined view: the patch, `viewKind`, and the + // `object` / `name` the adapter stamps. + listOverlay: { rowHeight: 'compact', viewKind: 'list', object: 'crm_lead', name: 'crm_lead.all' }, +}; + +/** The top-level shapes of a member (both arms of the ViewItem record). */ +function topLevelShapes(branch: ViewMetadataBranch): Array> { + let def: any = (VIEW_METADATA_MEMBERS[branch] as any)._zod.def; + while (def.type !== 'object' && def.type !== 'union') def = (def.in ?? def.innerType ?? def.schema)._zod.def; + if (def.type === 'object') return [def.shape]; + return def.options.map((arm: any) => arm._zod.def.shape); +} + +/** The field's `.describe()` meaning, on the field or under its optional wrapper. */ +function meaningOf(field: any): string | undefined { + for (let node = field; node; node = node._zod?.def?.innerType) { + if (typeof node.description === 'string' && node.description.trim()) return node.description; + } + return undefined; +} + +describe('VIEW_CONSOLE_ROUND_TRIP_KEYS — the census record (#20456)', () => { + it('equals the measured census, key for key and branch for branch', () => { + const record = Object.fromEntries( + Object.entries(VIEW_CONSOLE_ROUND_TRIP_KEYS).map(([k, v]) => [k, [...v].sort()]), + ); + const census = Object.fromEntries(Object.entries(CENSUS).map(([k, v]) => [k, [...v].sort()])); + expect(record).toEqual(census); + }); +}); + +describe('each census key is declared on each member that judges its row (#20456)', () => { + const pairs = Object.entries(CENSUS).flatMap(([key, branches]) => branches.map((b) => [key, b] as const)); + + it.each(pairs)('`%s` is a declared, described member of `%s`', (key, branch) => { + for (const shape of topLevelShapes(branch)) { + expect(Object.keys(shape), `${branch} does not declare \`${key}\``).toContain(key); + expect(meaningOf(shape[key]), `${branch}.${key} carries no \`.describe()\` meaning`).toBeTruthy(); + } + }); + + it.each(pairs)('a parse of a `%s` row through `%s` keeps it', (key, branch) => { + const body = { ...ROW[branch as 'viewItem' | 'listOverlay'], [key]: VALUE[key] }; + const direct = VIEW_METADATA_MEMBERS[branch].safeParse(body); + expect(direct.success, JSON.stringify(direct.success ? null : direct.error.issues)).toBe(true); + expect((direct.data as Record)[key]).toEqual(VALUE[key]); + // …and through the union `saveMetaItem` validates with, which is the parse + // the stored body would become. + const union = ViewMetadataSchema.safeParse(body); + expect(union.success).toBe(true); + expect((union.data as Record)[key]).toEqual(VALUE[key]); + }); +}); + +describe('the declarations are typed, not passthrough (#20456)', () => { + it.each([ + ['isPinned', 'yes'], + ['sortOrder', 1.5], + ['visibility', 'everyone'], + ['_isOverride', false], + ])('a list overlay row with `%s: %j` is refused at that key', (key, value) => { + const r = ViewMetadataSchema.safeParse({ ...ROW.listOverlay, [key]: value }); + expect(r.success).toBe(false); + expect(JSON.stringify(r.success ? [] : r.error.issues)).toContain(`"${key}"`); + }); + + it('a ViewItem record with an unknown `visibility` group is refused', () => { + expect(ViewMetadataSchema.safeParse({ ...ROW.viewItem, visibility: 'everyone' }).success).toBe(false); + }); +}); + +describe('what the census mapped to an existing spelling stays undeclared (#20456)', () => { + it.each([ + ['objectName', 'object'], + ['id', 'name'], + ])('`%s` is declared on no member — its declared spelling is `%s`', (alias, canonical) => { + for (const branch of Object.keys(VIEW_METADATA_MEMBERS) as ViewMetadataBranch[]) { + for (const shape of topLevelShapes(branch)) expect(Object.keys(shape)).not.toContain(alias); + } + for (const branch of ['viewItem', 'listOverlay'] as const) { + for (const shape of topLevelShapes(branch)) expect(Object.keys(shape)).toContain(canonical); + } + }); +}); + +describe('the authoring door still refuses the console-only keys (#20456)', () => { + it.each(['visibility', 'isPinned', 'sortOrder', '_isOverride'])('`defineViewItem` input with `%s` is refused by name', (key) => { + const r = ViewItemSchema.safeParse({ ...ROW.viewItem, [key]: VALUE[key] }); + expect(r.success).toBe(false); + const issue = r.success ? undefined : r.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect((issue as { keys?: string[] } | undefined)?.keys).toContain(key); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 4cc29d8a5ac..05be82173ac 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5082,6 +5082,10 @@ const VIEW_ITEM_SURFACE = { sortOrder: 'Switcher position is per-user Studio state, not authored metadata — use `order` for the authored default. Remove it from authored metadata.', // [#9933] Runtime-only overlay key — same disposition as the two above. columnState: 'Column order/widths are per-user runtime personalization the console grid writes through the `view` metadata API — not authored metadata. Remove it from authored metadata.', + // [#20456] Declared on the wire members with the switcher's other row + // state; named here so an author who reaches for it as access control is + // told what it is. + visibility: '`visibility` is the view switcher\'s display grouping (private / team / organization / public), which the console keeps on the stored row — it is not authored metadata and it restricts nobody: every user who can read the object can list and open the view. Remove it from authored metadata.', }, } as const; @@ -5123,24 +5127,82 @@ export const ViewItemSchema: z.ZodDiscriminatedUnion<[ ]), ); +/** + * [#20456] The switcher groups the console files a view's tab under, by + * `visibility`. Read off the console's own group order (private, team, + * organization, public). Declared ABOVE {@link viewItemWireFields} on purpose: + * under `OS_EAGER_SCHEMAS=1` every `lazySchema` factory runs at module init in + * file order, and a `const` below its first eager reader is a TDZ error. + */ +const VIEW_SWITCHER_VISIBILITY_GROUPS = ['private', 'team', 'organization', 'public'] as const; + +/** + * [#20456] The switcher's row state: the keys the console writes onto a STORED + * `view` row and reads back to draw the view switcher. One declaration, spread + * into every wire member that judges a row the console writes them on: the + * ViewItem record ({@link viewItemWireFields}) and the flattened LIST overlay + * ({@link listOverlayRoundTripFields}). The form overlay is not one of them: the + * switcher lists list-family views only, so no console write puts these keys on + * a form row. + * + * Measured, not recalled: the census on objectstack#20456 walked objectui's + * stored-view readers at the `.objectui-sha` pin and ran the console's write + * bodies through {@link ViewMetadataSchema}. Before this declaration the list + * overlay's `.strip()` dropped `isPinned` and `sortOrder` from the parse, and + * both members dropped `visibility`: `saveMetaItem` stores the request body + * verbatim (ADR-0005 appendix (c)), so the keys lived in the store and nowhere + * in the contract. + * + * None of the three is per-user. A stored `view` row is environment metadata + * with no per-user scope (ADR-0017, amended: per-user view scoping is a parked + * direction), so a pin, a position or a group applies to everyone who reads + * the view. + * + * ⛔ Not authorable. The authoring doors ({@link ViewItemSchema}, + * `ListViewSchema`) do not declare them and refuse them by name. + */ +function viewSwitcherRowStateFields() { + return { + isPinned: z.boolean().optional() + .describe( + 'Console round-trip: the view is pinned in the object\'s view switcher. Written by the ' + + 'console\'s pin toggle through the `view` metadata API and read back to draw the pinned group. ' + + 'Stored on the view\'s row, which has no per-user scope. Not authored.', + ), + sortOrder: z.number().int().optional() + .describe( + '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.', + ), + visibility: z.enum(VIEW_SWITCHER_VISIBILITY_GROUPS).optional() + .describe( + '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.', + ), + }; +} + /** * Auxiliary Studio round-trip keys, given an explicit DECLARED home on the wire * variant (#5074) instead of living implicitly on "the member nobody closed". * - * These are per-user switcher state the console writes through the `view` - * metadata API and reads back; `saveMetaItem` persists the body verbatim, so - * they are on the wire and in the store. They are deliberately declared HERE and - * not on {@link ViewItemSchema}: an author who writes `isPinned` in a `*.view.ts` - * gets a named rejection pointing at `order`, while the console's own PUT parses. + * The console writes them through the `view` metadata API and reads them back; + * `saveMetaItem` persists the body verbatim, so they are on the wire and in the + * store. They are deliberately declared HERE and not on {@link ViewItemSchema}: + * an author who writes `isPinned` in a `*.view.ts` gets a named rejection + * pointing at `order`, while the console's own PUT parses. [#20456] The + * switcher's three keys come from {@link viewSwitcherRowStateFields}, the one + * declaration the list overlay shares. */ function viewItemWireFields() { return { - isPinned: z.boolean().optional() - .describe('Studio round-trip: view pinned in the switcher (per-user state, written by the console — not authored).'), - sortOrder: z.number().int().optional() - .describe('Studio round-trip: position within the switcher (per-user state, written by the console — not authored).'), - // [#9933] Same disposition as the two keys above: per-user state the - // console writes through the `view` metadata API. `updateView` PUTs + ...viewSwitcherRowStateFields(), + // [#9933] Same disposition as the switcher keys: console state written + // through the `view` metadata API. `updateView` PUTs // `{ ...current, ...partial }`, so on a standalone ViewItem record the // key arrives at THIS member's top level; declaring it validates the // shape where `.strip()` used to let it ride through unchecked. @@ -5419,7 +5481,11 @@ function flattenedViewOverlayFields(kind: K) { // AND gets its inner shape genuinely validated instead of ridden past // `.strip()` unchecked. columnState: ViewColumnStateSchema.optional(), - isDefault: z.boolean().optional(), + // [#20456] A console round-trip key (the switcher's set-default writes it + // and reads it back), declared here since before that census; now it + // carries its meaning too. + isDefault: z.boolean().optional() + .describe('Whether this is the object\'s default view in the switcher. The console\'s set-default writes it, and the console opens the default view when the URL names none.'), order: z.number().int().optional(), scope: ViewScopeSchema.optional(), // [#20230] RETIRED — ADR-0049 enforce-or-remove, the view item's pair @@ -5884,6 +5950,40 @@ function listOverlayPatchFields() { }; } +/** + * [#20456] The console's round-trip keys on a flattened LIST overlay row: the + * switcher's row state ({@link viewSwitcherRowStateFields}, shared with the + * ViewItem record) plus the settings-overlay marker, which only this row shape + * carries. + * + * `_isOverride` is the discriminant objectui's adapter stamps on the row it + * writes for a toolbar change to a CODE-DEFINED view (density, sort, hidden + * fields, column widths, inline edit), and the only thing that classifies a + * stored row as that view's settings overlay rather than a saved view of its + * own: the console's view list excludes a marked row from the switcher, and + * the merge over the source view takes only the overlay's own keys from it. + * The parse used to strip it. Were the parsed body ever the stored one, a + * toolbar change would come back as a saved view of its own (listed in the + * switcher, with rename and delete), and the merge over its source view would + * no longer be narrowed to the overlay's own keys. Spelled + * as the console writes it, with the leading underscore of the other + * platform-stamped keys (`_lock`, `_lockReason`); only `true` is ever written, + * and only `true` is declared. + */ +function listOverlayRoundTripFields() { + return { + ...viewSwitcherRowStateFields(), + _isOverride: z.literal(true).optional() + .describe( + 'Console round-trip: `true` marks this row as the console\'s settings overlay for the ' + + 'code-defined view it is saved under (a toolbar change: density, sort, hidden fields, ' + + 'column widths, inline edit), not a saved view of its own. The console leaves a marked row ' + + 'out of the view switcher and merges only the overlay\'s own keys over the source view. ' + + 'Stamped by the console; not authored.', + ), + }; +} + /** * [#20186] A column-less flattened list overlay that NAMES a `type` is a full * inline config missing its columns — refused at `columns`. Reads the input @@ -5992,6 +6092,9 @@ const ListViewOverlayWireSchema = lazySchema(() => ...flattenedViewOverlayFields('list'), options: ListViewOverlayOptionsSchema.optional(), ...listOverlayPatchFields(), + // [#20456] The console's round-trip keys on this row shape, declared so + // the parse keeps them instead of `.strip()`ping them unread. + ...listOverlayRoundTripFields(), }).strip() .superRefine(checkListOverlayTypeNeedsColumns) .superRefine(checkListViewCalendarVisualization) @@ -6066,6 +6169,49 @@ export const VIEW_METADATA_MEMBERS = { formOverlay: FormViewOverlayWireSchema, } as const satisfies Record; +/** + * [#20456] The console's round-trip keys on a stored `view` row: every + * top-level key objectui's console WRITES onto a stored row and READS BACK, + * mapped to the {@link VIEW_METADATA_MEMBERS} branches whose rows the console + * writes it on. Each is declared, with its meaning, on each of those members, + * so a parse of the row keeps it. This is the spec symbol the ADR-0005 + * appendix (c) amendment cites: "every round-trip key is declared" means this + * record, closed by `view-console-round-trip-keys.test.ts`. + * + * Measured on objectstack#20456 against objectui at the `.objectui-sha` pin: + * a syntax walk of property reads in the console's stored-view readers, + * crossed with the console's write bodies run through + * {@link ViewMetadataSchema}. Keys that census found and deliberately left + * OFF this record, with the declared spelling each one maps to: + * + * - `objectName` / `object_name` → `object` (declared, required on both + * overlays). The console stamps `objectName` onto rows it has read, and a + * saved view's toolbar save writes it back; every reader already falls back + * to `object`. + * - a top-level `id` / `_id` → `name`. The console reads them only when a row + * has no `name`, and the write path stamps `name` on every row. + * - `filter[].id` / `sort[].id` → {@link VIEW_CONSOLE_ROW_DECORATIONS}, removed + * before the parse by {@link stripViewConsoleDecorations}. The builders mint + * a fresh id for a row that has none, so a parsed row loses nothing the + * console shows. + * - a bare-array `exportOptions` → the object form, which the parse already + * lifts it to and the export menu reads (`exportOptions.formats`). + * - `_draft` / `_diagnostics` → `METADATA_READ_DECORATIONS` + * (`kernel/metadata-read-decorations.ts`): stamped on the read, stripped + * before the write, never stored. + * + * ⛔ Not a registry to grow by hand. A new entry is a new console write that + * the census measured, declared on its members in the same change. + */ +export const VIEW_CONSOLE_ROUND_TRIP_KEYS = { + isDefault: ['viewItem', 'listOverlay'], + isPinned: ['viewItem', 'listOverlay'], + sortOrder: ['viewItem', 'listOverlay'], + visibility: ['viewItem', 'listOverlay'], + columnState: ['viewItem', 'listOverlay'], + _isOverride: ['listOverlay'], +} as const satisfies Record; + /** * [#5599] The `view` vocabulary, derived once from the members on first use. * From 8315ac66069bb6e343f36ad101185e96f4eb6bc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:38:58 +0000 Subject: [PATCH 2/6] test(spec): the pin / reorder PUT parse output keeps the declared round-trip keys Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- packages/spec/src/ui/view-metadata-schema.test.ts | 2 +- packages/spec/src/ui/view-union-diagnostics.test.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/ui/view-metadata-schema.test.ts b/packages/spec/src/ui/view-metadata-schema.test.ts index 0dfa94d2b4c..91cce36bf16 100644 --- a/packages/spec/src/ui/view-metadata-schema.test.ts +++ b/packages/spec/src/ui/view-metadata-schema.test.ts @@ -415,7 +415,7 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha name: 'showcase_task.default', object: 'showcase_task', viewKind: 'list', - _isOverride: true, // objectui's private marker — undeclared, rides `.strip()` as before + _isOverride: true, // objectui's settings-overlay marker — declared on this member since #20456 columnState: { order: ['email', 'name'], widths: { email: 240 } }, }); expect(r.success).toBe(true); diff --git a/packages/spec/src/ui/view-union-diagnostics.test.ts b/packages/spec/src/ui/view-union-diagnostics.test.ts index be98becbc72..125e9641b6a 100644 --- a/packages/spec/src/ui/view-union-diagnostics.test.ts +++ b/packages/spec/src/ui/view-union-diagnostics.test.ts @@ -300,9 +300,12 @@ describe('[#7025] the acceptance face of ViewMetadataSchema — as re-ruled by # // so it parses to a list (`type: 'grid'`). It used to parse to // `type: 'simple'` — a FORM — because the form member was the one accepting // it, with every list key stripped unread. - ['put.isPinned', { isPinned: true, ...BOUND_LIST }, { type: 'grid', ...BOUND_LIST }], - ['put.sortOrder', { sortOrder: 3, ...BOUND_LIST }, { type: 'grid', ...BOUND_LIST }], - ['put.pinAndOrder', { isPinned: true, sortOrder: 3, ...BOUND_LIST }, { type: 'grid', ...BOUND_LIST }], + // [#20456] …and the pin / reorder keys it carries are the console's + // round-trip keys, declared on this member now, so the parse KEEPS them. + // They used to be `.strip()`ped from the output while the save stored them. + ['put.isPinned', { isPinned: true, ...BOUND_LIST }, { type: 'grid', isPinned: true, ...BOUND_LIST }], + ['put.sortOrder', { sortOrder: 3, ...BOUND_LIST }, { type: 'grid', sortOrder: 3, ...BOUND_LIST }], + ['put.pinAndOrder', { isPinned: true, sortOrder: 3, ...BOUND_LIST }, { type: 'grid', isPinned: true, sortOrder: 3, ...BOUND_LIST }], ]; const REFUSED: Array<[string, unknown, string[]]> = [ From d19cbad8bffb89993e28adfea07aac60e4ec5f99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:48:55 +0000 Subject: [PATCH 3/6] chore(spec): regenerate artifacts, correct the per-user wording, add the changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-surface / export-origins record the new `VIEW_CONSOLE_ROUND_TRIP_KEYS` export; the view reference page carries the declared meanings. - A stored `view` row has no per-user scope, so the column-layout descriptions no longer call it per-user state. - Changeset: `@objectstack/spec` minor, with the Clause-② declaration. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../20456-view-console-round-trip-keys.md | 29 +++++++++++++++++++ content/docs/references/ui/view.mdx | 22 +++++++------- packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + .../metadata-form-zod-reconciliation.test.ts | 6 ++-- packages/spec/src/ui/view.zod.ts | 8 ++--- 6 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 .changeset/20456-view-console-round-trip-keys.md diff --git a/.changeset/20456-view-console-round-trip-keys.md b/.changeset/20456-view-console-round-trip-keys.md new file mode 100644 index 00000000000..bb15fe184a9 --- /dev/null +++ b/.changeset/20456-view-console-round-trip-keys.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): the console's round-trip keys on a stored `view` row are declared on the wire, so a parse keeps them (#20456) + +Clause-②: yes + +`saveMetaItem` stores a `view` body exactly as it was sent (ADR-0005 appendix (c)), and the members of `ViewMetadataSchema` that judge a stored row `.strip()` every key they do not declare. So the keys the console writes onto a stored view and reads back were in the store and nowhere in the contract. A census of objectui's console (at the `.objectui-sha` pin) measured which ones the parse dropped: + +- `isPinned` and `sortOrder` on a flattened list overlay (they were already declared on the ViewItem record); +- `visibility`, on both the flattened list overlay and the ViewItem record; +- `_isOverride`, the marker that tells the console a row is the settings overlay of a code-defined view and not a saved view of its own. + +## What it does now + +- The ViewItem wire member (`ViewItemWireSchema`) and the flattened list overlay (`VIEW_METADATA_MEMBERS.listOverlay`) declare `isPinned`, `sortOrder` and `visibility` from one shared declaration, each with its meaning. The flattened list overlay also declares `_isOverride: true`, and its existing `isDefault` now carries its meaning. A parse of a console-written row keeps every one of them. +- **New export `VIEW_CONSOLE_ROUND_TRIP_KEYS`** (`@objectstack/spec/ui`): each round-trip key, mapped to the members whose rows the console writes it on (`isDefault`, `isPinned`, `sortOrder`, `visibility`, `columnState`, `_isOverride`). +- `visibility` is display grouping only (`private` / `team` / `organization` / `public` in the view switcher). It restricts nobody, and its declared meaning says so. +- None of these keys is authorable. `defineViewItem` still refuses each of them by name, and `visibility` now gets a prescription that says what it is. + +## What does not change + +- **What is persisted.** The save still stores the request body verbatim. Storing the parsed body is a later, separate change. +- The alias spellings the census found keep their declared spellings: `objectName` is `object`, and a top-level `id` is `name`. The console's filter / sort builder row ids stay `VIEW_CONSOLE_ROW_DECORATIONS`, removed before the parse. + +## Values that are now refused + +A declared key is typed, so a stored-row write carrying one of these keys with a value of the wrong type is now refused `422 INVALID_METADATA` at that key, where the key used to be dropped from the parse and the body stored as sent: a non-boolean `isPinned`, a non-integer `sortOrder`, a `visibility` outside the four groups, or an `_isOverride` other than `true`. The console writes none of these: its pin toggle writes a boolean, its reorder an integer index, and it stamps the marker as `true`. diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 57f4ada36d4..2502f806f71 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -2256,9 +2256,10 @@ This schema accepts one of the following structures: | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -| **isPinned** | `boolean` | optional | Studio round-trip: view pinned in the switcher (per-user state, written by the console — not authored). | -| **sortOrder** | `integer` | optional | Studio round-trip: position within the switcher (per-user state, written by the console — not authored). | -| **columnState** | `{ order?: string[]; widths?: Record }` | optional | Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored) | +| **isPinned** | `boolean` | optional | Console round-trip: the view is pinned in the object's view switcher. Written by the console's pin toggle through the `view` metadata API and read back to draw the pinned group. Stored on the view's row, which has no per-user scope. Not authored. | +| **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. | +| **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. | +| **columnState** | `{ order?: string[]; widths?: Record }` | 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) | ### Nested Shape: `ViewItemWire[viewKind='list'].config` @@ -2327,8 +2328,8 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **order** | `string[]` | optional | Column order as field names, leftmost first (runtime-only per-user state — written by the console grid, never authored). | -| **widths** | `Record` | optional | Column widths in pixels, keyed by field name (runtime-only per-user state — written by the console grid, never authored). | +| **order** | `string[]` | optional | Column order as field names, leftmost first (runtime-only state — written by the console grid, never authored). | +| **widths** | `Record` | optional | Column widths in pixels, keyed by field name (runtime-only state — written by the console grid, never authored). | --- @@ -2356,9 +2357,10 @@ This schema accepts one of the following structures: | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -| **isPinned** | `boolean` | optional | Studio round-trip: view pinned in the switcher (per-user state, written by the console — not authored). | -| **sortOrder** | `integer` | optional | Studio round-trip: position within the switcher (per-user state, written by the console — not authored). | -| **columnState** | `{ order?: string[]; widths?: Record }` | optional | Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored) | +| **isPinned** | `boolean` | optional | Console round-trip: the view is pinned in the object's view switcher. Written by the console's pin toggle through the `view` metadata API and read back to draw the pinned group. Stored on the view's row, which has no per-user scope. Not authored. | +| **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. | +| **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. | +| **columnState** | `{ order?: string[]; widths?: Record }` | 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) | ### Nested Shape: `ViewItemWire[viewKind='form'].config` @@ -2402,8 +2404,8 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **order** | `string[]` | optional | Column order as field names, leftmost first (runtime-only per-user state — written by the console grid, never authored). | -| **widths** | `Record` | optional | Column widths in pixels, keyed by field name (runtime-only per-user state — written by the console grid, never authored). | +| **order** | `string[]` | optional | Column order as field names, leftmost first (runtime-only state — written by the console grid, never authored). | +| **widths** | `Record` | optional | Column widths in pixels, keyed by field name (runtime-only state — written by the console grid, never authored). | --- diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index d5bbfb0a06b..51fb2e00608 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -402,6 +402,7 @@ "UserFilters (type)", "UserFiltersParsed (type)", "UserFiltersSchema (const)", + "VIEW_CONSOLE_ROUND_TRIP_KEYS (const)", "VIEW_CONSOLE_ROW_DECORATIONS (const)", "VIEW_FILTER_LIST_VALUE_OPERATORS (const)", "VIEW_FILTER_OPERATORS (const)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 08486d318ca..b332d04951f 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -388,6 +388,7 @@ "UserFilters": "src/ui/view.zod.ts#UserFilters (type)", "UserFiltersParsed": "src/ui/view.zod.ts#UserFiltersParsed (type)", "UserFiltersSchema": "src/ui/view.zod.ts#UserFiltersSchema (const)", + "VIEW_CONSOLE_ROUND_TRIP_KEYS": "src/ui/view.zod.ts#VIEW_CONSOLE_ROUND_TRIP_KEYS (const)", "VIEW_CONSOLE_ROW_DECORATIONS": "src/ui/view.zod.ts#VIEW_CONSOLE_ROW_DECORATIONS (const)", "VIEW_FILTER_LIST_VALUE_OPERATORS": "src/ui/view.zod.ts#VIEW_FILTER_LIST_VALUE_OPERATORS (const)", "VIEW_FILTER_OPERATORS": "src/ui/view.zod.ts#VIEW_FILTER_OPERATORS (const)", diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index d2fe718f77e..fd4623dcfa2 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -274,21 +274,21 @@ const LEDGER: ReadonlyArray = [ type: 'view', path: ROOT_PATH, key: 'columnState', - why: "platform-written, never authored — `Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored)`. Declared on the wire members only so the console's own write parses; the authoring door (`ViewItemSchema`) rejects the key by name", + why: "platform-written, never authored — `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)`. Declared on the wire members only so the console's own write parses; the authoring door (`ViewItemSchema`) rejects the key by name", }, { kind: 'omit', type: 'view', path: ROOT_PATH, key: 'isPinned', - why: "platform-written, never authored — `Studio round-trip: view pinned in the switcher (per-user state, written by the console — not authored)`; the authoring door (`ViewItemSchema`) rejects the key by name", + why: "platform-written, never authored — `Console round-trip: the view is pinned in the object's view switcher. … Not authored.`; the authoring door (`ViewItemSchema`) rejects the key by name", }, { kind: 'omit', type: 'view', path: ROOT_PATH, key: 'sortOrder', - why: "platform-written, never authored — `Studio round-trip: position within the switcher (per-user state, written by the console — not authored)`; the authoring door (`ViewItemSchema`) rejects the key by name and points the author at `order`, the authored default", + why: "platform-written, never authored — `Console round-trip: the view's position among the object's saved views in the switcher … Not authored`; the authoring door (`ViewItemSchema`) rejects the key by name and points the author at `order`, the authored default", }, // Deprecated or legacy alias — deliberately not offered to new authors, the diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 05be82173ac..d2aacf7ab34 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5060,11 +5060,11 @@ type ViewItemWireArmShape = { */ const ViewColumnStateSchema = z.object({ order: z.array(z.string()).optional() - .describe('Column order as field names, leftmost first (runtime-only per-user state — written by the console grid, never authored).'), + .describe('Column order as field names, leftmost first (runtime-only state — written by the console grid, never authored).'), widths: z.record(z.string(), z.number()).optional() - .describe('Column widths in pixels, keyed by field name (runtime-only per-user state — written by the console grid, never authored).'), + .describe('Column widths in pixels, keyed by field name (runtime-only state — written by the console grid, never authored).'), }).describe( - 'Runtime-only personalization overlay key: the per-user column layout (order/widths) the console grid ' + 'Runtime-only personalization overlay key: the column layout (order/widths) the console grid ' + 'persists through the `view` metadata API. NOT authorable — authoring doors reject it by name; do not write it in metadata source.', ); @@ -5207,7 +5207,7 @@ function viewItemWireFields() { // key arrives at THIS member's top level; declaring it validates the // shape where `.strip()` used to let it ride through unchecked. columnState: ViewColumnStateSchema.optional() - .describe('Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored)'), + .describe('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)'), }; } From a47aeb5d734736d090c65a215a94440b0be19f6b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:10:58 +0000 Subject: [PATCH 4/6] chore(spec): drop a re-added citation that no longer resolves from a rewritten comment Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- packages/spec/src/ui/view.zod.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index d2aacf7ab34..77fbb237199 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5201,8 +5201,9 @@ function viewSwitcherRowStateFields() { function viewItemWireFields() { return { ...viewSwitcherRowStateFields(), - // [#9933] Same disposition as the switcher keys: console state written - // through the `view` metadata API. `updateView` PUTs + // Same disposition as the switcher keys: console state written through + // the `view` metadata API ({@link ViewColumnStateSchema} carries the + // key's own ruling). `updateView` PUTs // `{ ...current, ...partial }`, so on a standalone ViewItem record the // key arrives at THIS member's top level; declaring it validates the // shape where `.strip()` used to let it ride through unchecked. From b426de7e2a49ddfba089b6b75ce243c9b8b22808 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:48:22 +0000 Subject: [PATCH 5/6] chore(spec): regenerate api-surface and export-origins on the merged tree The os-regen driver kept one side of both ui.json artifacts in the merge of origin/main; regenerated from the merged source they carry main's new component-props exports and this branch's VIEW_CONSOLE_ROUND_TRIP_KEYS. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- packages/spec/api-surface/ui.json | 17 +++++++++++++++++ packages/spec/export-origins/ui.json | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 51fb2e00608..fc7584b8749 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -11,11 +11,23 @@ "ActionAi (type)", "ActionAiParsed (type)", "ActionAiSchema (const)", + "ActionButtonProps (type)", + "ActionButtonPropsParsed (type)", + "ActionButtonPropsSchema (const)", "ActionEngineFacade (interface)", + "ActionGroupProps (type)", + "ActionGroupPropsParsed (type)", + "ActionGroupPropsSchema (const)", "ActionHandler (type)", "ActionHandlerContext (interface)", + "ActionIconProps (type)", + "ActionIconPropsParsed (type)", + "ActionIconPropsSchema (const)", "ActionLocation (type)", "ActionLocationSchema (const)", + "ActionMenuProps (type)", + "ActionMenuPropsParsed (type)", + "ActionMenuPropsSchema (const)", "ActionNavItem (type)", "ActionNavItemParsed (type)", "ActionNavItemSchema (const)", @@ -146,6 +158,8 @@ "ElementDataSource (type)", "ElementDataSourceParsed (type)", "ElementDataSourceSchema (const)", + "ElementDefinitionListProps (type)", + "ElementDefinitionListPropsSchema (const)", "ElementFilterPropsSchema (const)", "ElementFormPropsSchema (const)", "ElementImagePropsSchema (const)", @@ -156,6 +170,9 @@ "ElementRecordPickerProps (type)", "ElementRecordPickerPropsParsed (type)", "ElementRecordPickerPropsSchema (const)", + "ElementRepeaterProps (type)", + "ElementRepeaterPropsParsed (type)", + "ElementRepeaterPropsSchema (const)", "ElementTextInputPropsSchema (const)", "ElementTextPropsSchema (const)", "ExpandViewResult (interface)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index b332d04951f..cd0bd4be19f 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -10,11 +10,23 @@ "ActionAi": "src/ui/action.zod.ts#ActionAi (type)", "ActionAiParsed": "src/ui/action.zod.ts#ActionAiParsed (type)", "ActionAiSchema": "src/ui/action.zod.ts#ActionAiSchema (const)", + "ActionButtonProps": "src/ui/component.zod.ts#ActionButtonProps (type)", + "ActionButtonPropsParsed": "src/ui/component.zod.ts#ActionButtonPropsParsed (type)", + "ActionButtonPropsSchema": "src/ui/component.zod.ts#ActionButtonPropsSchema (const)", "ActionEngineFacade": "src/ui/action-params.zod.ts#ActionEngineFacade (interface)", + "ActionGroupProps": "src/ui/component.zod.ts#ActionGroupProps (type)", + "ActionGroupPropsParsed": "src/ui/component.zod.ts#ActionGroupPropsParsed (type)", + "ActionGroupPropsSchema": "src/ui/component.zod.ts#ActionGroupPropsSchema (const)", "ActionHandler": "src/ui/action-params.zod.ts#ActionHandler (type)", "ActionHandlerContext": "src/ui/action-params.zod.ts#ActionHandlerContext (interface)", + "ActionIconProps": "src/ui/component.zod.ts#ActionIconProps (type)", + "ActionIconPropsParsed": "src/ui/component.zod.ts#ActionIconPropsParsed (type)", + "ActionIconPropsSchema": "src/ui/component.zod.ts#ActionIconPropsSchema (const)", "ActionLocation": "src/ui/action.zod.ts#ActionLocation (type)", "ActionLocationSchema": "src/ui/action.zod.ts#ActionLocationSchema (const)", + "ActionMenuProps": "src/ui/component.zod.ts#ActionMenuProps (type)", + "ActionMenuPropsParsed": "src/ui/component.zod.ts#ActionMenuPropsParsed (type)", + "ActionMenuPropsSchema": "src/ui/component.zod.ts#ActionMenuPropsSchema (const)", "ActionNavItem": "src/ui/app.zod.ts#ActionNavItem (type)", "ActionNavItemParsed": "src/ui/app.zod.ts#ActionNavItemParsed (type)", "ActionNavItemSchema": "src/ui/app.zod.ts#ActionNavItemSchema (const)", @@ -142,6 +154,8 @@ "ElementDataSource": "src/ui/page.zod.ts#ElementDataSource (type)", "ElementDataSourceParsed": "src/ui/page.zod.ts#ElementDataSourceParsed (type)", "ElementDataSourceSchema": "src/ui/page.zod.ts#ElementDataSourceSchema (const)", + "ElementDefinitionListProps": "src/ui/component.zod.ts#ElementDefinitionListProps (type)", + "ElementDefinitionListPropsSchema": "src/ui/component.zod.ts#ElementDefinitionListPropsSchema (const)", "ElementFilterPropsSchema": "src/ui/component.zod.ts#ElementFilterPropsSchema (const)", "ElementFormPropsSchema": "src/ui/component.zod.ts#ElementFormPropsSchema (const)", "ElementImagePropsSchema": "src/ui/component.zod.ts#ElementImagePropsSchema (const)", @@ -152,6 +166,9 @@ "ElementRecordPickerProps": "src/ui/component.zod.ts#ElementRecordPickerProps (type)", "ElementRecordPickerPropsParsed": "src/ui/component.zod.ts#ElementRecordPickerPropsParsed (type)", "ElementRecordPickerPropsSchema": "src/ui/component.zod.ts#ElementRecordPickerPropsSchema (const)", + "ElementRepeaterProps": "src/ui/component.zod.ts#ElementRepeaterProps (type)", + "ElementRepeaterPropsParsed": "src/ui/component.zod.ts#ElementRepeaterPropsParsed (type)", + "ElementRepeaterPropsSchema": "src/ui/component.zod.ts#ElementRepeaterPropsSchema (const)", "ElementTextInputPropsSchema": "src/ui/component.zod.ts#ElementTextInputPropsSchema (const)", "ElementTextPropsSchema": "src/ui/component.zod.ts#ElementTextPropsSchema (const)", "ExpandViewResult": "src/ui/view.zod.ts#ExpandViewResult (interface)", From fc5a47d08d0c842125d9ccc97c91b7e858647d4b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:50:42 +0000 Subject: [PATCH 6/6] =?UTF-8?q?chore(changeset):=20declare=20the=20ill-typ?= =?UTF-8?q?ed-value=20narrowing=20=E2=80=94=20Clause-=E2=91=A1=20yes=20(na?= =?UTF-8?q?rrowing),=20BREAKING=20line,=20ADR-0087=20disposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .changeset/20456-view-console-round-trip-keys.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/20456-view-console-round-trip-keys.md b/.changeset/20456-view-console-round-trip-keys.md index bb15fe184a9..119c2f13220 100644 --- a/.changeset/20456-view-console-round-trip-keys.md +++ b/.changeset/20456-view-console-round-trip-keys.md @@ -4,7 +4,11 @@ feat(spec): the console's round-trip keys on a stored `view` row are declared on the wire, so a parse keeps them (#20456) -Clause-②: yes +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save) and on every door that parses `ViewMetadataSchema`, shipped as `minor` under the repo's launch-window convention. The newly declared keys are typed, so a non-boolean `isPinned`, a non-integer `sortOrder`, a `visibility` outside `private` / `team` / `organization` / `public`, or an `_isOverride` other than `true` is now refused at the parse (`422 INVALID_METADATA` at the save door), where the strip used to swallow the key and the save stored the body as sent. To fix a refused body, correct the value or delete the key. The console writes none of these values: its pin toggle writes a boolean, its reorder an integer index, and it stamps the marker as `true`. The diff also widens: the keys are now declared, and `VIEW_CONSOLE_ROUND_TRIP_KEYS` is a new export. `saveMetaItem` stores a `view` body exactly as it was sent (ADR-0005 appendix (c)), and the members of `ViewMetadataSchema` that judge a stored row `.strip()` every key they do not declare. So the keys the console writes onto a stored view and reads back were in the store and nowhere in the contract. A census of objectui's console (at the `.objectui-sha` pin) measured which ones the parse dropped: @@ -23,7 +27,3 @@ Clause-②: yes - **What is persisted.** The save still stores the request body verbatim. Storing the parsed body is a later, separate change. - The alias spellings the census found keep their declared spellings: `objectName` is `object`, and a top-level `id` is `name`. The console's filter / sort builder row ids stay `VIEW_CONSOLE_ROW_DECORATIONS`, removed before the parse. - -## Values that are now refused - -A declared key is typed, so a stored-row write carrying one of these keys with a value of the wrong type is now refused `422 INVALID_METADATA` at that key, where the key used to be dropped from the parse and the body stored as sent: a non-boolean `isPinned`, a non-integer `sortOrder`, a `visibility` outside the four groups, or an `_isOverride` other than `true`. The console writes none of these: its pin toggle writes a boolean, its reorder an integer index, and it stamps the marker as `true`.