Skip to content

Commit 8a218fc

Browse files
fix(spec)!: the view write door refuses an unknown top-level key (#26851)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB5z61o2LpDkGEiu41Hnhi5p)_ Fixes #26711 Clause-②: no (narrowing) — the `view` write door refuses a body carrying an undeclared top-level key on a view item record or an inline list or form view, which it saved before without the key. Nothing is widened. Metadata-protocol: narrowed (a saved `view` body may no longer carry a top-level key its shape does not declare) Skills-docs: none — no page under skills/** or content/docs teaches that the view write door kept an unknown top-level key; the authoring surfaces (defineView / defineViewItem) were already closed ## 维护者速读 - 改了什么:`view` 写入口(`ViewMetadataSchema` 的 view item 记录、内联 list 覆盖、内联 form 覆盖三个成员)从 `.strip()` 改为封闭,未知顶层键返回 422 `unrecognized_keys` 并点名;container 本来就是封闭的。 - 为什么改:自 #20051 起保存的是解析后的 body,未知键(拼错的、已退役的如 `striped`)会得到 200 但被静默丢弃,与 object / page / dashboard 写入口不一致。 - 风险与回滚:收窄型变更,带 `unrecognized_keys` 的旧请求现在会被拒;已测语料(示例 59 个 view、showcase 启动后 36 个已服务 view 的回写)零误伤;唯一受影响的平台写入方是 UI 适配器 `createView` 给 envelope 加顶层 `data`,本 PR 已修。回滚 = revert 本 PR。 - 先看:`packages/spec/src/ui/view.zod.ts`(`closeWireMember` 与三个成员),再看 `view-wire-unknown-key-refusal.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run --project local src/ui/view-wire-unknown-key-refusal.test.ts`,以及 `pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.save-union-issues.test.ts`。 - 需要你做的:进入队列前需要一次 contract 级评审(收窄型协议变更)。 ## What changes `ViewMetadataSchema` judges every `view` body saved through `PUT /api/v1/meta/view/:name` (draft or publish), the MCP metadata tools and Studio. It is a union of four members. The `defineView` container was already closed; the other three stripped their top level: - the view item record `{ name, object, viewKind, config }` (`ViewItemWireSchema`'s arms), - the inline list view overlay (`ListViewOverlayWireSchema`), - the inline form view overlay (`FormViewOverlayWireSchema`). Since the save stores the parsed body (#20051), a key one of them did not declare answered `200` and was not stored. All three are now closed with the same `strictObject` / `closedObject` machinery the other doors use, so the save answers `422 INVALID_METADATA` with an `unrecognized_keys` issue that names the key and the shape: ``Unrecognized key(s) on this inline list view: `striped`.`` - **Console decorations are kept.** The preprocess still removes the console's row ids on `filter[]` / `sort[]` before the check; the save door removes the read decorations (`_diagnostics`, `_draft`) and the server-stamped provenance (`_packageId`, `_packageVersion`, `_provenance`) before it validates, as it did before. Every key the console writes and reads back (`VIEW_CONSOLE_ROUND_TRIP_KEYS`: `isDefault`, `isPinned`, `sortOrder`, `visibility`, `columnState`, `_isOverride`) is declared on the member it is written on. - **Retired keys get what their retirement gives them.** Keys with a live 18.x tombstone (`filterableFields`, `pageName`, `tabs`, `owner`, `hidden`) raise their own prescription, lifted to the union message as before. `striped`, `bordered` and `virtualScroll` retired in 17.x, and their kits have aged out under the ADR-0087 amendment of 2026-10-26 (#25767 ruling D2 = A), so they are named and pointed at the protocol upgrade guide, which is the channel that amendment prescribes. I did not re-add tombstones for them, since that would contradict the ruling. - **Alias spellings point at the declared key.** `objectName` gets ``Did you mean `objectName` → `object`?`` and a top-level `id` gets `name`. - **One platform writer fixed.** `ObjectStackAdapter.createView` (`@object-ui/data-objectstack`) added a top-level `data` to every spec. On a ViewItem envelope `data` belongs inside `config`, so that create would now be refused. It now adds `data` only to a flat list view (patch changeset for `@object-ui/data-objectstack` under the root `.changeset`, which is the only changeset directory the release reads). The console's own create flow goes through `createRuntimeMetadata` / `viewEnvelope` and was not affected. - **Reverse pins moved.** `STILL_STRIP` in `metadata-type-schemas.test.ts` is now empty: `view` was its last entry, and its "end state" note is kept as history with the premise that changed. The authoring lint's view-divergence pin now asserts the registry schema refuses the key, instead of the old strip finding. The strictness ledger row for `view.zod.ts` is updated and its counts regenerated. ## Tests - `packages/spec/src/ui/view-wire-unknown-key-refusal.test.ts` (new, 17 tests) covers each closed member: - a clean body saves; - console row ids are still removed; - a junk key is refused by name with the member's surface; - the container is still closed; - `striped` gets the upgrade-guide sentence; - `filterableFields` gets its live tombstone prescription; - `objectName` and `id` get their alias guidance. - `packages/metadata-protocol/src/protocol.save-union-issues.test.ts` exercises the real `saveMetaItem` path: - a draft or publish save with a junk key, or with `striped`, on an inline list view answers 422 `INVALID_METADATA` and writes 0 rows, and the message names the key; - the same holds for a junk key on a view item record; - control: a clean draft stores one row; - read path: a stored row carrying a junk key is served with `_diagnostics.valid: false` naming the key. - `ui/packages/data-objectstack/src/createView.envelopeData-26711.test.ts`: `createView` on an envelope sends no top-level `data` and the platform schema accepts the body. The control shows that body with the old `data` is refused, and a flat list view still gets its default `data`. - Re-judged fixtures that froze the strip. Each one now pins the refusal, or moves the key to where it is declared: - `view-authoring-wire-split`, `view-filter-rule-wire-id`, `view-metadata-schema`, `view-overlay-viewkind-arm`, `view-strictness-batch18`, `view-union-diagnostics`, `view.test`; - `metadata-type-schemas`, `metadata-authoring-lint`; - `protocol.graft-folded-form-sections`; - CI round at `a3aeacbf`: `@objectstack/objectql` `protocol-meta` and `protocol-publish-rollback` (`objectName` is now refused naming `object`, and `extra` is gone from the diff fixture); spec `scripts/strictness-ledger.test` (the `view.zod.ts` strip count 4 → 2, with a history line); and `view-list-filterable-fields-retirement` (an EXCLUDED entry for this PR's own refusal pin). ## Acceptance notes - **The #5599 premise changed.** #5599 kept the members' `.strip()` only while the save stored the original body. After #20051 the save stores the parsed body, and #20456 declared the console's round-trip keys, so stripping no longer kept anything; it only lost it silently. - **Legacy stored rows.** A row stored before #20051 that still carries an undeclared top-level key is served badged `_diagnostics.valid: false`, naming the key. This is pinned on the read side. Its next console save is refused, naming the key. That part is inferred from the same parse and was not driven through the console. - **Corpus measured, nothing refused.** At `ef7067fd`, the example apps' view barrels (app-crm, app-showcase, app-todo, authored and expanded items) gave 59 bodies. All 59 save before and after the change. With a top-level junk key added, 49 saved before and 0 save after. `platform-objects` ships no view definitions. ## Verification - Premise re-measured on `main` `ef7067fd` (the QA ran at `3fcafcd3`). At schema level, the list overlay, the view item record and the form overlay each accepted a junk key and `striped`, and stripped them. The container refused. - Live showcase boot on this branch: - `PUT /api/v1/meta/view/showcase_task.qa_rk_view?mode=draft` with a clean body: `200`. - The same body with `zzz_junk_key` or `striped`: `422 INVALID_METADATA`, and the issue names the key. - The stored draft keeps only the clean keys. - Round-trip of 36 served views (GET, then PUT back unchanged as a draft): 36 accepted, 0 refused. - CI round at `a3aeacbf` (Test Core 1/8 and 7/8 on `66637aea` were this PR's): before the main merge, a locked wide run passed 17 of 17 suites (spec `--project repo`, objectql, rest, runtime, metadata-fs, metadata-core, cli unit, hono-server, client, audit, mcp, i18n, automation, http-conformance, security, cloud-connection, client-react). On the merged tree (`5b87033b`), `@objectstack/metadata-protocol` passed 270 files / 21672 tests, `@objectstack/objectql` passed 475 / 8843, and spec `check:generated` exits 0. - Locked test runs on this branch, after the fixture re-judging (logs below): - spec, metadata-protocol, lint and metadata; - the 19 `ui` test files that reference the view schema exports; - spec and metadata-protocol typecheck; - `check:test-typecheck`, `check:generated` and `check:strictness-ledger`. - Derived gates: all 89 accounted for, 0 not measured. Dogfood on the final (merged) head: 314 files, 2609 tests green. <details><summary>Test and gate logs</summary> Merged head, under `scripts/pm/os-verify-lock.sh` (shared box): ``` spec pnpm --filter @objectstack/spec test Test Files 735 passed (735) · Tests 22045 passed | 1 todo protocol pnpm --filter @objectstack/metadata-protocol test Test Files 269 passed | 4 skipped · Tests 21628 passed | 26 skipped ui view 19 ui test files on the view schema exports Test Files 19 passed · Tests 591 passed adapter pnpm --filter @object-ui/data-objectstack test Test Files 101 passed · Tests 1205 passed dogfood pnpm --filter '@objectstack/dogfood^...' build; pnpm --filter @objectstack/dogfood test Test Files 314 passed | 3 skipped (317) · Tests 2609 passed | 20 skipped generated pnpm --filter @objectstack/spec run check:generated exit 0 (after the merge) ``` Pre-merge head (same diff), also locked: spec and metadata-protocol typecheck exit 0, `@objectstack/lint` test exit 0, `@objectstack/metadata` test exit 0, `pnpm lint` exit 0. Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`, 89 commands, each run with its exit code recorded): ``` Run reconciliation — 89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED (a DERIVED zero — all 89 recorded an exit code and none of them is 3). ``` Six of the 89 first answered exit 3 (prerequisite not met): five spec gates that read `dist` (the test-file edits had moved the source hash) and `check-plugin-teardown-shape --self-test` (needs an unshallow clone). They were re-run after `pnpm --filter @objectstack/spec build` and `git fetch --unshallow`, and all exit 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the `维护者速读` block is the maintainer digest the dispatch asks for). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufziYCUHZQE3MXucvWe9)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufziYCUHZQE3MXucvWe9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1708115 commit 8a218fc

23 files changed

Lines changed: 626 additions & 149 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@object-ui/data-objectstack': patch
3+
---
4+
5+
`ObjectStackAdapter.createView` no longer adds a top-level `data` to a ViewItem envelope (`{ name, object, viewKind, config }`). `data` belongs inside `config` there, and the platform's `view` write door now refuses a top-level key the envelope does not declare (objectstack#26711), so such a create was refused naming `data`. Before, the door dropped the key and the view saved. A flat list view still gets its default `data`, where that shape declares it. The console's own create flow writes through `createRuntimeMetadata` and was not affected.
6+
7+
Clause-②: no
8+
Metadata-protocol: none
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: the `view` write door refuses an unknown top-level key on a view item record and on an inline list or form view, as the object, page and dashboard doors do, instead of saving without it
6+
7+
`ViewMetadataSchema` judges every `view` body saved through `PUT /api/v1/meta/view/:name` (draft or publish), the MCP metadata tools and Studio. Three of its four members (the view item record `{ name, object, viewKind, config }`, and the inline list and form views that carry their config at the top level) dropped a top-level key they do not declare. The save stores the parsed body, so a misspelt or retired key answered success and was not stored. They now refuse it with `422 INVALID_METADATA` / `unrecognized_keys`, naming the key and the shape: `Unrecognized key(s) on this inline list view: \`striped\`.` The `defineView` container was already closed.
8+
9+
Clause-②: no (narrowing) — the `view` write door refuses a body carrying an undeclared top-level key on a view item record or an inline list or form view, which it saved before without the key. Nothing is widened.
10+
11+
Metadata-protocol: narrowed (a saved `view` body may no longer carry a top-level key its shape does not declare)
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: the three members declared the same keys before and after, and every key the console writes and reads back is among them. An undeclared key never reached the store, since the save stores the parsed body, so there is nothing for a migration to rewrite; the author decides, and the refusal names the key. -->
14+
15+
**BREAKING** accept-set narrowing at the `view` write door (`@objectstack/spec`), served by every door that saves a `view`.
16+
17+
- **Before.** `PUT /api/v1/meta/view/:name` with a valid inline list view plus `"zzz_junk_key": true`, or a retired list key such as `"striped": true`, answered `200`, and the key was absent from the stored view. The same held for a view item record and an inline form view.
18+
- **Now.** The same body answers `422 INVALID_METADATA` with an `unrecognized_keys` issue naming the key. A retired key gets what its retirement gives it: a key whose retirement is still current (`filterableFields`, `pageName`, `tabs`, `owner`, `hidden`) raises its own prescription, as before; one whose retirement has aged out (`striped`, `bordered`, `virtualScroll`) is named and pointed at the protocol upgrade guide. `objectName` is answered with `Did you mean \`objectName\` → \`object\`?`, and a top-level `id` with `name`.
19+
- **Unchanged.** Every key the console writes and reads back on a stored view (`isDefault`, `isPinned`, `sortOrder`, `visibility`, `columnState`, `_isOverride`) is declared and saves as before. The console's row ids on `filter[]` / `sort[]` are still removed before the check. The read decorations (`_diagnostics`, `_draft`) and the server-stamped provenance (`_packageId`, `_packageVersion`, `_provenance`) are still removed by the save before it validates. `defineView` / `defineViewItem` were already closed.
20+
- **Who sees it.** An author (a person, an MCP client or an AI agent) who sends a `view` body with a key its shape does not declare. That key was not stored before. A stored view row written before the save began storing the parsed body may still carry such a key; the next save of that row through the console is refused, naming it.
21+
22+
**The remedy.** Remove the key, or move it to where it is declared: a list view setting on a view item record goes under `config`, `objectName` is `object`, a top-level `id` is `name`.

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 217 | 206 | 4 | 0 | 7 |
24+
| `ui/` | 217 | 208 | 4 | 0 | 5 |
2525

2626
## `ui/` — sites
2727

@@ -54,21 +54,21 @@ Per file, how many of its sites still silently discard unknown keys. The `Class`
5454
column that decides the bucket split is hand-written in the ledger; the arithmetic
5555
over it is here.
5656

57-
**7 strip of 217**, in 4 file(s).
57+
**5 strip of 217**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
63-
| `view.zod.ts` | 4 | 59 |
63+
| `view.zod.ts` | 2 | 59 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **7** | **217** |
65+
| **total** | **5** | **217** |
6666

6767
| Bucket | Sites |
6868
|---|---|
6969
| authorable — the ruling's forced scope | 1 |
7070
| unresolved — needs a per-schema verdict | 0 |
71-
| wire / open — out of forced scope | 4 |
71+
| wire / open — out of forced scope | 2 |
7272
| no door — no carrier, ADR-0049 territory | 1 |
7373
| no gate — carrier live, no parse | 0 |
7474
| covered — no carrier, no parse, guarded at every consumer | 1 |

‎docs/audits/2026-07-unknown-key-strictness-ledger.md‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts‎

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,25 @@ async function storedViewBody(name: string, item: unknown): Promise<any> {
148148
return JSON.parse(row!.metadata);
149149
}
150150

151+
/**
152+
* [#26711] Save a view the door refuses: the 422 envelope, NO row written, and
153+
* the `unrecognized_keys` issue the claimed member raised for the undeclared keys.
154+
*/
155+
async function refusedUndeclared(item: Record<string, unknown>): Promise<{ message: string }> {
156+
const { protocol, rows } = makeProtocol();
157+
let err: any;
158+
try {
159+
await (protocol as any).saveMetaItem({ type: 'view', name: item.name as string, item });
160+
} catch (e) { err = e; }
161+
expect(err?.code).toBe('INVALID_METADATA');
162+
expect(err?.status).toBe(422);
163+
expect(Array.from(rows.values()).filter((r) => r.type === 'view')).toHaveLength(0);
164+
const issue = (err.issues as Array<{ code?: string; message: string }>)
165+
.find((i) => i.code === 'unrecognized_keys');
166+
expect(issue, JSON.stringify(err.issues)).toBeDefined();
167+
return { message: issue!.message };
168+
}
169+
151170
/** The one section every fixture below declares, in the authored spelling. */
152171
const SECTION = { label: 'About you', fields: ['name', 'email'] };
153172
const SHARING = { allowAnonymous: true, publicLink: '/forms/contact-us' };
@@ -214,22 +233,23 @@ describe('#7134 the save path persists the folded `sections`, at every depth a f
214233
expect(body).not.toHaveProperty('groups');
215234
});
216235

