Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/26711-create-view-envelope-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@object-ui/data-objectstack': patch
---

`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.

Clause-②: no
Metadata-protocol: none
22 changes: 22 additions & 0 deletions .changeset/26711-view-write-door-unknown-key-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/spec': minor
---

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

`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.

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)

<!-- 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. -->

**BREAKING** accept-set narrowing at the `view` write door (`@objectstack/spec`), served by every door that saves a `view`.

- **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.
- **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`.
- **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.
- **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.

**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`.
10 changes: 5 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 217 | 206 | 4 | 0 | 7 |
| `ui/` | 217 | 208 | 4 | 0 | 5 |

## `ui/` — sites

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

**7 strip of 217**, in 4 file(s).
**5 strip of 217**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `view.zod.ts` | 4 | 59 |
| `view.zod.ts` | 2 | 59 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **7** | **217** |
| **total** | **5** | **217** |

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 1 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 4 |
| wire / open — out of forced scope | 2 |
| no door — no carrier, ADR-0049 territory | 1 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 1 |
2 changes: 1 addition & 1 deletion docs/audits/2026-07-unknown-key-strictness-ledger.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,25 @@ async function storedViewBody(name: string, item: unknown): Promise<any> {
return JSON.parse(row!.metadata);
}

/**
* [#26711] Save a view the door refuses: the 422 envelope, NO row written, and
* the `unrecognized_keys` issue the claimed member raised for the undeclared keys.
*/
async function refusedUndeclared(item: Record<string, unknown>): Promise<{ message: string }> {
const { protocol, rows } = makeProtocol();
let err: any;
try {
await (protocol as any).saveMetaItem({ type: 'view', name: item.name as string, item });
} catch (e) { err = e; }
expect(err?.code).toBe('INVALID_METADATA');
expect(err?.status).toBe(422);
expect(Array.from(rows.values()).filter((r) => r.type === 'view')).toHaveLength(0);
const issue = (err.issues as Array<{ code?: string; message: string }>)
.find((i) => i.code === 'unrecognized_keys');
expect(issue, JSON.stringify(err.issues)).toBeDefined();
return { message: issue!.message };
}

/** The one section every fixture below declares, in the authored spelling. */
const SECTION = { label: 'About you', fields: ['name', 'email'] };
const SHARING = { allowAnonymous: true, publicLink: '/forms/contact-us' };
Expand Down Expand Up @@ -214,22 +233,23 @@ describe('#7134 the save path persists the folded `sections`, at every depth a f
expect(body).not.toHaveProperty('groups');
});

it('[#20051] the fold and the form overlay\'s declared keys coexist on one save; its undeclared ones do not', async () => {
// Re-judged at stage (iv) of #20051, which stores a view's parsed
// body. The fold is stored, and so is `isDefault`, which the flattened
// FORM overlay declares. `isPinned` and `sortOrder` are the list
// switcher's row state: the form member declares neither and the
// console writes neither on a form row (`VIEW_CONSOLE_ROUND_TRIP_KEYS`
// maps no key to `formOverlay`), so the stored row drops both.
const body = await storedViewBody(
'contact_us',
flatForm({ groups: [SECTION], isPinned: true, isDefault: false, sortOrder: 3 }),
);
it('[#26711] the fold and the form overlay\'s declared keys coexist on one save; its undeclared ones are refused by name', async () => {
// Re-judged twice. At stage (iv) of #20051, which stores a view's parsed
// body, the undeclared keys were dropped from the row on a `200`. Since
// #26711 the form member is closed, so they are refused. The fold is
// stored, and so is `isDefault`, which the flattened FORM overlay
// declares. `isPinned` and `sortOrder` are the list switcher's row
// state: the form member declares neither and the console writes
// neither on a form row (`VIEW_CONSOLE_ROUND_TRIP_KEYS` maps no key to
// `formOverlay`).
const body = await storedViewBody('contact_us', flatForm({ groups: [SECTION], isDefault: false }));
expect(body.sections).toEqual([SECTION]);
expect(body).not.toHaveProperty('groups');
expect(body.isDefault).toBe(false);
expect(body).not.toHaveProperty('isPinned');
expect(body).not.toHaveProperty('sortOrder');
const refusal = await refusedUndeclared(
flatForm({ groups: [SECTION], isPinned: true, isDefault: false, sortOrder: 3 }),
);
expect(refusal.message).toMatch(/^Unrecognized key\(s\) on this inline form view: `isPinned`, `sortOrder`\./);
});
});

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

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

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

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

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

