From 077b387c12ea0a77adbd5ecd37865a87dda75a2a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:21:50 +0000 Subject: [PATCH 1/2] fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot Every stored page sort slot (element dataSource.sort, a related list's sort and parentDataSource.sort, each sort-carrying block's own sort, interfaceConfig.sort, an app page's record.sort) is now asked existence and provenance by validateSortableFields, the member the page publish door runs: an unknown name is page-field-unknown at error, as the list door answers it 400 INVALID_SORT on every read. validatePageFieldBindings no longer reads a page sort, so each slot has one reader. Claude-Session: https://claude.ai/code/session_01Utp6CYvKbGaAgjBLQdkwPB Co-authored-by: Claude --- .changeset/30093-page-sort-unknown-field.md | 20 +++ .../src/validate-page-field-bindings.test.ts | 31 ++-- .../lint/src/validate-page-field-bindings.ts | 41 ++++- .../lint/src/validate-sortable-fields.test.ts | 145 +++++++++++++++++- packages/lint/src/validate-sortable-fields.ts | 109 ++++++++++--- .../protocol.runtime-authoring-gate.test.ts | 98 ++++++++++++ 6 files changed, 400 insertions(+), 44 deletions(-) create mode 100644 .changeset/30093-page-sort-unknown-field.md diff --git a/.changeset/30093-page-sort-unknown-field.md b/.changeset/30093-page-sort-unknown-field.md new file mode 100644 index 00000000000..ae53fc278cd --- /dev/null +++ b/.changeset/30093-page-sort-unknown-field.md @@ -0,0 +1,20 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: a stored page sort naming a field its object lacks is refused by `os validate` and at the page publish door (`page-field-unknown`, error), on every page sort slot (#30093) + +Clause-②: no (narrowing) — an unknown sort field on a page slot moves from silent or warning to error: `os validate` / `os build` / `os lint` and the `page` publish door refuse a page sort they accepted before. +Metadata-protocol: narrowed +Skills-docs: none — `skills/**` and `content/docs/**` teach no page sort that names a field the object lacks, and nothing there describes an unknown page sort as a warning; the verdict and its fix line name the field, the object and the nearest declared field. + + + +**BREAKING** accept-set narrowing at the page publish door (`PUT /api/v1/meta/page/NAME`; Studio, REST and MCP authors) and in `os validate` / `os build` / `os lint`. + +- **Before.** A page sort naming a field the object does not have saved green, and its first read, and every read after it, answered `400 INVALID_SORT` at the list door. A list page's `interfaceConfig.sort` and a `record:related_list` block's `sort` drew a `page-field-unknown` warning on the CLI alone; an element `dataSource.sort`, a related list's `parentDataSource.sort`, an app page's `record.sort` and the own `sort` of a `record:line_items`, `list-view` or `object-*` block were read by nothing. +- **Now.** Every one of those slots is judged by `validateSortableFields`, the reference-integrity member the page publish door already runs, and an unknown name is `page-field-unknown` at `error`, at the slot's path, bound to the object the slot's renderer reads (`dataSource.object`, else the block's object prop; `interfaceConfig.source`, else the page's `object`; `parentDataSource.object`; `record.object`). The page door refuses it `422 INVALID_METADATA`. A registry-injected column on an `external` object is `page-field-unprovisioned` at `warning`, as before. `validatePageFieldBindings` no longer reads a page sort, so each slot has one reader. +- **Not judged.** A dotted relationship path (the list door refuses it as its own verdict), a system column such as `created_at`, an object the stack (or the publish snapshot) does not carry, and an object with no field map. +- **Not changed.** A stored page already carrying such a sort keeps loading; only its next save is judged, and its read answers 400 as it did before. + +**What an author does.** Name a field the object declares, or add the field to the object. A refused name never sorted the page, so there is nothing to keep. diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 312ee5bf47a..052eee51390 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -423,7 +423,10 @@ describe('validatePageFieldBindings — a related list splits its child and pare ]]); }); - it('a dataSource-bound list checks sort, filter and relationshipField against the child too', () => { + // [#30093] The list's `sort` is a stored page sort, judged by + // `validateSortableFields` at the list door's grade + // (`validate-sortable-fields.test.ts` pins every slot), so it is not read here. + it('a dataSource-bound list checks filter and relationshipField against the child too, and leaves sort to the sort rule', () => { expect(findingsOf({ dataSource: { object: 'crm_account' }, properties: { @@ -432,7 +435,6 @@ describe('validatePageFieldBindings — a related list splits its child and pare filter: [{ field: 'ghost_filter', operator: 'equals', value: 'x' }], }, }).map(([, , path, message]) => [path, message])).toEqual([ - [`${LIST}.sort[0].field`, expect.stringContaining('"ghost_sort" is not a field on object "crm_account"')], [`${LIST}.filter[0].field`, expect.stringContaining('"ghost_filter" is not a field on object "crm_account"')], [`${LIST}.relationshipField`, expect.stringContaining('"ghost_fk" is not a field on object "crm_account"')], ]); @@ -731,14 +733,11 @@ describe('validatePageFieldBindings — false-positive floor', () => { }); }); -describe('validatePageFieldBindings — legacy bare-string sort (#4340)', () => { - /** - * `ListViewSchema.sort` still accepts `"created_at desc"`. Reading the whole - * string as a field name reported `"amount desc"` as unknown — a finding - * whose "field" the author never wrote, and one no fix could satisfy. The - * shared `sortFieldRefs` reads its head instead, the way the renderer does. - */ - it('judges the head segment, not the whole "field desc" string', () => { +// [#30093] A list page's `interfaceConfig.sort` is a stored page sort, judged +// by `validateSortableFields` (existence at `error`, the legacy string form read +// by its head — the #4340 reading, pinned there), so this walk reads none of it. +describe('validatePageFieldBindings — a stored page sort is not read here (#30093)', () => { + it('reports nothing for an unknown interfaceConfig.sort, in either form, and still reads the columns', () => { const listPage = (sort: unknown) => ({ ...baseStack(), pages: [ @@ -746,15 +745,15 @@ describe('validatePageFieldBindings — legacy bare-string sort (#4340)', () => name: 'list_page', type: 'list', object: 'crm_lead', - interfaceConfig: { source: 'crm_lead', columns: ['name'], sort }, + interfaceConfig: { source: 'crm_lead', columns: ['name', 'ghost_col'], sort }, }, ], }); - expect(validatePageFieldBindings(listPage('amount desc'))).toEqual([]); - const bad = validatePageFieldBindings(listPage('ghost_col desc')); - expect(bad).toHaveLength(1); - expect(bad[0].message).toContain('"ghost_col"'); - expect(bad[0].message).not.toContain('ghost_col desc'); + for (const sort of ['ghost_sort desc', [{ field: 'ghost_sort', order: 'desc' }]]) { + expect(validatePageFieldBindings(listPage(sort)).map((f) => f.path)).toEqual([ + 'pages[0].interfaceConfig.columns[1]', + ]); + } }); }); diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 0b58c005cdd..0bd89fd6d93 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -52,6 +52,21 @@ * same skips, same rule id: the two surfaces agree on what counts as a field by * construction rather than by two lists that happen to match, which is the * drift #4330 had just finished removing from the system-field lists. + * + * ── Stored page sorts are not read here (#30093) ───────────────────────── + * + * A page `sort` is not a binding a renderer skips: it lowers into the list + * door's ORDER BY, which answers an unknown name `400 INVALID_SORT` on the + * first load and every load after it. So every page sort slot (a component's + * `dataSource.sort`, a related list's `sort` and `parentDataSource.sort`, each + * sort-carrying block's own `sort`, `interfaceConfig.sort`, an app page's + * `record.sort`) is judged by `validateSortableFields`, under this family's + * ids: `page-field-unknown` at `error`, `page-field-unprovisioned` at + * `warning`. That member is the one the page publish door runs, so the miss + * refuses the save instead of publishing a page that breaks on its first read. + * Until #30093 this walk read two of those slots at `warning` and the rest + * were read by nothing. The react surface's sort props are not page metadata + * slots and keep their own reading in `validate-react-page-props`. */ export const PAGE_FIELD_UNKNOWN = 'page-field-unknown'; @@ -600,6 +615,20 @@ export function checkFieldRefs( return findings; } +/** + * [#30093] Is `ref` one a related list's `sort` prop yielded? A STORED page + * sort lowers into the list door's ORDER BY, which answers an unknown name + * `400 INVALID_SORT` on every load, so it is not the `skipped` miss this pass + * grades: `validateSortableFields` asks every page sort slot existence, + * provenance and sortability, under this family's ids, and it is the member the + * page publish door runs. {@link relatedListFieldRefs} keeps yielding the + * sort refs for its out-of-repo callers; only this walk sets them aside. + */ +function isPageSortRef(ref: FieldRef, propsPath: string): boolean { + const sortPath = `${propsPath}.sort`; + return ref.path === sortPath || ref.path.startsWith(`${sortPath}[`) || ref.path.startsWith(`${sortPath}.`); +} + export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { const findings: PageFieldFinding[] = []; if (!stack || typeof stack !== 'object') return findings; @@ -643,7 +672,14 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { // parent; without it, `properties.objectName` names the child. const split = relatedListFieldRefs(props, base); const dataSource = isRec(component.dataSource) ? component.dataSource : undefined; - checkRefs(split.related, strName(dataSource?.object) ?? split.relatedObject, where); + // [#30093] The list's `sort` is a stored page sort, judged by + // `validateSortableFields` at the list door's grade (see + // {@link isPageSortRef}); every other child-side ref stays here. + checkRefs( + split.related.filter((ref) => !isPageSortRef(ref, base)), + strName(dataSource?.object) ?? split.relatedObject, + where, + ); // `relationshipValueField` names a field on the PARENT — the record the // list hangs off, never its own child `dataSource`. The parent is the // list's own `parentDataSource.object` when it declares one (ruling B on @@ -690,9 +726,10 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { if (cfg) { const cfgObject = strName(cfg.source) ?? strName(page.object); const base = `pages[${pi}].interfaceConfig`; + // [#30093] `cfg.sort` is a stored page sort: `validateSortableFields` + // judges it, at the list door's grade. const refs: FieldRef[] = [ ...fieldRefsFrom(cfg.columns, `${base}.columns`), - ...sortFieldRefs(cfg.sort, `${base}.sort`), ...filterFieldRefs(cfg.filterBy, `${base}.filterBy`), ]; const userFilters = isRec(cfg.userFilters) ? cfg.userFilters : undefined; diff --git a/packages/lint/src/validate-sortable-fields.test.ts b/packages/lint/src/validate-sortable-fields.test.ts index 8dfd7015af7..5a42969517e 100644 --- a/packages/lint/src/validate-sortable-fields.test.ts +++ b/packages/lint/src/validate-sortable-fields.test.ts @@ -16,6 +16,11 @@ import { ComponentPropsMap } from '@objectstack/spec/ui'; import { indexObjectSearchTargets } from './validate-searchable-fields.js'; import { indexUnprovisionedAnchors } from './system-fields.js'; import { explainRule } from './rule-explanations.js'; +import { + PAGE_FIELD_UNKNOWN, + PAGE_FIELD_UNPROVISIONED, + validatePageFieldBindings, +} from './validate-page-field-bindings.js'; // [#22161] Each finding of the three ids is one verdict sentence; the reasoning // it used to carry is the id's `os explain` entry. Every call below records @@ -871,7 +876,7 @@ describe('validateSortableFields — stored PAGE sorts (#30023)', () => { expect(findings[0].message).toContain("a virtual 'formula' field"); }); - it('controls: no key, `true`, an unknown name, a dotted path and another package\'s object are not this rule\'s', () => { + it('controls: no key, `true`, a dotted path, a system column and another package\'s object are not this rule\'s', () => { const findings = validateSortableFields(pageStack({ ...region( { @@ -879,8 +884,9 @@ describe('validateSortableFields — stored PAGE sorts (#30023)', () => { properties: { objectName: 'help_ticket', relationshipField: 'subject', - // Existence and dotted paths stay with validate-page-field-bindings. - sort: [{ field: 'subject' }, { field: 'priority' }, { field: 'no_such_field' }, { field: 'owner.name' }, { field: 'created_at' }], + // A dotted path is refused by the door as its own verdict; neither + // family judges it. An unknown name is #30093's (below). + sort: [{ field: 'subject' }, { field: 'priority' }, { field: 'owner.name' }, { field: 'created_at' }], }, }, { type: 'object-grid', properties: { objectName: 'not_in_this_stack', sort: [{ field: 'body' }] } }, @@ -900,6 +906,132 @@ describe('validateSortableFields — stored PAGE sorts (#30023)', () => { }); }); +/** + * [#30093] Existence and provenance on every stored page sort slot. The list + * door answers an unknown sort name `400 INVALID_SORT` on every load, so a page + * slot naming one is graded `error`, like a list view's `sort-field-unknown`, + * under the page family's id. Before #30093 two slots warned (from + * `validate-page-field-bindings`) and the rest were read by nothing. + */ +describe('validateSortableFields — an unknown field on a stored PAGE sort (#30093)', () => { + const everySlot = (field: string) => ({ + ...region( + { type: 'record:related_list', properties: { objectName: 'help_ticket', relationshipField: 'subject', sort: [{ field }] } }, + { + type: 'record:related_list', + dataSource: { object: 'help_ticket', sort: [{ field, order: 'desc' }] }, + properties: { relationshipField: 'subject', parentDataSource: { object: 'help_ticket', sort: [{ field }] } }, + }, + { type: 'object-grid', properties: { objectName: 'help_ticket', sort: [{ field: 'subject' }, { field }] } }, + { type: 'record:line_items', properties: { childObject: 'help_ticket', sort: [{ field }] } }, + { type: 'list-view', properties: { type: 'grid', data: { provider: 'object', object: 'help_ticket' }, sort: [{ field }] } }, + { type: 'element:number', dataSource: { object: 'help_ticket', sort: [{ field }] }, properties: { field: 'priority' } }, + ), + interfaceConfig: { source: 'help_ticket', sort: [{ field }] }, + record: { object: 'help_ticket', sort: [{ field }] }, + }); + const SLOT_PATHS = [ + 'pages[0].regions[0].components[0].properties.sort[0]', + 'pages[0].regions[0].components[1].dataSource.sort[0]', + 'pages[0].regions[0].components[1].properties.parentDataSource.sort[0]', + 'pages[0].regions[0].components[2].properties.sort[1]', + 'pages[0].regions[0].components[3].properties.sort[0]', + 'pages[0].regions[0].components[4].properties.sort[0]', + 'pages[0].regions[0].components[5].dataSource.sort[0]', + 'pages[0].interfaceConfig.sort[0]', + 'pages[0].record.sort[0]', + ]; + + it('refuses it at every page slot, as `page-field-unknown` at `error`, at the slot path', () => { + const findings = validateSortableFields(pageStack(everySlot('subjcet'))); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual( + SLOT_PATHS.map((path) => [PAGE_FIELD_UNKNOWN, 'error', path]), + ); + expect(findings[0].message).toBe( + 'field "subjcet" is not a field on object "help_ticket" — the record:related_list sort orders by it, ' + + 'so every load answers 400 INVALID_SORT.', + ); + expect(findings[0].hint).toBe('Fix the field name, or add "subjcet" to help_ticket. Did you mean "subject"?'); + expect(findings.map((f) => f.where)).toContain('page "p" · record'); + }); + + it('is the one reader of a page sort: validate-page-field-bindings reports none of those slots', () => { + const stack = pageStack(everySlot('subjcet')); + const bindingPaths = validatePageFieldBindings(stack).map((f) => f.path); + expect(bindingPaths.filter((p) => /\.sort(\[|\.|$)/.test(p))).toEqual([]); + // Non-vacuity: the same walk still reads a related list's other child refs. + const withColumn = pageStack(region({ + type: 'record:related_list', + properties: { objectName: 'help_ticket', relationshipField: 'subject', columns: ['subjcet'], sort: [{ field: 'subjcet' }] }, + })); + expect(validatePageFieldBindings(withColumn).map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.columns[0]', + ]); + }); + + it('binds each slot to the object its renderer reads', () => { + // `body` is a help_ticket field and not a crm_opportunity one. + const blockOnDataSource = validateSortableFields(pageStack(region({ + type: 'object-grid', + dataSource: { object: 'crm_opportunity' }, + properties: { objectName: 'help_ticket', sort: [{ field: 'subject' }] }, + }))); + expect(blockOnDataSource.map((f) => [f.rule, f.path])).toEqual([ + [PAGE_FIELD_UNKNOWN, 'pages[0].regions[0].components[0].properties.sort[0]'], + ]); + expect(blockOnDataSource[0].message).toContain('object "crm_opportunity"'); + const listPageOnPageObject = validateSortableFields(pageStack({ + name: 'p', type: 'list', object: 'crm_opportunity', interfaceConfig: { sort: [{ field: 'subject' }] }, + })); + expect(listPageOnPageObject.map((f) => f.message)).toEqual([expect.stringContaining('object "crm_opportunity"')]); + }); + + it('judges the head of the legacy string form, its `-field` shorthand and each comma-separated key', () => { + const listPage = (sort: unknown) => pageStack({ name: 'p', type: 'list', object: 'help_ticket', interfaceConfig: { sort } }); + expect(validateSortableFields(listPage('subject desc'))).toEqual([]); + const bad = validateSortableFields(listPage('ghost_col desc')); + expect(bad.map((f) => [f.rule, f.path])).toEqual([[PAGE_FIELD_UNKNOWN, 'pages[0].interfaceConfig.sort']]); + expect(bad[0].message).toContain('"ghost_col"'); + expect(bad[0].message).not.toContain('ghost_col desc'); + expect(validateSortableFields(listPage('-ghost_col')).map((f) => f.rule)).toEqual([PAGE_FIELD_UNKNOWN]); + expect(validateSortableFields(listPage('subject asc, ghost_col desc')).map((f) => f.path)).toEqual([ + 'pages[0].interfaceConfig.sort[1]', + ]); + }); + + it('skips what the family cannot judge: a dotted path, another package\'s object, an object with no field map', () => { + const findings = validateSortableFields({ + objects: [ticket, { name: 'introspected_table' }], + pages: [region( + { type: 'object-grid', properties: { objectName: 'help_ticket', sort: [{ field: 'ghost.name' }] } }, + { type: 'object-grid', properties: { objectName: 'not_in_this_stack', sort: [{ field: 'ghost' }] } }, + { type: 'object-grid', properties: { objectName: 'introspected_table', sort: [{ field: 'ghost' }] } }, + )], + }); + expect(findings).toEqual([]); + }); + + it('asks provenance of an injected anchor on an external object, at `warning`, and not on the local twin', () => { + const page = (object: string) => ({ name: 'p', type: 'list', object, interfaceConfig: { sort: [{ field: 'created_at' }] } }); + const external = validateSortableFields({ objects: [externalObject], pages: [page('showcase_ext_customer')] }); + expect(external.map((f) => [f.rule, f.severity, f.path])).toEqual([ + [PAGE_FIELD_UNPROVISIONED, 'warning', 'pages[0].interfaceConfig.sort[0]'], + ]); + expect(external[0].message).toContain('so its ORDER BY is dropped'); + expect(external[0].hint).toContain('columnMap'); + expect(validateSortableFields({ objects: [localTwin], pages: [page('showcase_customer')] })).toEqual([]); + }); + + it('an author-declared field of a system name is the author\'s column, judged by the projection', () => { + const owned = { name: 'help_ticket', fields: { ...ticketFields, created_at: { type: 'datetime', label: 'Created', sortable: false } } }; + const findings = validateSortableFields({ + objects: [owned], + pages: [{ name: 'p', type: 'list', object: 'help_ticket', interfaceConfig: { sort: [{ field: 'created_at' }] } }], + }); + expect(findings.map((f) => [f.rule, f.path])).toEqual([[SORT_FIELD_UNSORTABLE, 'pages[0].interfaceConfig.sort[0]']]); + }); +}); + /** As much of a zod (v4) node as the drift pin below reads. */ interface ZodDefLite { type?: string; shape?: Record; in?: unknown; getter?: () => unknown; innerType?: unknown } const defOf = (node: unknown): ZodDefLite | undefined => (node as { _zod?: { def?: ZodDefLite } } | undefined)?._zod?.def; @@ -944,7 +1076,12 @@ describe('[#22161] one-line verdicts — the three sort ids', () => { it('every verdict the cases above fired is one line of at most 200 characters', () => { // The coverage control first: each id fired at least once, so the shape // assertion below cannot pass over an empty record. - expect([...new Set(fired.map((f) => f.rule))].sort()).toEqual([...SORT_IDS].sort()); + // [#30093] Page sort slots answer existence and provenance under the page + // family's ids, so those fire from this module too and are held to the + // same one-line shape. + expect([...new Set(fired.map((f) => f.rule))].sort()).toEqual( + [...SORT_IDS, PAGE_FIELD_UNKNOWN, PAGE_FIELD_UNPROVISIONED].sort(), + ); for (const f of fired) { expect(f.message, f.rule).not.toContain('\n'); expect(f.message.length, `${f.rule}: ${f.message}`).toBeLessThanOrEqual(200); diff --git a/packages/lint/src/validate-sortable-fields.ts b/packages/lint/src/validate-sortable-fields.ts index 97a0eee3fcc..c4224e93bee 100644 --- a/packages/lint/src/validate-sortable-fields.ts +++ b/packages/lint/src/validate-sortable-fields.ts @@ -137,15 +137,17 @@ * index, which is `validateChartBindings`' family, not this one. * * PAGE sorts (`ui/page.zod.ts`, `ui/component.zod.ts`) are walked since - * #30023, for checks 2 and 3 only: a component's `dataSource.sort`, a - * related list's `parentDataSource.sort`, the `properties.sort` of each block - * in `PAGE_BLOCK_SORT_OBJECT_PROP`, a list page's `interfaceConfig.sort` and an + * #30023: a component's `dataSource.sort`, a related list's + * `parentDataSource.sort`, the `properties.sort` of each block in + * `PAGE_BLOCK_SORT_OBJECT_PROP`, a list page's `interfaceConfig.sort` and an * app page's `record.sort`. Each reaches the same list door a list view's sort - * does. Existence and provenance on those positions stay with - * `validate-page-field-bindings`, which already reads them at its own - * severities; this module adds the one question it does not ask. A - * `kind: 'react'` page's JSX props are still `validate-react-page-props`' - * territory and are not walked here. + * does. Since #30093 every one of them is asked existence and provenance here + * too, under the page family's ids (`page-field-unknown` at `error`, + * `page-field-unprovisioned` at `warning`), and `validate-page-field-bindings` + * no longer reads a page sort: one reader per slot, at the door's grade, in the + * member that reaches the page publish door. A `kind: 'react'` page's JSX + * props are still `validate-react-page-props`' territory and are not walked + * here. * The flattened standalone list overlay used to be listed here too — the * #4463 P2 gap this module's first landing recorded — and is walked since * #9313 (the self rung above). Its sibling shape, a standalone ViewItem @@ -256,6 +258,7 @@ import { import { recordsOf, suggestName } from './object-graph.js'; import { walkPageComponents } from './page-walk.js'; +import { PAGE_FIELD_UNKNOWN, PAGE_FIELD_UNPROVISIONED } from './validate-page-field-bindings.js'; import { SYSTEM_FIELDS, indexUnprovisionedAnchors, @@ -579,18 +582,39 @@ export const PAGE_BLOCK_SORT_OBJECT_PROP: Readonly> = { }; /** - * [#30023] Judge one STORED PAGE sort against the sortability projection - * alone. A page sort reaches the same list door a list view's does, so a - * `sortable: false` verdict there is the same `400 INVALID_SORT`. Existence and - * provenance stay with `validate-page-field-bindings` (`page-field-unknown` / - * `page-field-unprovisioned`), which already reads these positions; answering - * them here too would report one name twice under two severities. + * [#30023] Judge one STORED PAGE sort. A page sort reaches the same list door a + * list view's does, so each key is asked the door's questions in the door's + * order, at the door's severity: + * + * 1. EXISTENCE (`page-field-unknown`, `error`, #30093) — a name the object + * does not have. The list door (`assertSortFieldsExist`) answers it + * `400 INVALID_SORT` on the first fetch and every fetch after it, so the + * grade is the list view's `sort-field-unknown` grade, under the page + * family's id: an author reading a page finding reads the page rule. + * Before #30093 two slots (a related list's `sort`, `interfaceConfig.sort`) + * were read here by `validate-page-field-bindings` at `warning`, and the + * element `dataSource.sort`, `parentDataSource.sort`, `record.sort` and + * the other blocks' own `sort` were read by nothing, so the page published + * green and broke on its first read. This member is the one that reaches + * the page publish door (`runtimeTypes` carries `page`), so asking it here + * refuses the page at save instead. + * 2. PROVENANCE (`page-field-unprovisioned`, `warning`) — a registry-injected + * anchor on an ADR-0015 `external` object. Its ORDER BY reaches the driver + * and is dropped; no door refuses it, so it stays advisory (#10474's + * reasoning, the list-view twin's grade). + * 3. SORTABILITY (`sort-field-unsortable`, `error`) — the projection's + * `sortable: false`, a `formula` or an author-declared one. + * + * Two skips, the `page-field-unknown` family's own: a DOTTED name, which the + * door refuses as its own verdict and this family does not judge, and an + * object this stack does not define or that declares no field map. */ function checkPageSortDeclaration( declared: unknown, objectName: string | undefined, fieldsByObject: ReadonlyMap, sortability: ReadonlyMap, + unprovisionedAnchors: ReadonlyMap>, where: string, path: string, subject: string, @@ -598,15 +622,52 @@ function checkPageSortDeclaration( if (declared === undefined || declared === null || !objectName) return []; const projection = sortability.get(objectName); if (!projection) return []; // ① object from another package + const target = fieldsByObject.get(objectName); // null ⇒ ② no authored field map + const anchors = unprovisionedAnchors.get(objectName); const findings: SortableFieldFinding[] = []; for (const key of readSortKeys(declared)) { + const name = key.field; + const at = `${path}${key.at}`; + if (name.includes('.')) continue; + if (SYSTEM_FIELDS.has(name)) { + if (anchors?.has(name)) { + findings.push({ + severity: 'warning', + rule: PAGE_FIELD_UNPROVISIONED, + where, + path: at, + message: + `${subject}: ${unprovisionedAnchorVerdict(objectName, name)}, so its ORDER BY is ` + + 'dropped and every fetch returns rows in an arbitrary order', + hint: unprovisionedAnchorFix(objectName, name), + }); + } + // An author-DECLARED field of a system name is the author's own column, + // so it falls through to the projection; a bare anchor has no entry there. + if (!target?.names.has(name)) continue; + } + if (target && !target.names.has(name)) { + findings.push({ + severity: 'error', + rule: PAGE_FIELD_UNKNOWN, + where, + path: at, + message: + `field "${name}" is not a field on object "${objectName}" — the ${subject} orders by it, ` + + 'so every load answers 400 INVALID_SORT.', + // The `page-field-unknown` family's one remedy line: the nearest + // declared field, never the field list. + hint: `Fix the field name, or add "${name}" to ${objectName}.${suggestName(name, target.names)}`, + }); + continue; + } const unsortable = unsortableFinding( - projection[key.field], - key.field, + projection[name], + name, objectName, - fieldsByObject.get(objectName)?.fields[key.field]?.type, + target?.fields[name]?.type, where, - `${path}${key.at}`, + at, subject, ); if (unsortable) findings.push(unsortable); @@ -616,7 +677,8 @@ function checkPageSortDeclaration( /** * [#30023] Every stored sort a PAGE carries that reaches the Data API list - * door, judged by {@link checkPageSortDeclaration}: + * door, judged by {@link checkPageSortDeclaration} (existence, provenance and + * sortability since #30093), each bound to the object its renderer reads: * * - every component's node-level `dataSource.sort` (`ElementDataSourceSchema`), * bound to `dataSource.object`; @@ -631,11 +693,14 @@ function validatePageSorts( stack: AnyRec, fieldsByObject: ReadonlyMap, sortability: ReadonlyMap, + unprovisionedAnchors: ReadonlyMap>, ): SortableFieldFinding[] { const findings: SortableFieldFinding[] = []; const check = (declared: unknown, objectName: string | undefined, where: string, path: string, subject: string) => { findings.push( - ...checkPageSortDeclaration(declared, objectName, fieldsByObject, sortability, where, path, subject), + ...checkPageSortDeclaration( + declared, objectName, fieldsByObject, sortability, unprovisionedAnchors, where, path, subject, + ), ); }; @@ -872,8 +937,8 @@ export function validateSortableFields(stack: AnyRec): SortableFieldFinding[] { } } - // ── [#30023] Stored page sorts: the projection's verdict only ── - findings.push(...validatePageSorts(stack, fieldsByObject, sortability)); + // ── [#30023] Stored page sorts: existence (#30093), provenance, sortability ── + findings.push(...validatePageSorts(stack, fieldsByObject, sortability, unprovisionedAnchors)); return findings; } diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index e22cc3e9d28..bc61ec7bd9d 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -5433,3 +5433,101 @@ describe('runtime authoring gate — a stored sort on a `sortable: false` field expect(sortIssues(pageErr)[0].message).not.toContain('sortable: false'); }); }); + +// #30093 — a stored page sort naming a field the object does not have is +// refused at the publish door. The Data API list door answers that sort +// `400 INVALID_SORT` on the page's first read and every read after it, so the +// save is refused instead (`page-field-unknown`, `error`, at the slot path). +// Before it, `interfaceConfig.sort` and a related list's `sort` were judged at +// `warning` on the CLI alone and the element `dataSource.sort` and app page +// `record.sort` by nothing, so the page saved green and broke on first read. +describe('runtime authoring gate — a stored page sort naming an unknown field (#30093)', () => { + const OBJECT = 'help_ticket'; + const ticket = { + name: OBJECT, + label: 'Ticket', + fields: { subject: { type: 'text', label: 'Subject' }, priority: { type: 'number', label: 'Priority' } }, + }; + function host() { + const { engine, rows } = makeStubEngine(); + engine.registry.listItems = (type: string) => (type === 'object' ? [ticket] : []); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + return { protocol, rows }; + } + const unknownIssues = (err: any) => (err?.issues ?? []).filter((i: any) => i.rule === 'page-field-unknown'); + const activePages = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'page' && r.state === 'active'); + const savePage = (protocol: any, item: unknown) => + protocol.saveMetaItem({ type: 'page', name: 'ticket_page', item }); + + /** One page per stored page slot, each sorting by `field`. */ + const pages = (field: string): Array<[slot: string, path: string, page: Record]> => [ + ['element dataSource.sort', 'pages[0].regions[0].components[0].dataSource.sort[0]', { + name: 'ticket_page', label: 'Tickets', type: 'record', object: OBJECT, + regions: [{ name: 'main', components: [{ + type: 'record:related_list', + dataSource: { object: OBJECT, sort: [{ field, order: 'desc' }] }, + properties: { objectName: OBJECT, relationshipField: 'subject' }, + }] }], + }], + ['record:related_list parentDataSource.sort', 'pages[0].regions[0].components[0].properties.parentDataSource.sort[0]', { + name: 'ticket_page', label: 'Tickets', type: 'record', object: OBJECT, + regions: [{ name: 'main', components: [{ + type: 'record:related_list', + properties: { + objectName: OBJECT, relationshipField: 'subject', + parentDataSource: { object: OBJECT, sort: [{ field, order: 'asc' }] }, + }, + }] }], + }], + ['record:related_list sort', 'pages[0].regions[0].components[0].properties.sort[0]', { + name: 'ticket_page', label: 'Tickets', type: 'record', object: OBJECT, + regions: [{ name: 'main', components: [{ + type: 'record:related_list', + properties: { objectName: OBJECT, relationshipField: 'subject', sort: [{ field, order: 'asc' }] }, + }] }], + }], + ['list page interfaceConfig.sort', 'pages[0].interfaceConfig.sort[0]', { + name: 'ticket_page', label: 'Tickets', type: 'list', object: OBJECT, + interfaceConfig: { source: OBJECT, sort: [{ field, order: 'asc' }] }, + }], + ['app page record.sort', 'pages[0].record.sort[0]', { + name: 'ticket_page', label: 'Tickets', type: 'app', + record: { object: OBJECT, sort: [{ field, order: 'asc' }] }, + regions: [{ name: 'main', components: [{ type: 'page:header', properties: { title: 'Tickets' } }] }], + }], + ]; + + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + }); + + it.each(pages('subjcet').map(([slot, path, page]) => [slot, path, page] as const))( + 'refuses a page whose %s names it: 422 INVALID_METADATA, the rule at its slot path, nothing stored', + async (_slot, path, page) => { + const { protocol, rows } = host(); + const err = await savePage(protocol, page).catch((e: any) => e); + expect(err?.status, JSON.stringify(err?.issues ?? err?.message)).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + expect(unknownIssues(err)).toEqual([expect.objectContaining({ + severity: 'error', + path, + message: expect.stringContaining('field "subjcet" is not a field on object "help_ticket"'), + })]); + expect(activePages(rows)).toEqual([]); + }, + ); + + it('CONTROL — the same page on a declared field saves, at every slot', async () => { + const { protocol, rows } = host(); + for (const [slot, , page] of pages('subject')) { + await expect(savePage(protocol, page), slot).resolves.toMatchObject({ success: true }); + } + expect(activePages(rows)).toHaveLength(1); + }); +}); From 647a1c1fda4e3b6426696b10e82370e612a558a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:36:58 +0000 Subject: [PATCH 2/2] docs(lint): say a page sort naming an unknown field is an error refused at the page door validating-metadata.mdx section 5 no longer lists a related list's sort as an advisory page-binding check, and the two code notes on the page crossing now say it also answers existence and provenance. Claude-Session: https://claude.ai/code/session_01Utp6CYvKbGaAgjBLQdkwPB Co-authored-by: Claude --- .changeset/30093-page-sort-unknown-field.md | 2 +- content/docs/deployment/validating-metadata.mdx | 10 +++++++++- packages/lint/src/authoring-rules.ts | 5 +++-- packages/lint/src/reference-integrity-suite.ts | 6 +++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.changeset/30093-page-sort-unknown-field.md b/.changeset/30093-page-sort-unknown-field.md index ae53fc278cd..56ebf8637d1 100644 --- a/.changeset/30093-page-sort-unknown-field.md +++ b/.changeset/30093-page-sort-unknown-field.md @@ -6,7 +6,7 @@ fix(lint)!: a stored page sort naming a field its object lacks is refused by `os Clause-②: no (narrowing) — an unknown sort field on a page slot moves from silent or warning to error: `os validate` / `os build` / `os lint` and the `page` publish door refuse a page sort they accepted before. Metadata-protocol: narrowed -Skills-docs: none — `skills/**` and `content/docs/**` teach no page sort that names a field the object lacks, and nothing there describes an unknown page sort as a warning; the verdict and its fix line name the field, the object and the nearest declared field. +Skills-docs: updated here — content/docs/deployment/validating-metadata.mdx diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 9f9718bd209..221b07c483e 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -163,12 +163,20 @@ component silently skips it and the page renders one item short. Which object a component binds follows `dataSource.object` → `properties.object` → the page's `object`, so a multi-object page is checked per element rather than -against one page-wide guess. A `record:related_list`'s `columns`/`sort`/`filter` +against one page-wide guess. A `record:related_list`'s `columns`/`filter` resolve against its **related** object (`objectName`), and its add-picker against its own. Advisory, like form-layout field references — every consumer degrades rather than failing. The same descriptor table drives the react page surface (§10), so a component is described once and checked wherever it is authored. +A page **sort** is the exception, because no consumer degrades it: the list door +answers an unknown sort field `400 INVALID_SORT` on every load. So every stored +page sort (an element's `dataSource.sort`, a related list's `sort` and +`parentDataSource.sort`, a list block's own `sort`, `interfaceConfig.sort`, an app +page's `record.sort`) naming a field the bound object lacks is a +`page-field-unknown` **error**, and the page publish door refuses the save with +`422 INVALID_METADATA`. + Skipped, to keep false positives at zero: relationship paths (`account.name`, resolved by the query engine), registry-injected system fields (`created_at`, `owner_id`, …), components bound to an object another package defines, and diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 99debed06b4..e70225cd35a 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -1252,8 +1252,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // [#30023] `page` joins, NARROW by the same mechanism: a page write reaches // exactly `validateSortableFields`, the one member that declares `page`, // so a stored page sort naming a field the list door refuses - // (`sortable: false`, a `formula`) is refused at publish rather than at - // its first fetch. Every other member keeps its declaration. + // (`sortable: false`, a `formula`, and since #30093 a field the object + // does not have, as `page-field-unknown`) is refused at publish rather + // than at its first fetch. Every other member keeps its declaration. surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow', 'view', 'object', 'dataset', 'report', 'page'], run: (stack, ctx) => validateReferenceIntegrity(stack, ctx), diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 9d2bfab5171..e9b59c9b36f 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -286,7 +286,11 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // [#30023] `page` joins: since the projection's `author-declared` verdict // the member also walks every stored PAGE sort (element `dataSource.sort`, a // block's own `sort`, `interfaceConfig.sort`, `record.sort`) and judges it - // by the sortability projection alone. It resolves only against + // by the sortability projection. [#30093] It also asks each page sort + // existence (`page-field-unknown`, `error`) and provenance + // (`page-field-unprovisioned`, `warning`), which `validatePageFieldBindings` + // no longer reads, so the page door refuses an unknown page sort field too. + // It resolves only against // `stack.objects`, which the per-write page snapshot carries, so it has no // missing-collection false-positive channel — the #9313 reason, one write // type over. It is the only member that declares `page`, so a page write