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
49 changes: 49 additions & 0 deletions .changeset/20051-view-overlay-options-bag-judged.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered view-overlay-options-bag-judged -->
16 changes: 8 additions & 8 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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 |
|---|---|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
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);
});
});
149 changes: 149 additions & 0 deletions packages/rest/src/meta-view-overlay-options-bag.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
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);
});
});
Loading
Loading