From 3c07cebfdff024fa27dd2004ef482dd0cc9dd028 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 02:39:50 +0000 Subject: [PATCH 1/3] =?UTF-8?q?test(lint):=20flip=20the=20#9313=20boundary?= =?UTF-8?q?=20marker=20=E2=80=94=20a=20ViewItem=20record's=20config.sort/s?= =?UTF-8?q?earchableFields=20must=20be=20judged=20(#10001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three record-rung refusal tests, the retarget/clean/round-trip controls, and the rung-split positive controls. Red on origin/main's walks by design — the walk rungs land in the following commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --- .../lint/src/runtime-gate.view-writes.test.ts | 131 ++++++++++++++++-- .../src/validate-searchable-fields.test.ts | 59 +++++++- .../lint/src/validate-sortable-fields.test.ts | 122 +++++++++++++++- 3 files changed, 290 insertions(+), 22 deletions(-) diff --git a/packages/lint/src/runtime-gate.view-writes.test.ts b/packages/lint/src/runtime-gate.view-writes.test.ts index 14826ccfcf..41e091f3b4 100644 --- a/packages/lint/src/runtime-gate.view-writes.test.ts +++ b/packages/lint/src/runtime-gate.view-writes.test.ts @@ -1,7 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // #9313 — the two list-view FIELD rules at the runtime publish gate, on the -// flattened standalone list overlay. +// flattened standalone list overlay. #10001 extends the same door onto the +// standalone ViewItem RECORD (`config.sort` / `config.searchableFields`, one +// level down) — the boundary marker #9313 left here is flipped in the record +// block below. // // The card's own trap, restated because every test here exists to spring it: // widening the reference-integrity suite's `runtimeTypes` to `view` is @@ -176,24 +179,124 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => { expect(offFlow).toEqual([]); }); - // ── the shapes this card deliberately does not judge ── + // ── [#10001] the ViewItem RECORD rung — the #9313 boundary marker, flipped ── + // + // The test that stood here pinned the opposite: "a ViewItem RECORD's nested + // `config.sort` is not judged here — recorded scope, not a rung that fell + // off". That marker kept the gap RECORDED until its own card; #10001 is that + // card, and this block is the rung's proof. The record is + // `ViewMetadataSchema`'s member 1 (`ViewItemWireSchema`, + // `{ name, object, viewKind: 'list', config }`) — the shape a Studio-saved + // view takes through `PUT /api/v1/meta/view`, and a hot one: objectui's + // `updateView` GETs the stored record and PUTs `{ ...current, ...partial }` + // (`view.zod.ts`'s #5074 trace), so every pin/reorder toggle round-trips the + // whole record, `config` included. - it('a ViewItem RECORD\'s nested `config.sort` is not judged here — recorded scope, not a rung that fell off', () => { - // `{ name, object, viewKind, config }` is `ViewMetadataSchema`'s member 1; - // its bad sort lives one level down, in `config`, which no walk reads. - // #9313's scope is the flattened overlay (the fence in the claim), and the - // record shape is filed as its own follow-up — this pin is the boundary - // marker that keeps the gap RECORDED instead of rediscovered. - const record = { - name: 'crm_case.pipeline', + /** A ViewItem record as `saveMetaItem` stores it (original body, verbatim). */ + const record = ( + configPatch: Record, + patch: Record = {}, + ) => ({ + name: 'crm_case.pipeline', + object: 'crm_case', + viewKind: 'list', + config: { type: 'grid', columns: ['name'], ...configPatch }, + ...patch, + }); + + it('REFUSES a record\'s `config.sort` naming an unknown field — the flipped boundary marker (#10001)', () => { + const { errors } = gate(record({ sort: [{ field: 'amout', order: 'desc' }] })); + const f = errors.find((e) => e.rule === SORT_FIELD_UNKNOWN); + expect(f, JSON.stringify(errors)).toBeDefined(); + expect(f!.path).toBe('views[0].config.sort[0]'); + expect(f!.where).toContain('ViewItem record'); + }); + + it('REFUSES a record\'s `config.sort` naming a formula field — no column to ORDER BY (#10001)', () => { + const { errors } = gate(record({ sort: [{ field: 'days_open', order: 'desc' }] })); + const f = errors.find((e) => e.rule === SORT_FIELD_UNSORTABLE); + expect(f, JSON.stringify(errors)).toBeDefined(); + expect(f!.path).toBe('views[0].config.sort[0]'); + }); + + it('REFUSES a record\'s `config.searchableFields` entry that resolves to no field (#10001)', () => { + const { errors } = gate(record({ searchableFields: ['name', 'budget'] })); + const f = errors.find((e) => e.rule === SEARCHABLE_FIELD_UNKNOWN); + expect(f, JSON.stringify(errors)).toBeDefined(); + expect(f!.path).toBe('views[0].config.searchableFields[1]'); + }); + + it('honors the record config\'s own `data.object` binding over the record\'s `object` (#10001)', () => { + // ADR-0047's explicit retarget, resolved on the CONFIG (where a record's + // data binding lives), ahead of the record's top-level `object` — the + // same order every other list-view rung reads. + const { errors } = gate(record( + { data: { provider: 'object', object: 'crm_case' }, sort: 'nonexistent_field desc' }, + { object: 'crm_other' }, + )); + expect(errors.map((e) => e.rule)).toContain(SORT_FIELD_UNKNOWN); + }); + + it('a console-shaped record round-trip publishes clean — `updateView`\'s merged PUT, decorations and all (#10001)', () => { + // `{ ...current, ...partial }`: `isPinned`/`sortOrder` at the top, + // `config.sort[].id` carrying objectui's `crypto.randomUUID()` row ids + // (#5074) — `saveMetaItem` persists the original body, so the gate judges + // exactly this shape. + const result = gate(record( + { + sort: [{ id: 'a2b4c86e-1111-4111-8111-000000000003', field: 'status', order: 'asc' }], + searchableFields: ['name'], + }, + { isPinned: true, sortOrder: 2 }, + )); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + }); + + it('judges a record on its `config` rung ONLY — a stray top-level `sort` is not judged as an overlay (#10001)', () => { + // The rung-split control. The overlay rung's `!isRec(config)` guard keeps + // record bodies out, and the record rung reads `config` alone — so a + // record carrying a stray top-level `sort` (the wire schema strips the + // key, but `saveMetaItem` persists the ORIGINAL body) yields exactly one + // finding, on the config path. Two findings here = the rungs leaked into + // each other's shapes; a top-level-path finding = the record was read as + // an overlay. Both are the drift this control exists to catch. + const { errors } = gate(record( + { sort: [{ field: 'amout', order: 'desc' }] }, + { sort: [{ field: 'also_not_a_field', order: 'asc' }] }, + )); + const sortFindings = errors.filter((e) => e.rule === SORT_FIELD_UNKNOWN); + expect(sortFindings, JSON.stringify(errors)).toHaveLength(1); + expect(sortFindings[0].path).toBe('views[0].config.sort[0]'); + }); + + it('a FORM record\'s config declares no list-field surface and is not judged (#10001)', () => { + // The rung keys on `viewKind: 'list'`, mirroring the overlay rung and the + // wire union's own arms: a `form` record carries `FormViewSchema` config, + // which has no `sort` / `searchableFields` — a stray one riding in the + // stored body must not be judged by a list-view rule. + const { errors } = gate({ + name: 'crm_case.edit', object: 'crm_case', - viewKind: 'list', - config: { type: 'grid', columns: ['name'], sort: [{ field: 'amout', order: 'desc' }] }, - }; - const { errors } = gate(record); + viewKind: 'form', + config: { type: 'simple', fields: ['name'], sort: [{ field: 'amout', order: 'desc' }] }, + }); expect(errors, JSON.stringify(errors)).toEqual([]); }); + // ── positive control: the pre-#10001 rungs behave EXACTLY as before ── + + it('the flattened overlay is judged exactly once, on its top-level path — no record-rung leak (#10001)', () => { + // Passes on origin/main BEFORE the record rung and must keep passing + // after: one finding, top-level path. A `config`-rung leak into the + // overlay shape would move the path; a double judgment would add one. + const { errors } = gate(overlay({ sort: [{ field: 'amout', order: 'desc' }] })); + const sortFindings = errors.filter((e) => e.rule === SORT_FIELD_UNKNOWN); + expect(sortFindings, JSON.stringify(errors)).toHaveLength(1); + expect(sortFindings[0].path).toBe('views[0].sort[0]'); + expect(sortFindings[0].where).toContain('flattened list overlay'); + }); + // ── the D4 differential, on the newly reachable rules ── it('does not blame a `view` write for a stored object\'s own bad list view', () => { diff --git a/packages/lint/src/validate-searchable-fields.test.ts b/packages/lint/src/validate-searchable-fields.test.ts index 8d609f1388..76ce996794 100644 --- a/packages/lint/src/validate-searchable-fields.test.ts +++ b/packages/lint/src/validate-searchable-fields.test.ts @@ -351,9 +351,15 @@ describe('validateSearchableFields — list views that narrow the set', () => { expect(findings[0].path).toBe('views[0].searchableFields[1]'); }); - it('does NOT read a ViewItem record\'s top level as an overlay (#9313)', () => { - // The record shape carries its set in `config` — a different rung, - // deliberately not walked (recorded scope; see the sort twin's module note). + // ── [#10001] the RECORD rung: a standalone ViewItem record ── + // + // The record shape (`ViewMetadataSchema`'s member 1) carries its set one + // level down, in `config`. The test that stood here pinned the #9313 + // boundary ("a different rung, deliberately not walked"); #10001 closes + // that recorded scope — recogniser and binding order mirrored from the + // sort twin, which carries the full note. + + it('flags a stale entry on a ViewItem record\'s nested `config.searchableFields` (#10001)', () => { const findings = validateSearchableFields({ objects: [objectWithFields], views: [ @@ -365,6 +371,53 @@ describe('validateSearchableFields — list views that narrow the set', () => { }, ], }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNKNOWN); + expect(findings[0].path).toBe('views[0].config.searchableFields[0]'); + expect(findings[0].where).toBe('view "crm_account.pipeline" (ViewItem record)'); + }); + + it('judges a record\'s set as a NARROWING — the #4830 admissibility applies (#10001)', () => { + // A lookup-typed entry in the record's config set is echoed as the + // `$searchFields` override on the view's toolbar search, the same as + // every other list-view surface. + const findings = validateSearchableFields({ + objects: [ + { + name: 'crm_case', + fields: { name: { type: 'text' }, account_id: { type: 'lookup' } }, + }, + ], + views: [ + { + name: 'crm_case.mine', + object: 'crm_case', + viewKind: 'list', + config: { type: 'grid', searchableFields: ['name', 'account_id'] }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); + expect(findings[0].path).toBe('views[0].config.searchableFields[1]'); + }); + + it('still does NOT read a record\'s top level as an overlay (#10001)', () => { + // A record carrying a stray top-level set (`saveMetaItem` persists the + // original body) is judged on `config.searchableFields` alone — the + // overlay rung's `!isRec(config)` guard holds, exactly as before #10001. + const findings = validateSearchableFields({ + objects: [objectWithFields], + views: [ + { + name: 'crm_account.pipeline', + object: 'crm_account', + viewKind: 'list', + searchableFields: ['not_a_field'], + config: { type: 'grid', columns: ['name'], searchableFields: ['name'] }, + }, + ], + }); expect(findings).toEqual([]); }); diff --git a/packages/lint/src/validate-sortable-fields.test.ts b/packages/lint/src/validate-sortable-fields.test.ts index d50b208000..ab94a0df79 100644 --- a/packages/lint/src/validate-sortable-fields.test.ts +++ b/packages/lint/src/validate-sortable-fields.test.ts @@ -314,11 +314,19 @@ describe('validateSortableFields — the surfaces it walks', () => { expect(findings[0].message).toContain('crm_task'); }); - it('does NOT read a ViewItem record\'s top level as an overlay (#9313)', () => { - // `{ viewKind, config }` is the RECORD shape — its sort lives in `config`, - // a rung this rule deliberately does not walk (recorded scope, see the - // module note). The structural guard is `config` being a record, mirroring - // the wire schema's own member discrimination. + // ── [#10001] the RECORD rung: a standalone ViewItem record ── + // + // `{ name, object, viewKind: 'list', config }` is `ViewMetadataSchema`'s + // member 1 (`ViewItemWireSchema`) — the shape a Studio-saved view takes + // through `PUT /api/v1/meta/view`, whose `sort` lives one level down in + // `config`. The test that stood here pinned the #9313 boundary ("its sort + // lives in `config`, a rung this rule deliberately does not walk"); #10001 + // closes that recorded scope, and the rung is recognised by the wire + // schema's own member discrimination: `viewKind: 'list'` AND a + // record-shaped `config` — the exact complement of the overlay rung's + // `!isRec(config)` guard. + + it('walks a ViewItem record\'s nested `config.sort` (#10001)', () => { const findings = validateSortableFields({ objects: [{ name: 'crm_lead', fields }], views: [ @@ -330,9 +338,113 @@ describe('validateSortableFields — the surfaces it walks', () => { }, ], }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SORT_FIELD_UNSORTABLE); + expect(findings[0].where).toBe('view "crm_lead.pipeline" (ViewItem record)'); + expect(findings[0].path).toBe('views[0].config.sort[0]'); + }); + + it('honors the record config\'s `data.object` retarget over the record\'s `object` (#10001)', () => { + const findings = validateSortableFields({ + objects: [ + { name: 'crm_lead', fields: { name: { type: 'text' } } }, + { name: 'crm_task', fields: { age: { type: 'formula' } } }, + ], + views: [ + { + name: 'crm_lead.tasks', + object: 'crm_lead', + viewKind: 'list', + config: { + type: 'grid', + data: { provider: 'object', object: 'crm_task' }, + sort: [{ field: 'age', order: 'desc' }], + }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('crm_task'); + }); + + it('still does NOT read a ViewItem record\'s top level as an overlay (#10001)', () => { + // The rung-split half the record rung must not disturb: a record carrying + // a stray top-level `sort` (`saveMetaItem` persists the original body) is + // judged on `config.sort` alone — the overlay rung's `!isRec(config)` + // guard keeps the record's top level out, exactly as before #10001. + const findings = validateSortableFields({ + objects: [{ name: 'crm_lead', fields }], + views: [ + { + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list', + sort: [{ field: 'not_a_field', order: 'asc' }], + config: { type: 'grid', columns: ['name'], sort: [{ field: 'name', order: 'asc' }] }, + }, + ], + }); expect(findings).toEqual([]); }); + it('a FORM record\'s config is not judged — the rung keys on viewKind \'list\' (#10001)', () => { + // `FormViewSchema` declares no `sort`; a stray one riding in the stored + // body (the store keeps the original) must not be judged by a list rule. + const findings = validateSortableFields({ + objects: [{ name: 'crm_lead', fields }], + views: [ + { + name: 'crm_lead.edit', + object: 'crm_lead', + viewKind: 'form', + config: { type: 'simple', sort: [{ field: 'score', order: 'desc' }] }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('each list-view rung is judged exactly once — no rung double-judges another\'s shape (#10001)', () => { + // One bad sort per rung in a single stack: object listView, container + // `list`, flattened overlay, ViewItem record. Exactly four findings, one + // per declared path — a leak or double judgment changes the census. + const findings = validateSortableFields({ + objects: [ + { + name: 'crm_lead', + fields, + listViews: { aging: { type: 'grid', sort: [{ field: 'score', order: 'desc' }] } }, + }, + ], + views: [ + { + name: 'lead_views', + objectName: 'crm_lead', + list: { type: 'grid', sort: [{ field: 'score', order: 'desc' }] }, + }, + { + name: 'crm_lead.hot', + object: 'crm_lead', + viewKind: 'list', + type: 'grid', + sort: [{ field: 'score', order: 'desc' }], + }, + { + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list', + config: { type: 'grid', sort: [{ field: 'score', order: 'desc' }] }, + }, + ], + }); + expect(findings.map((f) => f.path).sort()).toEqual([ + 'objects[0].listViews.aging.sort[0]', + 'views[0].list.sort[0]', + 'views[1].sort[0]', + 'views[2].config.sort[0]', + ]); + }); + it('a form overlay has no sort surface and is not judged (#9313)', () => { // `FormViewSchema` tombstones `sort` — a related list sorts by its own // list view's sort — so the rung keys on `viewKind: 'list'` alone. From dc2d43620050290c7940041fd29e777fb861de38 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 02:42:55 +0000 Subject: [PATCH 2/3] feat(lint): a standalone ViewItem record's config.sort/config.searchableFields reach the runtime publish gate (#10001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RECORD rung in both twin walks: a views[] entry carrying viewKind 'list' AND a record-shaped config — ViewMetadataSchema's member 1 (ViewItemWireSchema), the Studio-saved-view shape through PUT /api/v1/meta/view — is judged on config.sort / config.searchableFields against listViewObject(config) ?? entry.object, path views[i].config.*. The #9313 dispatch widening already routes view writes to exactly these two members; this closes the recorded follow-up scope, walk-only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --- .../lint/src/validate-searchable-fields.ts | 35 ++++++++++--- packages/lint/src/validate-sortable-fields.ts | 52 +++++++++++++++++-- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/packages/lint/src/validate-searchable-fields.ts b/packages/lint/src/validate-searchable-fields.ts index 0c0533af46..1aff830e67 100644 --- a/packages/lint/src/validate-searchable-fields.ts +++ b/packages/lint/src/validate-searchable-fields.ts @@ -530,10 +530,11 @@ export function checkSearchableFieldList( /** * Validate every `searchableFields` declaration in the stack — the object's own - * (the canonical set, ADR-0061) and the list views that narrow it, including a - * flattened standalone list overlay authored as a `views[]` entry itself - * (#9313 — the `PUT /api/v1/meta/view` shape the runtime publish gate - * snapshots). Returns findings (empty = clean). + * (the canonical set, ADR-0061) and the list views that narrow it, including + * the two standalone `views[]` shapes the `PUT /api/v1/meta/view` door + * carries and the runtime publish gate snapshots: the flattened list overlay + * (#9313, top-level set) and the ViewItem record (#10001, + * `config.searchableFields` one level down). Returns findings (empty = clean). * * The react page surface (``) is deliberately * NOT walked here: its declaration lives inside JSX source, and @@ -622,8 +623,8 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ // recognises it (`validate-sortable-fields.ts` carries the full note): // `viewKind: 'list'` (required on the overlay arm since #7741, refused by // name on the strict container schema) with no nested `config` (that - // shape is a ViewItem RECORD — its `config.searchableFields` is a - // different rung, deliberately not walked here). A `narrowing`, like + // shape is a ViewItem RECORD — judged by its own record rung below since + // #10001). A `narrowing`, like // every list-view surface: the overlay's set is echoed verbatim as the // `$searchFields` override and judged by the #4254 ingress gate. if (view.viewKind === 'list' && !isRec(view.config)) { @@ -637,6 +638,28 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ ); } + // ── [#10001] The RECORD rung: a standalone ViewItem record ── + // + // The self rung's structural complement — `ViewMetadataSchema`'s member 1 + // (`ViewItemWireSchema`, `{ name, object, viewKind: 'list', config }`), + // the Studio-saved-view shape through the same door, its set one level + // down inside `config`. Recogniser, binding order and the deliberate + // non-reading of the record's top level are mirrored from the sort twin + // (`validate-sortable-fields.ts`), which carries the full note. A + // `narrowing` for the same reason as every list-view surface: the + // record's config set is echoed as the `$searchFields` override on that + // view's toolbar search and judged by the #4254 ingress gate. + if (view.viewKind === 'list' && isRec(view.config)) { + check( + view.config.searchableFields, + listViewObject(view.config) ?? viewObject, + `view "${viewLabel}" (ViewItem record)`, + `views[${vi}].config.searchableFields`, + 'list-view searchableFields', + 'narrowing', + ); + } + if (isRec(view.list)) { check( view.list.searchableFields, diff --git a/packages/lint/src/validate-sortable-fields.ts b/packages/lint/src/validate-sortable-fields.ts index 3ea58a4604..5414b16d6d 100644 --- a/packages/lint/src/validate-sortable-fields.ts +++ b/packages/lint/src/validate-sortable-fields.ts @@ -91,6 +91,13 @@ * without this rung the dispatch widening (#9313's other half) would be * a silent no-op that reads as coverage. See the self rung note at the * walk below for how the shape is recognised. + * - `views[].config.sort` on a standalone ViewItem RECORD (#10001) — the + * wire union's OTHER standalone member (`ViewItemWireSchema`, + * `{ name, object, viewKind: 'list', config }`), through the same door: + * objectui's `updateView` round-trips the whole record on every + * pin/reorder toggle, `config` included. Judged against + * `listViewObject(config) ?? entry.object`; the record rung note at the + * walk below carries the recogniser. * * NOT walked, each verified against the schema rather than assumed: * @@ -127,9 +134,8 @@ * #4463 P2 gap this module's first landing recorded — and is walked since * #9313 (the self rung above). Its sibling shape, a standalone ViewItem * RECORD (`{ name, object, viewKind, config }` — `config.sort` one level - * down), is NOT walked here: #9313's scope is the flattened overlay, and the - * record shape is recorded as its own follow-up rather than silently ridden - * along. + * down), was #9313's recorded follow-up scope and is walked since #10001 + * (the record rung above). * * ── Skips, matching the search axis one for one (ADR-0072 D1) ──────────── * @@ -450,8 +456,8 @@ export function validateSortableFields(stack: AnyRec): SortableFieldFinding[] { // `viewKind: 'list'` (REQUIRED on the overlay arm since #7741, and a key // the strict container schema refuses by name, so a container can never // match) with no nested `config` (a body carrying one is a ViewItem - // RECORD, whose `config.sort` is a different rung — deliberately not - // walked here, see the module note). `view-walk.ts` established the same + // RECORD, judged by its own record rung below since #10001). + // `view-walk.ts` established the same // `self` rung for the section-carrying shapes; this is its list twin. // // The binding order matches every other list-view rung: the overlay's own @@ -469,6 +475,42 @@ export function validateSortableFields(stack: AnyRec): SortableFieldFinding[] { ); } + // ── [#10001] The RECORD rung: a standalone ViewItem record ── + // + // The self rung's structural complement — `ViewMetadataSchema`'s member 1 + // (`ViewItemWireSchema`): `{ name, object, viewKind: 'list', config }`, + // the shape a Studio-saved view takes through `PUT /api/v1/meta/view`, + // with its `sort` one level down inside `config`. A hot wire shape, not a + // corner: objectui's `updateView` GETs the stored record and PUTs + // `{ ...current, ...partial }` (`view.zod.ts`'s #5074 trace), so every + // pin/reorder toggle round-trips the whole record, `config` included — + // and before this rung a record whose `config.sort` named nothing + // published through the runtime gate in silence, then answered + // `400 INVALID_SORT` on the view's first fetch (#6994/#7095), every load. + // + // Recognised by the wire schema's own member discrimination, exactly as + // the self rung recognises its member: `viewKind: 'list'` AND a + // record-shaped `config` (the union's own record/overlay split; a strict + // container can carry neither key, and a `form` record's + // `FormViewSchema` config declares no sort surface). The binding order + // matches every other list-view rung: the CONFIG's own `data.object` + // (ADR-0047 explicit retarget — a record's data binding lives on its + // config) ahead of the record's required top-level `object`. + // `config.sort[].id` row decorations (#5074) ride through `readSortKeys` + // unchanged. The record's top LEVEL is deliberately still not read: any + // stray `sort` riding beside `config` in the stored body (`saveMetaItem` + // persists the original) is a key the wire schema strips and no runtime + // read path serves — judging it would be a false-positive channel. + if (view.viewKind === 'list' && isRec(view.config)) { + check( + view.config.sort, + listViewObject(view.config) ?? viewObject, + `view "${viewLabel}" (ViewItem record)`, + `views[${vi}].config.sort`, + 'list-view sort', + ); + } + if (isRec(view.list)) { check( view.list.sort, From b7477d5a67c6a5fa8ed2577f710081921cc9cc9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 02:53:07 +0000 Subject: [PATCH 3/3] chore: changeset for the ViewItem record config rung (#10001) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --- .../view-door-viewitem-record-config-rung.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/view-door-viewitem-record-config-rung.md diff --git a/.changeset/view-door-viewitem-record-config-rung.md b/.changeset/view-door-viewitem-record-config-rung.md new file mode 100644 index 0000000000..2ee1343555 --- /dev/null +++ b/.changeset/view-door-viewitem-record-config-rung.md @@ -0,0 +1,37 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): a standalone ViewItem record's nested `config.sort` / `config.searchableFields` reach the runtime publish gate (#10001) + +An `active`-state `view` save through `saveMetaItem` (Studio, REST `/meta` +item CRUD, an MCP/AI author) whose body is a standalone ViewItem RECORD — +`ViewMetadataSchema`'s member 1, `{ name, object, viewKind: 'list', config }`, +the shape a Studio-saved view takes and the shape objectui's `updateView` +round-trips on every pin/reorder toggle — is now refused with the existing +422 `invalid_metadata` envelope when its `config.sort` / `config.searchableFields` +declares a field the bound object cannot honor: an unknown name, a virtual +(`formula`) sort target with no stored column to ORDER BY, or a search +narrowing the #4254 ingress gate would refuse on every toolbar search. #9313 +closed the same gap for the flattened list overlay, one union member over; +the record's declarations live one level down, inside `config`, and were +judged by neither list-view field rule — so a record write carrying +`config.sort: [{ field: '' }]` published in silence and answered +`400 INVALID_SORT` (#6994/#7095) on the view's first fetch, every load. + +Walk-only, by design: #9313 already widened the reference-integrity suite +entry and exactly these two members onto `view` writes, so this change adds +the RECORD rung to both twin walks — recognised by the wire union's own +member discrimination (`viewKind: 'list'` AND a record-shaped `config`; the +flattened-overlay rung keeps its `no nested config` guard, a strict container +carries neither key, and a `form` record has no list-field surface), judged +against `listViewObject(config) ?? record.object` at path +`views[i].config.sort[…]` / `views[i].config.searchableFields[…]`. The +per-member granularity split is unchanged: no further suite member crosses +onto `view`. Measured before shipping: 0 refusals and 0 advisories over 39 +record-shaped console round-trip bodies (one per shipped list surface, +`config.sort[].id` decorations and `isPinned`/`sortOrder` riding along, the +shape `saveMetaItem` really stores) across the four shipped stacks — a lower +bound, as every authored corpus is. Draft saves are untouched (D1), stored +rows keep being served (ADR-0087 asymmetry), and +`OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to a loud log.