217-
it('[#20051] the fold and the form overlay\'s declared keys coexist on one save; its undeclared ones do not', async () => {
218-
// Re-judged at stage (iv) of #20051, which stores a view's parsed
219-
// body. The fold is stored, and so is `isDefault`, which the flattened
220-
// FORM overlay declares. `isPinned` and `sortOrder` are the list
221-
// switcher's row state: the form member declares neither and the
222-
// console writes neither on a form row (`VIEW_CONSOLE_ROUND_TRIP_KEYS`
223-
// maps no key to `formOverlay`), so the stored row drops both.
224-
const body = await storedViewBody(
225-
'contact_us',
226-
flatForm({ groups: [SECTION], isPinned: true, isDefault: false, sortOrder: 3 }),
227-
);
236+
it('[#26711] the fold and the form overlay\'s declared keys coexist on one save; its undeclared ones are refused by name', async () => {
237+
// Re-judged twice. At stage (iv) of #20051, which stores a view's parsed
238+
// body, the undeclared keys were dropped from the row on a `200`. Since
239+
// #26711 the form member is closed, so they are refused. The fold is
240+
// stored, and so is `isDefault`, which the flattened FORM overlay
241+
// declares. `isPinned` and `sortOrder` are the list switcher's row
242+
// state: the form member declares neither and the console writes
243+
// neither on a form row (`VIEW_CONSOLE_ROUND_TRIP_KEYS` maps no key to
244+
// `formOverlay`).
245+
const body = await storedViewBody('contact_us', flatForm({ groups: [SECTION], isDefault: false }));
228246
expect(body.sections).toEqual([SECTION]);
229247
expect(body).not.toHaveProperty('groups');
230248
expect(body.isDefault).toBe(false);
231-
expect(body).not.toHaveProperty('isPinned');
232-
expect(body).not.toHaveProperty('sortOrder');
249+
const refusal = await refusedUndeclared(
250+
flatForm({ groups: [SECTION], isPinned: true, isDefault: false, sortOrder: 3 }),
251+
);
252+
expect(refusal.message).toMatch(/^Unrecognized key\(s\) on this inline form view: `isPinned`, `sortOrder`\./);
233253
});
234254
});
235255

