diff --git a/.changeset/20051-view-overlay-options-bag-judged.md b/.changeset/20051-view-overlay-options-bag-judged.md new file mode 100644 index 00000000000..8157c36b6c0 --- /dev/null +++ b/.changeset/20051-view-overlay-options-bag-judged.md @@ -0,0 +1,49 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: a flattened list view overlay's legacy `options` bag is judged at the view write door, so an out-of-contract `options.KIND` key is refused by name exactly as the direct spelling is (#20051) + +**BREAKING** accept-set narrowing on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save). It ships as `minor` under the repo's launch-window convention for breaking changes. This is the door half of ruling A on objectui#10380 (maintainer 「其他同意」). + +Clause-②: no + +## What was wrong + +The flattened list overlay member of `ViewMetadataSchema` re-opens its top level with `.strip()` so the console's round-trip keys survive. That strip also dropped a top-level `options` bag from the parse without looking inside it. `saveMetaItem` stores the request body, not the parse output, and objectui's interface page forwards a stored view's `options` into the list renderer, which merges `options.KIND` under the top-level `KIND` block. Measured on `origin/main` @ `8d1f7ab` through the real save: `timeline: { metaFields: [...] }` answered `422`, while the same key written as `options: { timeline: { metaFields: [...] } }` answered `200` and the row held it as sent. + +## What it does now + +- **The list overlay declares `options`.** Each `options.KIND` (`kanban`, `calendar`, `gantt`, `gallery`, `timeline`, `chart`, `map`, `tree`) is judged by that kind's own block schema: the same closed key set, the same per-key schemas and the same unknown-key message. The refusal names the key, with only the `options.` prefix added to the path. The kinds are derived from the list-view shape, not listed by hand. +- **Key by key.** The renderer reads the bag as a per-key underlay of the top-level block, so the block's required keys are not asked of it. `kanban: { groupByField, columns }` beside `options.kanban: { titleField }` stays legal, which is the population objectui pins. +- **The bag is closed.** A key that is not a kind (`options.foo`, `options.grid`) is refused by name at `options`. It is no longer dropped. +- **The form overlay pins `options` absent.** Without this, a column-less, type-less list body that the list overlay refused over its bag would be accepted by the form overlay and stored unjudged. The refusal says the bag belongs to a list view. + +The legacy `options.map` bag that objectui pins (`locationField`, `titleField`) is still accepted, and it round-trips. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `options: { kanban: { groupField: 'stage' } }` | `kanban: { groupByField: 'stage', columns: [...] }`, or `options: { kanban: { groupByField: 'stage' } }` | +| `options: { calendar: { dateField: 'kickoff' } }` | `calendar: { startDateField: 'kickoff' }` | +| `options: { timeline: { metaFields: ['region'] } }` | delete `metaFields`: the timeline block has no such key | +| `options: { chart: { xAxisField, yAxisFields } }` | the dataset-bound block, `chart: { dataset, values, dimensions }` | +| `options: { foo: 1 }` | delete `foo`: the bag carries per-kind blocks only | +| `options: {...}` on a form overlay (`viewKind: 'form'`) | delete `options`: a form view has no per-kind blocks | + +**The one-line fix:** read the refusal. It names the key and the block that refuses it; move the key to the top-level block's declared spelling, or delete it. + +## Stored-row census (ruling item 3) + +- **objectstack** @ `8d1f7ab` (examples, dogfood, fixtures, tests): zero view bodies carry a top-level `options` bag with a kind block. Authored views go through the strict authoring shape, which has always refused `options`. +- **objectui**, at the `.objectui-sha` pin `f8a9d0fb0` and at `main` `c3a26ccda`: 28 `options` bag literals, plus the finding's own probe body, judged against this change. 17 pass and 12 fail, and every failure is an out-of-contract key refused by name. None fails for a missing key. + - **Bodies that model a stored or authored view** (a console-merged `listViews` entry or a named view): 8, of which 5 pass, the pinned `options.map` path among them. The 3 that fail: `options.kanban.groupField` in `plugin-view` `ObjectView.tsx`'s docblock example (write `groupByField`), `options.calendar.dateField` in `ObjectView.calendarAliasRefused-8355.test.tsx` (write `startDateField`; that test already pins the alias as refused on objectui's side), and the finding's `options.timeline.metaFields` (delete it). + - **Renderer-level `ListView` props** (21), which never reach this door: 12 pass. The 9 that fail spell the legacy keys objectui's own refusal pins already retire (`groupField`, `groupBy`, `dateField`, `metaFields`, and the object-bound chart keys `xAxisField` / `yAxisFields` / `aggregation`). +- **Production `sys_metadata` rows: NOT MEASURED.** No deployment's store is reachable from the repository. A stored row that fails keeps being read and served exactly as stored. It is refused only on its next save, and the refusal names the key. + +## Not in this change + +Persisting the parse output instead of the request body (ruling item 2) is not in this change. This change leaves the save path's storage behaviour as it was: a body the door now accepts is stored as sent, so every stored `options` bag is one the door judged. + + diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 70b6d05f511..14379c83fda 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 452 | +| Object sites in them | 453 | | Still-open (strip) sites | 125 | | Files carrying at least one | 22 | @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 179 | 169 | 3 | 0 | 7 | +| `ui/` | 180 | 170 | 3 | 0 | 7 | | `data/` | 159 | 76 | 1 | 0 | 82 | | `automation/` | 67 | 43 | 0 | 1 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **452** | **322** | **4** | **1** | **125** | +| **total** | **453** | **323** | **4** | **1** | **125** | ## File-level triage — site counts @@ -74,9 +74,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 3 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | -| `view.zod.ts` | 61 | +| `view.zod.ts` | 62 | | `widget.zod.ts` | 1 | -| **total** | **179** | +| **total** | **180** | ### `data/` — sites @@ -155,15 +155,15 @@ over it is here. ### `ui/` — open -**7 strip of 179**, in 4 file(s). +**7 strip of 180**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 19 | -| `view.zod.ts` | 4 | 61 | +| `view.zod.ts` | 4 | 62 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **179** | +| **total** | **7** | **180** | | Bucket | Sites | |---|---| diff --git a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts index 49667dd8e30..2d5f98d95b5 100644 --- a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts +++ b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts @@ -327,3 +327,86 @@ describe('graftFoldedFormSections — structural safety, no save involved', () = expect('sections' in grafted).toBe('sections' in parsed.data); }); }); + +/** + * [#20051] The view write door judges a flat list overlay's legacy `options` + * bag — driven through the REAL `saveMetaItem`, riding this file's pinned + * engine double rather than minting a second one (the double is the reason + * these cases live here, not the `groups` fold). + * + * Measured before the change on `origin/main` @ `8d1f7ab`, through this same + * harness: `options: { timeline: { metaFields: ['region'] } }` on a flat list + * overlay saved `success: true` and the row held the bag byte-for-byte, while + * the direct `timeline.metaFields` was refused. `saveMetaItem` stores the + * request body, not the parse output, so what the door does not judge is what + * reaches storage. + * + * Refusals assert the envelope (`code` + `status`) and that NO row was written. + */ +describe('[#20051] the save door judges a flat list overlay\'s `options` bag', () => { + const TIMELINE = { startDateField: 'created_at', titleField: 'name', metaFields: ['region'] }; + const flatList = (extra: Record) => ({ + name: 'crm_lead.timeline', + object: 'crm_lead', + viewKind: 'list', + label: 'Timeline', + type: 'timeline', + columns: ['name'], + ...extra, + }); + + async function refusal(item: unknown): Promise<{ err: any; rowsWritten: number }> { + const { protocol, rows } = makeProtocol(); + let err: any; + try { + await (protocol as any).saveMetaItem({ type: 'view', name: 'crm_lead.timeline', item }); + } catch (e) { err = e; } + return { err, rowsWritten: Array.from(rows.values()).filter((r) => r.type === 'view').length }; + } + + it('a direct and an `options`-wrapped out-of-contract key get the same refusal, and nothing is stored', async () => { + const direct = await refusal(flatList({ timeline: TIMELINE })); + const wrapped = await refusal(flatList({ options: { timeline: TIMELINE } })); + for (const { err, rowsWritten } of [direct, wrapped]) { + expect(err?.code).toBe('INVALID_METADATA'); + expect(err?.status).toBe(422); + expect(rowsWritten).toBe(0); + } + const hit = (err: any, path: string) => + (err.issues as Array<{ path: string; code?: string; message: string }>) + .find((i) => i.path === path && i.code === 'unrecognized_keys'); + const directHit = hit(direct.err, 'timeline'); + const wrappedHit = hit(wrapped.err, 'options.timeline'); + expect(directHit, JSON.stringify(direct.err.issues)).toBeDefined(); + expect(wrappedHit, JSON.stringify(wrapped.err.issues)).toBeDefined(); + // Refused by NAME, on the same surface text as the direct spelling. + expect(wrappedHit!.message).toBe(directHit!.message); + expect(wrappedHit!.message).toContain('`metaFields`'); + }); + + it('an unknown key in the bag itself is refused by name, not dropped', async () => { + const { err, rowsWritten } = await refusal(flatList({ timeline: { startDateField: 'created_at', titleField: 'name' }, options: { foo: 1 } })); + expect(err?.code).toBe('INVALID_METADATA'); + expect(err?.status).toBe(422); + expect(rowsWritten).toBe(0); + expect((err.issues as Array<{ path: string; message: string }>).some((i) => i.path === 'options' && i.message.includes('`foo`'))) + .toBe(true); + }); + + it('a legal legacy `options.map` bag (the path objectui pins) saves, and the row carries it unchanged', async () => { + const authored = { + name: 'showcase_task.work_map', + object: 'showcase_task', + viewKind: 'list', + label: 'Work Map', + type: 'map', + columns: ['title', 'location'], + options: { map: { locationField: 'location', titleField: 'legacy_title' } }, + }; + const body = await storedViewBody('showcase_task.work_map', authored); + expect(body.options).toEqual(authored.options); + // …and the stored row passes the same door again: a GET → PUT of it is + // not refused by the judgement it was saved under. + expect((ViewMetadataSchema as any).safeParse(body).success).toBe(true); + }); +}); diff --git a/packages/rest/src/meta-view-overlay-options-bag.test.ts b/packages/rest/src/meta-view-overlay-options-bag.test.ts new file mode 100644 index 00000000000..9da29752fcc --- /dev/null +++ b/packages/rest/src/meta-view-overlay-options-bag.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20051 — a flat list overlay's legacy `options` bag is judged at + * `PUT /api/v1/meta/view/:name`, the door a Studio tenant or an MCP/AI author + * writes through, on the real composition: the real `RestServer` route over + * the real `saveMetaItem`, backed by a real `ObjectQL` + SQLite `sys_metadata`. + * + * Before the change (measured on `origin/main` @ `8d1f7ab`): the list overlay + * member `.strip()`ped a top-level `options` from the parse without judging it, + * so `options.timeline.metaFields` answered `200` and was stored as sent, while + * the direct `timeline.metaFields` answered `422`. + * + * Refusal cases assert the ADR-0112 envelope — `code` AND `status` — and that + * no row reached `sys_metadata`: the pair separates "refused by the door" from + * a failure somewhere downstream, and the empty store separates "refused" from + * "refused after writing". + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + SysMetadata, + SysMetadataHistoryObject, + SysMetadataAuditObject, +} from '@objectstack/platform-objects/metadata'; +import { RestServer } from './rest-server.js'; + +const META_ITEM = '/api/v1/meta/:type/:name'; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => {}, close: async () => {}, + }; +} + +function makeRes() { + const res: any = { + _status: 200, + write: () => true, end: () => {}, send: () => res, setHeader: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.metadata-objects', + name: 'Metadata Platform Objects', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject], + }); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + // The route demands `manage_metadata`; the caller holds it, so a refusal + // here is the spec door's and nothing else's. + (rest as any).resolveExecCtx = async () => ({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'PUT' && r.path === META_ITEM); + if (!route) throw new Error(`PUT ${META_ITEM} is not registered`); + + const put = async (name: string, body: unknown) => { + const res = makeRes(); + await route.handler({ params: { type: 'view', name }, query: {}, headers: {}, body } as any, res); + return res; + }; + const storedRows = async (name: string) => engine.find('sys_metadata', { where: { type: 'view', name } }); + return { put, storedRows }; +} + +const TIMELINE = { startDateField: 'created_at', titleField: 'name', metaFields: ['region'] }; +const flatList = (extra: Record) => ({ + name: 'crm_lead.timeline', + object: 'crm_lead', + viewKind: 'list', + label: 'Timeline', + type: 'timeline', + columns: ['name'], + ...extra, +}); + +type Issue = { path?: string; code?: string; message?: string }; +const issuesOf = (res: any): Issue[] => (res._json?.issues ?? []) as Issue[]; + +describe('#20051 PUT /api/v1/meta/view judges a flat list overlay\'s `options` bag', () => { + it('a direct and an `options`-wrapped out-of-contract key get the same 422, and nothing is stored', async () => { + const { put, storedRows } = await boot(); + + const direct = await put('crm_lead.timeline', flatList({ timeline: TIMELINE })); + const wrapped = await put('crm_lead.timeline', flatList({ options: { timeline: TIMELINE } })); + + for (const res of [direct, wrapped]) { + expect(res._status, JSON.stringify(res._json)).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + } + expect(await storedRows('crm_lead.timeline')).toEqual([]); + + const directHit = issuesOf(direct).find((i) => i.path === 'timeline' && i.code === 'unrecognized_keys'); + const wrappedHit = issuesOf(wrapped).find((i) => i.path === 'options.timeline' && i.code === 'unrecognized_keys'); + expect(directHit, JSON.stringify(direct._json)).toBeDefined(); + expect(wrappedHit, JSON.stringify(wrapped._json)).toBeDefined(); + expect(wrappedHit!.message).toBe(directHit!.message); + expect(wrappedHit!.message).toContain('`metaFields`'); + }); + + it('a legal legacy `options.map` bag answers 200 and the stored row carries it unchanged', async () => { + const { put, storedRows } = await boot(); + const options = { map: { locationField: 'location', titleField: 'legacy_title' } }; + const res = await put('showcase_task.work_map', { + name: 'showcase_task.work_map', + object: 'showcase_task', + viewKind: 'list', + label: 'Work Map', + type: 'map', + columns: ['title', 'location'], + options, + }); + expect(res._status, JSON.stringify(res._json)).toBe(200); + const rows = await storedRows('showcase_task.work_map'); + expect(rows).toHaveLength(1); + const stored = typeof rows[0].metadata === 'string' ? JSON.parse(rows[0].metadata) : rows[0].metadata; + expect(stored.options).toEqual(options); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.view-overlay-options-bag-judged.ts b/packages/spec/src/migrations/entries/semantic/18.view-overlay-options-bag-judged.ts new file mode 100644 index 00000000000..27ee9a9b10d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-overlay-options-bag-judged.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The door half of ruling A on objectui#10380 (#20051). A write-time narrowing +// of the flattened view overlays: the stored rows it newly refuses are read and +// served exactly as before and fail only on their next save, which is why this +// is a semantic entry and not a conversion — which key a row meant is a fact +// only its author holds. +export const entry: SemanticMigration = { + id: 'view-overlay-options-bag-judged', + surface: + 'The legacy `options` bag on a flattened `view` overlay saved through the metadata write door ' + + '(`PUT /api/v1/meta/view/:name`, the Studio / MCP save): `options.kanban`, `options.calendar`, ' + + '`options.gantt`, `options.gallery`, `options.timeline`, `options.chart`, `options.map` and ' + + '`options.tree` on a list overlay, any other key in the bag, and the bag on a form overlay.', + replacement: + 'Each `options.KIND` block carrying only keys the top-level `KIND` block declares, with values that ' + + 'block accepts — or, preferred, the same keys moved to the top-level `KIND` block, which wins per key ' + + 'where both set one. A key the block does not declare is deleted or re-spelled to the declared key the ' + + 'refusal names (`options.kanban.groupField` becomes `groupByField`, `options.calendar.dateField` ' + + 'becomes `startDateField`); `options.timeline.metaFields` has no declared successor and is deleted. ' + + 'Any other key in the bag is deleted, and a form overlay carries no bag at all.', + reason: + 'The list overlay member re-opens its top level with a strip so the console\'s round-trip keys ' + + 'survive, and that strip dropped the `options` bag from the parse without looking inside it. The save ' + + 'stores the request body, not the parse output, and objectui\'s interface page forwards a stored ' + + 'view\'s `options` into the list renderer, which merges `options.KIND` under the top-level block — so a ' + + 'key the strict block refuses by name (`timeline.metaFields`) was saved and rendered when spelled ' + + '`options.timeline.metaFields`. Measured on `origin/main` @ `8d1f7ab` through the real save. Ruled ' + + 'direction A (maintainer 「其他同意」): judge each `options.KIND` with the kind\'s strict schema and ' + + 'refuse an out-of-contract key by name, as the direct spelling is; refusing the bag whole was ruled ' + + 'out because the legacy `options.map` path is live and pinned. Judged key by key, because the ' + + 'renderer reads the bag as a per-key underlay of the top-level block: a bag that carries only the ' + + 'keys the top-level block leaves to it is legal and stays accepted. Not convertible: whether a ' + + 'refused key was a typo of a declared one or a retired capability is the author\'s call.', + acceptanceCriteria: + 'Every stored `view` overlay carrying a top-level `options` saves again unchanged. A row that does not ' + + 'is refused `422 INVALID_METADATA` on its next save, with an `unrecognized_keys` issue at ' + + '`options.KIND` naming the key and carrying the same message the direct spelling gets at `KIND` — ' + + 'or at `options` for a key that is not a kind, or a form overlay\'s bag. Nothing is rewritten on ' + + 'read and nothing is refused on read: a row that fails is served exactly as stored until it is ' + + 'saved. Verify by re-saving each stored overlay that carries `options` (a GET then a PUT of the ' + + 'same body) and reading a `200`.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9a266436f8a..08481742240 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -14409,6 +14409,47 @@ const step18: MigrationStep = { + 'equals, and operator: "in" with value: ["won"] — select the same rows, so the result ' + 'set cannot tell you which the metadata meant, and only the author knows.', }, + // The door half of ruling A on objectui#10380 (#20051). A write-time narrowing + // of the flattened view overlays: the stored rows it newly refuses are read and + // served exactly as before and fail only on their next save, which is why this + // is a semantic entry and not a conversion — which key a row meant is a fact + // only its author holds. + { + id: 'view-overlay-options-bag-judged', + surface: + 'The legacy `options` bag on a flattened `view` overlay saved through the metadata write door ' + + '(`PUT /api/v1/meta/view/:name`, the Studio / MCP save): `options.kanban`, `options.calendar`, ' + + '`options.gantt`, `options.gallery`, `options.timeline`, `options.chart`, `options.map` and ' + + '`options.tree` on a list overlay, any other key in the bag, and the bag on a form overlay.', + replacement: + 'Each `options.KIND` block carrying only keys the top-level `KIND` block declares, with values that ' + + 'block accepts — or, preferred, the same keys moved to the top-level `KIND` block, which wins per key ' + + 'where both set one. A key the block does not declare is deleted or re-spelled to the declared key the ' + + 'refusal names (`options.kanban.groupField` becomes `groupByField`, `options.calendar.dateField` ' + + 'becomes `startDateField`); `options.timeline.metaFields` has no declared successor and is deleted. ' + + 'Any other key in the bag is deleted, and a form overlay carries no bag at all.', + reason: + 'The list overlay member re-opens its top level with a strip so the console\'s round-trip keys ' + + 'survive, and that strip dropped the `options` bag from the parse without looking inside it. The save ' + + 'stores the request body, not the parse output, and objectui\'s interface page forwards a stored ' + + 'view\'s `options` into the list renderer, which merges `options.KIND` under the top-level block — so a ' + + 'key the strict block refuses by name (`timeline.metaFields`) was saved and rendered when spelled ' + + '`options.timeline.metaFields`. Measured on `origin/main` @ `8d1f7ab` through the real save. Ruled ' + + 'direction A (maintainer 「其他同意」): judge each `options.KIND` with the kind\'s strict schema and ' + + 'refuse an out-of-contract key by name, as the direct spelling is; refusing the bag whole was ruled ' + + 'out because the legacy `options.map` path is live and pinned. Judged key by key, because the ' + + 'renderer reads the bag as a per-key underlay of the top-level block: a bag that carries only the ' + + 'keys the top-level block leaves to it is legal and stays accepted. Not convertible: whether a ' + + 'refused key was a typo of a declared one or a retired capability is the author\'s call.', + acceptanceCriteria: + 'Every stored `view` overlay carrying a top-level `options` saves again unchanged. A row that does not ' + + 'is refused `422 INVALID_METADATA` on its next save, with an `unrecognized_keys` issue at ' + + '`options.KIND` naming the key and carrying the same message the direct spelling gets at `KIND` — ' + + 'or at `options` for a key that is not a kind, or a form overlay\'s bag. Nothing is rewritten on ' + + 'read and nothing is refused on read: a row that fails is served exactly as stored until it is ' + + 'saved. Verify by re-saving each stored overlay that carries `options` (a GET then a PUT of the ' + + 'same body) and reading a `200`.', + }, // The display page size a view gets when it declares none moved from 25 to 50 // (maintainer ruling on objectui#9853). A default move reaches every silent // document with no parse error and nothing in the author's diff, so the diff --git a/packages/spec/src/ui/view-overlay-options-bag.test.ts b/packages/spec/src/ui/view-overlay-options-bag.test.ts new file mode 100644 index 00000000000..0cc50925608 --- /dev/null +++ b/packages/spec/src/ui/view-overlay-options-bag.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20051] A flattened list overlay's legacy `options` bag is JUDGED at the + * view write door, by the same per-kind schemas the direct spelling is judged + * by — the door half of ruling A on objectui#10380. + * + * ## What was measured before the change (`origin/main` @ `8d1f7ab`) + * + * The list overlay member is `ListViewShapeSchema.extend(…).strip()`, and + * `.strip()` dropped a top-level `options` from the parse without looking + * inside it: + * + * - `timeline.metaFields` on a flat overlay ⇒ refused, `unrecognized_keys` + * at `timeline`; + * - the same key as `options.timeline.metaFields` ⇒ ACCEPTED, `options` absent + * from the output — and `saveMetaItem` stored the request body with the bag + * in it (measured through the real save; the save-door pins live in + * `metadata-protocol`'s `protocol.graft-folded-form-sections.test.ts`, whose + * engine double they ride, and the `PUT /api/v1/meta/view` pins in `rest`'s + * `meta-view-overlay-options-bag.test.ts`). + * + * ## What this file pins + * + * 1. An out-of-contract key gets the SAME refusal in both spellings — same + * code, same keys, same message — and only the path gains the `options` + * prefix; a value a key's own schema refuses is refused at that key too. + * Asserted for every kind the bag declares, so no kind is judged by + * anything but its own block. The underlay is judged KEY BY KEY: a partial + * bag (the per-key merge population objectui pins) parses. + * 2. The bag is closed: `options.foo` is refused by name, not dropped. + * 3. A legal `options.KIND` still parses and round-trips — including the + * legacy `options.map` bag objectui pins + * (`InterfaceListPage.mapConfig.test.tsx`, "CONTROL: the legacy + * `options.map` bag is still forwarded on its own path"). + * 4. The form overlay cannot accept the bag the list overlay refused (the + * column-less fall-through), and says why. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { ViewMetadataSchema, VIEW_METADATA_MEMBERS, diagnoseViewMetadata } from './view.zod'; + +/** A bound flat list overlay — the shape a `PUT /api/v1/meta/view` carries. */ +const overlay = (extra: Record = {}) => ({ + name: 'crm_lead.board', + object: 'crm_lead', + viewKind: 'list', + columns: ['name'], + ...extra, +}); + +/** The kinds the bag declares, read off the published member's JSON Schema. */ +function declaredBagKinds(): string[] { + const json = z.toJSONSchema(VIEW_METADATA_MEMBERS.listOverlay as unknown as z.ZodTypeAny, { io: 'input', unrepresentable: 'any' }) as { + properties?: Record }>; + }; + return Object.keys(json.properties?.options?.properties ?? {}).sort(); +} + +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[] }; + +/** The list overlay's own issues for a body (the branch the body claims). */ +function listOverlayIssues(body: unknown): Issue[] { + const d = diagnoseViewMetadata(body); + expect(d.success, `expected a refusal for ${JSON.stringify(body)}`).toBe(false); + if (d.success) return []; + expect(d.branch).toBe('listOverlay'); + return d.issues as unknown as Issue[]; +} + +/** Issues under `prefix`, with the prefix removed — for comparing two spellings. */ +function under(issues: Issue[], prefix: PropertyKey[]): Array & { path: string }> { + return issues + .filter((i) => prefix.every((p, n) => i.path[n] === p)) + .map((i) => ({ code: i.code, message: i.message, keys: i.keys, path: i.path.slice(prefix.length).join('.') })) + .sort((a, b) => `${a.path}|${a.code}`.localeCompare(`${b.path}|${b.code}`)); +} + +describe('[#20051] the bag declares exactly the list kinds, derived from the shape', () => { + it('declares one block per list-view kind that has a block — `grid` has none', () => { + // A pin on the DERIVATION (`listViewKindBlocks`): the list is not + // hand-maintained in the schema, so it is stated once here, where a new + // kind shows up as a deliberate one-line edit. + expect(declaredBagKinds()).toEqual( + ['calendar', 'chart', 'gallery', 'gantt', 'kanban', 'map', 'timeline', 'tree'], + ); + }); +}); + +describe('[#20051] a direct and an `options`-wrapped out-of-contract key get the same refusal', () => { + it('the card\'s headline: `options.timeline.metaFields` is refused by name, as `timeline.metaFields` is', () => { + const timeline = { startDateField: 'created_at', titleField: 'name', metaFields: ['region'] }; + const direct = listOverlayIssues(overlay({ type: 'timeline', timeline })); + const wrapped = listOverlayIssues(overlay({ type: 'timeline', options: { timeline } })); + + const directHit = direct.find((i) => i.code === 'unrecognized_keys'); + const wrappedHit = wrapped.find((i) => i.code === 'unrecognized_keys'); + expect(directHit?.path).toEqual(['timeline']); + expect(wrappedHit?.path).toEqual(['options', 'timeline']); + expect(wrappedHit?.keys).toEqual(['metaFields']); + expect(wrappedHit?.keys).toEqual(directHit?.keys); + // The refusal names the key the author wrote, on the same surface text. + expect(wrappedHit?.message).toBe(directHit?.message); + expect(wrappedHit?.message).toContain('`metaFields`'); + }); + + it.each(['calendar', 'chart', 'gallery', 'gantt', 'kanban', 'map', 'timeline', 'tree'])( + '`options.%s` refuses an out-of-contract key with its own block\'s refusal — same code, keys and message', + (kind) => { + const block = { zz_not_a_key: 1 }; + const unknownKey = (issues: ReturnType) => issues.filter((i) => i.code === 'unrecognized_keys'); + const direct = unknownKey(under(listOverlayIssues(overlay({ [kind]: block })), [kind])); + const wrapped = under(listOverlayIssues(overlay({ options: { [kind]: block } })), ['options', kind]); + expect(direct).toHaveLength(1); + // The ONLY complaint about the wrapped block is the unknown key — the + // underlay is judged key by key, so the block's required keys are not + // asked of it (see the per-key case below). + expect(wrapped).toEqual(direct); + expect(wrapped[0]!.keys).toEqual(['zz_not_a_key']); + }, + ); + + it('a value the key\'s own schema refuses is refused at the same key under `options`', () => { + // `map.zoom` is `min(1).max(20)`: the key-level schema travels with the key. + const direct = under(listOverlayIssues(overlay({ map: { zoom: 99 } })), ['map']); + const wrapped = under(listOverlayIssues(overlay({ options: { map: { zoom: 99 } } })), ['options', 'map']); + expect(wrapped).toEqual(direct); + expect(wrapped.map((i) => i.path)).toEqual(['zoom']); + }); + + it('the union refuses both spellings — the verdict, not only the diagnosis', () => { + const timeline = { startDateField: 'created_at', titleField: 'name', metaFields: ['region'] }; + expect(ViewMetadataSchema.safeParse(overlay({ type: 'timeline', timeline })).success).toBe(false); + expect(ViewMetadataSchema.safeParse(overlay({ type: 'timeline', options: { timeline } })).success).toBe(false); + }); +}); + +describe('[#20051] the bag itself is closed', () => { + it('`options.foo` is refused by name at `options`, not dropped', () => { + const issues = listOverlayIssues(overlay({ options: { foo: 1 } })); + const hit = issues.find((i) => i.code === 'unrecognized_keys'); + expect(hit?.path).toEqual(['options']); + expect(hit?.keys).toEqual(['foo']); + expect(hit?.message).toContain('legacy `options` bag'); + }); + + it('`options.grid` is answered with the prescription, not a typo guess', () => { + const hit = listOverlayIssues(overlay({ options: { grid: {} } })).find((i) => i.code === 'unrecognized_keys'); + expect(hit?.keys).toEqual(['grid']); + expect(hit?.message).toContain('top-level keys of the view itself'); + }); + + it('a non-object bag is refused, not dropped', () => { + const hit = listOverlayIssues(overlay({ options: 'map' })).find((i) => i.path[0] === 'options'); + expect(hit?.code).toBe('invalid_type'); + }); +}); + +describe('[#20051] a legal `options.KIND` still parses and round-trips', () => { + it('CONTROL: the legacy `options.map` bag objectui pins is accepted and kept in the parse output', () => { + // objectui `packages/app-shell/src/views/InterfaceListPage.mapConfig.test.tsx` + // — "CONTROL: the legacy `options.map` bag is still forwarded on its own + // path" — at the `.objectui-sha` pin `f8a9d0fb0`. + const options = { map: { locationField: 'location', titleField: 'legacy_title' } }; + const body = overlay({ name: 'showcase_task.work_map', object: 'showcase_task', type: 'map', columns: ['title', 'location'], options }); + const parsed = ViewMetadataSchema.safeParse(body); + expect(parsed.success, JSON.stringify(!parsed.success && parsed.error.issues)).toBe(true); + expect((parsed as { data: { options?: unknown } }).data.options).toEqual(options); + }); + + it('a legal legacy `options.kanban` / `options.calendar` pair round-trips byte-for-byte', () => { + const options = { kanban: { groupByField: 'status', columns: ['name'] }, calendar: { startDateField: 'created_at' } }; + const parsed = ViewMetadataSchema.safeParse(overlay({ type: 'kanban', options })); + expect(parsed.success, JSON.stringify(!parsed.success && parsed.error.issues)).toBe(true); + const out = (parsed as { data: { options?: Record } }).data.options; + expect(out).toEqual(options); + // …and re-parsing the parse output is a fixed point. + expect(ViewMetadataSchema.safeParse((parsed as { data: unknown }).data)).toMatchObject({ success: true }); + }); + + it('a PARTIAL underlay parses — the per-key merge population objectui pins', () => { + // objectui `ObjectView.namedViewProtocolKeys-8980.test.tsx`: "a key the + // canonical block does NOT restate survives from the legacy nesting — the + // merge is per-key, not wholesale". `kanban.groupByField` and + // `kanban.columns` are REQUIRED on the top-level block; the underlay here + // carries only the key the top-level block leaves to it. + const body = overlay({ + type: 'kanban', + kanban: { groupByField: 'status', columns: ['name'] }, + options: { kanban: { titleField: 'subject' } }, + }); + const parsed = ViewMetadataSchema.safeParse(body); + expect(parsed.success, JSON.stringify(!parsed.success && parsed.error.issues)).toBe(true); + // …while the SAME partial block written directly is still refused for the + // required keys it omits: the top-level block is the one the renderer + // completes, the underlay is not. + expect(ViewMetadataSchema.safeParse(overlay({ type: 'kanban', kanban: { titleField: 'subject' } })).success).toBe(false); + }); + + it('CONTROL: an overlay with no `options` parses exactly as before, with no `options` key', () => { + const parsed = ViewMetadataSchema.safeParse(overlay({ type: 'grid' })); + expect(parsed.success).toBe(true); + expect('options' in (parsed as { data: Record }).data).toBe(false); + }); +}); + +describe('[#20051] the form overlay cannot take the bag the list overlay refused', () => { + it('a column-less, type-less body with a bad `options` bag is refused, not accepted as a form', () => { + // Without the form overlay's `options` pin this body is ACCEPTED: the list + // member refuses it (no `columns`, and the bag), the form member — which + // `.strip()`s and requires no list key — takes it, and the bag is stored + // unjudged after all. + const body = { + name: 'crm_lead.all', + object: 'crm_lead', + viewKind: 'list', + sort: [{ field: 'name', order: 'asc' }], + options: { timeline: { metaFields: ['region'] } }, + }; + expect(ViewMetadataSchema.safeParse(body).success).toBe(false); + const form = VIEW_METADATA_MEMBERS.formOverlay.safeParse(body); + expect(form.success).toBe(false); + }); + + it('a flat form overlay carrying `options` is refused with the list-view prescription', () => { + const body = { + name: 'lead.contact_us', + object: 'lead', + viewKind: 'form', + type: 'simple', + options: { kanban: { groupByField: 'status' } }, + }; + const d = diagnoseViewMetadata(body); + expect(d.success).toBe(false); + if (d.success) return; + expect(d.branch).toBe('formOverlay'); + const hit = (d.issues as unknown as Issue[]).find((i) => i.path[0] === 'options'); + expect(hit?.message).toContain('A form view carries no `options` bag'); + }); + + it('CONTROL: a flat form overlay without `options` still parses', () => { + const body = { name: 'lead.contact_us', object: 'lead', viewKind: 'form', type: 'simple' }; + expect(ViewMetadataSchema.safeParse(body).success).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index c474dcb477c..17af6c62fc0 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5433,6 +5433,101 @@ const ViewContainerWireSchema = lazySchema(() => }), ); +/** + * [#20051] The per-kind blocks of the list-view shape, keyed by kind — the + * vocabulary of the legacy `options` bag below, each judged KEY BY KEY. + * + * Derived, never hand-listed: a kind is a value of the shape's own `type` enum + * that ALSO names a block on the shape (`grid` names none), and each entry is + * the very schema the direct spelling is judged by — the same strict key set, + * the same per-key schemas, the same unknown-key message — with every key made + * optional (`.partial()`). So a new kind with a new block is judged under + * `options` in the same edit, and `options.timeline` can never be judged by + * anything but `timeline`'s schema. + * + * Why `.partial()` and not the block as-is (measured, not preferred): the + * renderer reads `options.KIND` as an UNDERLAY of the top-level block, merged + * per key with the top-level block winning (objectui `ListView`: kanban, + * calendar, gallery, timeline, gantt and map spread `options.KIND` first and + * `KIND` last). A legacy bag that carries only the keys the top-level block + * does not restate (`kanban: { groupByField }` beside `options.kanban: + * { titleField }`) is therefore legal config — objectui pins exactly that + * population (`ObjectView.namedViewProtocolKeys-8980.test.tsx`, "the merge is + * per-key, not wholesale") — and judging it with the block's REQUIRED keys + * would refuse it for a key it never meant to carry. What the ruling asks is + * kept whole: an out-of-contract key is refused by name, and a value the key's + * own schema refuses is refused at that key. Required-ness belongs to the + * block the renderer builds, not to one layer of it. + * + * `.partial()` throws on an object that carries refinements (zod 4), which is + * the loud answer this derivation wants: a kind block that grows a cross-key + * check forces a decision about how that check reads on a partial underlay, + * instead of silently losing it. + */ +function listViewKindBlocks(): Record { + const shape = (ListViewShapeSchema as unknown as { shape: Record }).shape; + const blocks: Record = {}; + for (const kind of overlayTypeValues(ListViewShapeSchema)) { + const block = shape[kind] as unknown as { unwrap?: () => { partial: () => z.ZodTypeAny } } | undefined; + if (block?.unwrap) blocks[kind] = block.unwrap().partial().optional(); + } + return blocks; +} + +/** + * [#20051] The legacy `options` bag on a flattened LIST overlay, judged. + * + * The renderer still reads `options.KIND` beside the top-level `KIND` block + * (objectui's `ListView` merges the two per key, and its interface page + * forwards a stored view's `options` whole), so a stored bag is live config. + * Before this schema the overlay member's top-level `.strip()` dropped the bag + * from the parse without looking inside it, `saveMetaItem` stored the request + * body as sent, and the interface page rendered it: `options.timeline.metaFields` + * saved `200` while `timeline.metaFields` was refused `unrecognized_keys`. + * + * Ruled direction A (objectui#10380, maintainer 「其他同意」): each + * `options.KIND` is judged by the kind's own strict schema, so an out-of-contract + * key is refused by NAME, with the same code and the same surface text as the + * direct spelling — only the path gains the `options` prefix. Key by key: see + * {@link listViewKindBlocks} for why the block's required keys are not asked of + * an underlay. The bag itself is closed too: it carries per-kind blocks and + * nothing else, so `options.foo` is refused rather than dropped (ruled out: + * refusing the bag WHOLE, which would break the legacy `options.map` path + * objectui pins). + * + * Declared on the list overlay member ONLY — never on the authoring shape (the + * bag is a legacy wire spelling, not something to teach an author), and the + * form overlay pins it absent ({@link FORM_OVERLAY_OPTIONS_REFUSED}). + */ +const ListViewOverlayOptionsSchema = lazySchema(() => strictObject({ + surface: 'this legacy `options` bag', + history: + 'Until this bag was judged at the view write door it was dropped from the parse unread and ' + + 'stored as sent, so a key its own block refuses still reached the rendered view.', + guidance: { + grid: 'A grid has no per-kind block: its settings (`columns`, `sort`, `filter`, …) are top-level keys of the view itself. Remove `options.grid`.', + }, +}, listViewKindBlocks()).describe( + 'Legacy per-kind nesting (`options.kanban`, `options.timeline`, …) a stored list overlay may carry. ' + + 'Each block is judged key by key by the same schema as the top-level block of that kind, which wins ' + + 'per key where both set one; prefer the top-level spelling.', +)); + +/** + * [#20051] The refusal the flattened FORM overlay gives an `options` bag. + * + * `options` is list-view vocabulary, so a body carrying it is a list overlay — + * the same structural-guard reasoning as `config` / `list` / `form` pinned + * absent in {@link flattenedViewOverlayFields}. Without this pin the form + * member, which `.strip()`s and requires no list key, would ACCEPT a list + * overlay the list member refused over its `options` bag (a column-less, + * type-less personalization body reaches it), and the bag would be stored + * unjudged after all. + */ +const FORM_OVERLAY_OPTIONS_REFUSED = + 'A form view carries no `options` bag: `options.kanban`, `options.timeline` and the other per-kind blocks ' + + 'belong to a list view. Remove `options`, or save this body as a list view (`viewKind: "list"` with its `columns`).'; + /** * [#6391] Member 3 of {@link ViewMetadataSchema} — a flattened runtime LIST * overlay: an inline `ListView` config at the top level plus the optional @@ -5451,6 +5546,10 @@ const ViewContainerWireSchema = lazySchema(() => * [#7741] `object` + `viewKind` are required on this arm (and its form * sibling) — see {@link flattenedViewOverlayFields} for the ruling and the * measured serving filter that decides exactly this pair. + * + * [#20051] …and `options` is DECLARED here, judged by + * {@link ListViewOverlayOptionsSchema}: `.strip()` re-opens the top level for + * round-trip keys, and a bag the renderer reads is not one of those. */ const ListViewOverlayWireSchema = lazySchema(() => // [#13216] Built from {@link ListViewShapeSchema}, not {@link ListViewSchema}: @@ -5461,7 +5560,10 @@ const ListViewOverlayWireSchema = lazySchema(() => // author reaches, so it is the last place the refusals may go missing. // `viewDoorsCarryingObjectLevelChecks` in `view.test.ts` fails if any of the // three attachment points is dropped. - ListViewShapeSchema.extend(flattenedViewOverlayFields()).strip() + ListViewShapeSchema.extend({ + ...flattenedViewOverlayFields(), + options: ListViewOverlayOptionsSchema.optional(), + }).strip() .superRefine(checkListViewCalendarVisualization), ); @@ -5471,9 +5573,15 @@ const ListViewOverlayWireSchema = lazySchema(() => * and the same `.strip()` rationale as {@link ListViewOverlayWireSchema}; the * list member is tried first, and a flattened form (no required `columns`, * disjoint `type` enum) then matches here. + * + * [#20051] `options` is pinned ABSENT here — see + * {@link FORM_OVERLAY_OPTIONS_REFUSED} for the fall-through it closes. */ const FormViewOverlayWireSchema = lazySchema(() => - FormViewSchema.extend(flattenedViewOverlayFields()).strip(), + FormViewSchema.extend({ + ...flattenedViewOverlayFields(), + options: z.undefined({ error: () => FORM_OVERLAY_OPTIONS_REFUSED }).optional(), + }).strip(), ); /**