it('a declared key keeps its normalised value', async () => {
Expand Down Expand Up @@ -641,7 +671,7 @@ describe('[#20051] stage (iv): a saved view stores the parsed value of every key
it('the persisted body parses to exactly what the save parsed', async () => {
const bodies: Array<Record<string, unknown>> = [
...TOOLBAR_SAVES.map(([, patch]) => ({ ...patch, ...OVERLAY_ID })),
flatForm({ groups: [SECTION], isPinned: true }),
flatForm({ groups: [SECTION], isDefault: true }),
{
name: 'lead.contact_us', object: 'lead', viewKind: 'form', label: 'Contact us',
config: { type: 'simple', data: DATA, sharing: SHARING, groups: [SECTION] },
Expand Down
48 changes: 48 additions & 0 deletions packages/metadata-protocol/src/protocol.save-union-issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,3 +504,51 @@ describe('#20230 a flattened overlay carrying a retired owner/hidden is refused
}
});
});

// [#26711] The write door's unknown TOP-LEVEL key. Three of the union's four
// members `.strip()`ped their top level, and since the save stores the parsed
// body (#20051) a misspelt or retired key answered success and was not stored:
// `PUT /api/v1/meta/view/…?mode=draft` with `zzz_junk_key` → 200, key absent.
describe('#26711 the view write door refuses an unknown top-level key', () => {
const OVERLAY = { name: 'task_list', object: 'task', viewKind: 'list', type: 'grid', columns: ['title'] };
const RECORD = {
name: 'task.mine', object: 'task', viewKind: 'list',
config: { type: 'grid', data: { provider: 'object', object: 'task' }, columns: ['title'] },
};

it.each([
['an inline list view, draft', OVERLAY, 'task_list', 'draft', 'zzz_junk_key', 'this inline list view'],
['an inline list view, publish', OVERLAY, 'task_list', undefined, 'striped', 'this inline list view'],
['a view item record, draft', RECORD, 'task.mine', 'draft', 'zzz_junk_key', 'this view item record'],
] as const)('%s: 422 INVALID_METADATA naming the key, nothing stored', async (_label, body, name, mode, key, surface) => {
const { protocol, rows } = makeProtocol();

const err = await rejection(protocol.saveMetaItem({ type: 'view', name, item: { ...body, [key]: true }, ...(mode ? { mode } : {}) }));

expect(err.code).toBe('INVALID_METADATA');
expect(err.status).toBe(422);
expect(rows.size).toBe(0);
const unknownKey = err.issues.find((i: any) => i.code === 'unrecognized_keys');
expect(unknownKey?.message).toMatch(new RegExp(`^Unrecognized key\\(s\\) on ${surface}: \`${key}\`\\.`));
});

it.each([
['an inline list view', OVERLAY, 'task_list'],
['a view item record', RECORD, 'task.mine'],
] as const)('CONTROL: %s without the key saves, one row', async (_label, body, name) => {
const { protocol, rows } = makeProtocol();
const result = await protocol.saveMetaItem({ type: 'view', name, item: body, mode: 'draft' });
expect(result.success).toBe(true);
expect(rows.size).toBe(1);
});

it('READ PATH: a row stored with such a key is served badged invalid, naming it', async () => {
const { protocol } = makeProtocol([
{ type: 'view', name: 'task_list', metadata: { ...OVERLAY, zzz_junk_key: true } },
]);

const served = (await protocol.getMetaItem({ type: 'view', name: 'task_list' })).item;
expect(served._diagnostics?.valid).toBe(false);
expect(JSON.stringify(served._diagnostics)).toContain('zzz_junk_key');
});
});
Loading
Loading