@@ -258,22 +278,22 @@ describe('#7134 what the save path must NOT change', () => {
258278
expect(await storedViewBody(authored.name, authored)).toEqual(authored);
259279
});
260280

261-
it('[#20051] a form authored with canonical `sections` is stored as authored — no section default, no `sharing.enabled`, no undeclared key', async () => {
281+
it('[#20051] a form authored with canonical `sections` is stored as authored — no section default, no `sharing.enabled`', async () => {
262282
// Re-judged at stage (iv): was "stored byte-identical", with an
263-
// `isPinned` the form member does not declare riding along. The parse
264-
// adds `collapsible` / `collapsed` to the section and `enabled: false`
265-
// to `sharing`; ruling B stores none of them (ADR-0087's `storable`
266-
// rule), and the undeclared `isPinned` is dropped. A section's `columns`
267-
// has no default: undeclared, the parse adds no key (the renderer picks
268-
// the density by available width).
269-
const authored = flatForm({ sections: [SECTION], isPinned: true });
283+
// `isPinned` the form member does not declare riding along, which the
284+
// row then dropped. The parse adds `collapsible` / `collapsed` to the
285+
// section and `enabled: false` to `sharing`; ruling B stores none of
286+
// them (ADR-0087's `storable` rule). A section's `columns` has no
287+
// default: undeclared, the parse adds no key (the renderer picks the
288+
// density by available width). [#26711] The undeclared `isPinned` is
289+
// now refused by name (the test above), so the fixture no longer
290+
// carries it and the row is the body as authored.
291+
const authored = flatForm({ sections: [SECTION] });
270292
const parsed = (ViewMetadataSchema as any).parse(authored);
271293
expect(parsed.sections[0]).toMatchObject({ collapsible: false, collapsed: false });
272294
expect('columns' in parsed.sections[0]).toBe(false);
273295
expect(parsed.sharing.enabled).toBe(false);
274-
const body = await storedViewBody('contact_us', authored);
275-
const { isPinned: _dropped, ...declared } = authored as Record<string, unknown>;
276-
expect(body).toEqual(declared);
296+
expect(await storedViewBody('contact_us', authored)).toEqual(authored);
277297
});
278298

279299
it('GUARD: a LIST overlay is untouched — this walk is form-shaped only', async () => {
@@ -511,7 +531,10 @@ describe('#20186 the save door judges a flattened overlay by the member its view
511531
});
512532

513533
it('a column-less list overlay that names a `type`: 422 at `columns`, with the prescription', async () => {
514-
const issue = at(await refused({ ...LIST, type: 'kanban', groupByField: 'stage' }), 'columns');
534+
// [#26711] The kanban setting goes where the list view declares it: a
535+
// top-level `groupByField` is now refused as an unknown key, before
536+
// the column check runs.
537+
const issue = at(await refused({ ...LIST, type: 'kanban', kanban: { groupByField: 'stage', columns: ['name'] } }), 'columns');
515538
expect(issue.code).toBe('custom');
516539
expect(issue.message).toMatch(/^This list view overlay sets `type` but lists no `columns`\./);
517540
});
@@ -583,9 +606,14 @@ describe('[#20051] stage (iv): a saved view stores the parsed value of every key
583606
expect(stored.sort).toEqual([{ field: 'name', order: 'desc' }]);
584607
});
585608

586-
it('an undeclared key is dropped: the saved-view toolbar toggle\'s `id` / `objectName`, and a form-only `layout`', async () => {
587-
// W6b's shape: `buildPersistedViewBody`'s saved-view branch writes the
588-
// whole tab back, and the tab carries `id` and `objectName`.
609+
it('[#26711] an undeclared key is refused by name: a saved-view tab\'s `id` / `objectName`, and a form-only `layout`', async () => {
610+
// W6b's shape: `buildPersistedViewBody`'s saved-view branch wrote the
611+
// whole tab back, and the tab carried `id` and `objectName`. At stage
612+
// (iv) the row dropped them on a `200`; since #26711 the list member is
613+
// closed and the save is refused, naming each key and pointing the two
614+
// alias spellings at the declared ones. The console no longer writes
615+
// this shape: `buildViewConfigSaveBody` copies only the list view's
616+
// declared keys under the ViewItem envelope.
589617
const declared = {
590618
name: 'crm_lead.my_leads',
591619
object: 'crm_lead',
@@ -596,10 +624,12 @@ describe('[#20051] stage (iv): a saved view stores the parsed value of every key
596624
data: { provider: 'object', object: 'crm_lead' },
597625
isDefault: false,
598626
};
599-
const stored = await storedViewBody(declared.name, {
627+
expect(await storedViewBody(declared.name, declared)).toEqual(declared);
628+
const refusal = await refusedUndeclared({
600629
...declared, id: 'crm_lead.my_leads', objectName: 'crm_lead', layout: 'diagonal',
601630
});
602-
expect(stored).toEqual(declared);
631+
expect(refusal.message).toMatch(/^Unrecognized key\(s\) on this inline list view: `id`, `objectName`, `layout`\./);
632+
expect(refusal.message).toContain('Did you mean `id` → `name`, `objectName` → `object`?');
603633
});
604634

605635
it('a declared key keeps its normalised value', async () => {
@@ -641,7 +671,7 @@ describe('[#20051] stage (iv): a saved view stores the parsed value of every key
641671
it('the persisted body parses to exactly what the save parsed', async () => {
642672
const bodies: Array<Record<string, unknown>> = [
643673
...TOOLBAR_SAVES.map(([, patch]) => ({ ...patch, ...OVERLAY_ID })),
644-
flatForm({ groups: [SECTION], isPinned: true }),
674+
flatForm({ groups: [SECTION], isDefault: true }),
645675
{
646676
name: 'lead.contact_us', object: 'lead', viewKind: 'form', label: 'Contact us',
647677
config: { type: 'simple', data: DATA, sharing: SHARING, groups: [SECTION] },

‎packages/metadata-protocol/src/protocol.save-union-issues.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,3 +504,51 @@ describe('#20230 a flattened overlay carrying a retired owner/hidden is refused
504504
}
505505
});
506506
});
507+
508+
// [#26711] The write door's unknown TOP-LEVEL key. Three of the union's four
509+
// members `.strip()`ped their top level, and since the save stores the parsed
510+
// body (#20051) a misspelt or retired key answered success and was not stored:
511+
// `PUT /api/v1/meta/view/…?mode=draft` with `zzz_junk_key` → 200, key absent.
512+
describe('#26711 the view write door refuses an unknown top-level key', () => {
513+
const OVERLAY = { name: 'task_list', object: 'task', viewKind: 'list', type: 'grid', columns: ['title'] };
514+
const RECORD = {
515+
name: 'task.mine', object: 'task', viewKind: 'list',
516+
config: { type: 'grid', data: { provider: 'object', object: 'task' }, columns: ['title'] },
517+
};
518+
519+
it.each([
520+
['an inline list view, draft', OVERLAY, 'task_list', 'draft', 'zzz_junk_key', 'this inline list view'],
521+
['an inline list view, publish', OVERLAY, 'task_list', undefined, 'striped', 'this inline list view'],
522+
['a view item record, draft', RECORD, 'task.mine', 'draft', 'zzz_junk_key', 'this view item record'],
523+
] as const)('%s: 422 INVALID_METADATA naming the key, nothing stored', async (_label, body, name, mode, key, surface) => {
524+
const { protocol, rows } = makeProtocol();
525+
526+
const err = await rejection(protocol.saveMetaItem({ type: 'view', name, item: { ...body, [key]: true }, ...(mode ? { mode } : {}) }));
527+
528+
expect(err.code).toBe('INVALID_METADATA');
529+
expect(err.status).toBe(422);
530+
expect(rows.size).toBe(0);
531+
const unknownKey = err.issues.find((i: any) => i.code === 'unrecognized_keys');
532+
expect(unknownKey?.message).toMatch(new RegExp(`^Unrecognized key\\(s\\) on ${surface}: \`${key}\`\\.`));
533+
});
534+
535+
it.each([
536+
['an inline list view', OVERLAY, 'task_list'],
537+
['a view item record', RECORD, 'task.mine'],
538+
] as const)('CONTROL: %s without the key saves, one row', async (_label, body, name) => {
539+
const { protocol, rows } = makeProtocol();
540+
const result = await protocol.saveMetaItem({ type: 'view', name, item: body, mode: 'draft' });
541+
expect(result.success).toBe(true);
542+
expect(rows.size).toBe(1);
543+
});
544+
545+
it('READ PATH: a row stored with such a key is served badged invalid, naming it', async () => {
546+
const { protocol } = makeProtocol([
547+
{ type: 'view', name: 'task_list', metadata: { ...OVERLAY, zzz_junk_key: true } },
548+
]);
549+
550+
const served = (await protocol.getMetaItem({ type: 'view', name: 'task_list' })).item;
551+
expect(served._diagnostics?.valid).toBe(false);
552+
expect(JSON.stringify(served._diagnostics)).toContain('zzz_junk_key');
553+
});
554+
});

0 commit comments

Comments
 (0)