From 5fed01d6173c2397f6c868ab689171557620563f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 03:58:04 +0000 Subject: [PATCH 1/7] fix(lint): the overlay save door refuses a list view bound to an object that exists nowhere A runtime write that names no package is judged against the whole collection the host serves, so a list-view binding missing from it exists nowhere. validateListViewFieldRefs now refuses it once, at the binding key (list-view-object-unknown, error) when the reference-integrity suite states the whole collection (a runtime write with no closure in force). The CLI keeps skip 1. Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- packages/lint/src/index.ts | 4 + .../lint/src/reference-integrity-suite.ts | 23 +- .../src/runtime-gate.package-closure.test.ts | 11 +- .../src/runtime-gate.whole-collection.test.ts | 222 ++++++++++++++++++ .../lint/src/validate-list-view-field-refs.ts | 107 ++++++++- 5 files changed, 352 insertions(+), 15 deletions(-) create mode 100644 packages/lint/src/runtime-gate.whole-collection.test.ts diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index e93174a95f4..dffbccc0b32 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -493,11 +493,15 @@ export type { // [#24563] Its THIRD: a list view judged under a package closure whose bound // object is outside that closure — refused (`error`) where skip 1 judged // nothing; `ListViewFieldRefOptions` is how the runtime gate states the closure. +// [#24668] Its FOURTH: a list view judged against the whole collection (a +// runtime write that names no package) whose bound object exists nowhere — +// refused the same way; `wholeCollection` is the statement. export { validateListViewFieldRefs, LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED, LIST_VIEW_OBJECT_OUTSIDE_CLOSURE, + LIST_VIEW_OBJECT_UNKNOWN, } from './validate-list-view-field-refs.js'; export type { ListViewFieldRefFinding, diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 469f7ed6b4f..6ff756eaf8d 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -318,11 +318,29 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // missing-collection argument above is unchanged: the binding is judged // against `stack.objects`, the collection every per-write snapshot carries, // and the gate states the scope only when it narrowed that collection. + // + // [#24668] And the other half of that sentence: a runtime write with NO + // closure in force is judged against the whole collection the host serves + // (`RuntimeStackContext.objects`: registry plus stored rows, unnarrowed), so + // a binding missing from it exists nowhere, and the member refuses it + // (`list-view-object-unknown`, `error`) — stated as `wholeCollection`. + // `runtimeWriteType` is the signal because the gate sets it on every gated + // write and the CLI commands never do, and `packageScope` is absent exactly + // when the gate narrowed nothing (one predicate, `packageScopeInForce`). The + // CLI keeps skip 1: its stack is one app, and an unresolved binding there + // may be a dependency package's object. { name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view', 'object'], run: (stack, ctx) => - validateListViewFieldRefs(stack, ctx?.packageScope ? { packageScope: ctx.packageScope } : undefined), + validateListViewFieldRefs( + stack, + ctx?.packageScope + ? { packageScope: ctx.packageScope } + : ctx?.runtimeWriteType !== undefined + ? { wholeCollection: true } + : undefined, + ), }, // [#15254] The same question, one surface IN: the field-name lists the // OBJECT itself carries (`highlightFields`, `publicSharing.redactFields`), @@ -679,7 +697,8 @@ export interface ReferenceIntegrityRunOptions { * `stack.objects` to — spells the same key as * `AuthoringRuleContext.packageScope`. Present only when a closure was in * force; ABSENT on the CLI commands and on a write that names no package. - * Read by `validateListViewFieldRefs` only. + * Read by `validateListViewFieldRefs` only — which reads its absence beside + * a set `runtimeWriteType` as the whole collection (#24668). */ packageScope?: { readonly packageId: string; readonly dependencies: readonly string[] }; } diff --git a/packages/lint/src/runtime-gate.package-closure.test.ts b/packages/lint/src/runtime-gate.package-closure.test.ts index f1d6796c363..3c0a77bd2a6 100644 --- a/packages/lint/src/runtime-gate.package-closure.test.ts +++ b/packages/lint/src/runtime-gate.package-closure.test.ts @@ -385,16 +385,17 @@ describe('the view door under a package closure (#24563)', () => { expect(scoped(listViewOn('tenant_overlay')).advisories).toEqual([]); }); - it('a write that names no package is unchanged — the whole collection, field verdict and silence alike', () => { + it('a write that names no package is judged against the whole collection — field verdict unchanged, no closure verdict', () => { // ⛔ The overlay door: the stranger's object is in the whole collection, so - // its unknown column is refused exactly as before, and an object that - // exists nowhere stays skip 1's silence (no scope, so no closure verdict). + // its unknown column is refused exactly as before. An object that exists + // nowhere gets no closure verdict (no scope); [#24668] it is refused as + // existing nowhere instead (`runtime-gate.whole-collection.test.ts`). expect(rules(whole(listViewOn('other_0_0')).errors)).toEqual([FIELD_UNKNOWN]); expect(whole(listViewOn('other_0_0')).advisories).toEqual([]); - expect(whole(listViewOn('nosuch_obj'))).toMatchObject({ errors: [], advisories: [] }); + expect(rules(whole(listViewOn('nosuch_obj')).errors)).toEqual(['list-view-object-unknown']); // An empty package id narrows nothing, so it states no closure either. const empty = { packageId: '', dependencies: [] } as RuntimePackageScope; - expect(scoped(listViewOn('nosuch_obj'), empty)).toMatchObject({ errors: [], advisories: [] }); + expect(rules(scoped(listViewOn('nosuch_obj'), empty).errors)).toEqual(['list-view-object-unknown']); }); it('the flow verdict is unchanged, and a stored object\'s out-of-closure list view is not charged to it', () => { diff --git a/packages/lint/src/runtime-gate.whole-collection.test.ts b/packages/lint/src/runtime-gate.whole-collection.test.ts new file mode 100644 index 00000000000..1fbfab95e04 --- /dev/null +++ b/packages/lint/src/runtime-gate.whole-collection.test.ts @@ -0,0 +1,222 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24668 — the VIEW door against the WHOLE collection: a runtime write that + * names no package (an overlay write) of a list view bound to an object that + * exists nowhere is REFUSED `list-view-object-unknown`, once, at the binding. + * + * The overlay twin of #24563 (`runtime-gate.package-closure.test.ts`, the + * closure verdict). With no closure in force the gate hands the rules every + * object the host serves, so a binding missing from it exists nowhere — there + * is no other package for it to come from. What these pins hold: + * + * - the refusal itself, on every rung the rule walks, at the binding key; + * - a binding that resolves keeps its field verdict exactly; + * - the CLI (`os validate` / `os build` / `os lint`) keeps skip 1 — its stack + * is one app, and an unresolved binding may be a dependency's object; + * - an EMPTY collection is no universe, so nothing is judged; + * - a closure wins over the whole-collection statement; + * - the differential charges a write only with its own finding: a stored + * sibling's dangling binding cancels, including when the write creates the + * object nearest to it (the suggestion rides the hint, not the message). + * + * Drafts are not a lint-layer concept: the draft door is pinned where it + * lives, in `packages/metadata-protocol` (`protocol.runtime-authoring-gate.test.ts`). + */ +import { describe, expect, it } from 'vitest'; +import { runAuthoringRules, splitBySeverity } from './authoring-rules.js'; +import { validateReferenceIntegrity } from './reference-integrity-suite.js'; +import { runRuntimeAuthoringRules, type RuntimePackageScope } from './runtime-gate.js'; +import { + LIST_VIEW_FIELD_UNKNOWN, + LIST_VIEW_OBJECT_OUTSIDE_CLOSURE, + LIST_VIEW_OBJECT_UNKNOWN, + validateListViewFieldRefs, +} from './validate-list-view-field-refs.js'; + +type AnyRec = Record; + +const OWN = 'com.acme.crm'; +const STRANGER = 'com.other.analytics'; +const PLATFORM = 'com.objectstack.platform'; + +/** A minimal, spec-shaped object declaration (every one declares `name`). */ +function obj(name: string, packageId?: string, extra: AnyRec = {}): AnyRec { + return { + name, + label: name, + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' } }, + ...(packageId === undefined ? {} : { _packageId: packageId }), + ...extra, + }; +} + +/** The whole collection a host serves: two packages, a platform object, a provenance-less overlay row. */ +const OBJECTS: readonly AnyRec[] = [ + obj('crm_account', OWN), + obj('showcase_task', STRANGER), + obj('sys_audit_log', PLATFORM), + obj('tenant_overlay'), +]; + +/** + * The card's body: a flattened list overlay — the shape `PUT /api/v1/meta/view` + * carries — bound to `object`, one known and by default one unknown column. + */ +function listViewOn(object: string, columns: readonly string[] = ['name', 'zzz_nope']): AnyRec { + return { + name: `${object}.probe_list`, + object, + viewKind: 'list', + label: 'Probe', + type: 'grid', + data: { provider: 'object', object }, + columns: columns.map((field) => ({ field })), + }; +} + +const overlay = (item: AnyRec, objects: readonly AnyRec[] = OBJECTS) => + runRuntimeAuthoringRules({ type: 'view', item, context: { objects } }); +const rules = (fs: readonly AnyRec[]) => fs.map((f) => f.rule); + +describe('the view door against the whole collection (#24668)', () => { + it('REFUSES a binding to an object that exists nowhere — once, at the binding, positions unjudged', () => { + const result = overlay(listViewOn('nosuch_obj')); + + expect(result.advisories).toEqual([]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject({ severity: 'error', rule: LIST_VIEW_OBJECT_UNKNOWN }); + expect(result.errors[0].path).toMatch(/^views(\[0\]|\.nosuch_obj\.probe_list)\.data\.object$/); + expect(result.errors[0].message).toContain('"nosuch_obj"'); + // ⛔ One finding per list view: the unknown column is not judged against + // an object there is no field map for. + expect(rules(result.errors)).not.toContain(LIST_VIEW_FIELD_UNKNOWN); + }); + + it('names the nearest object in the HINT, and leaves the message free of it', () => { + const result = overlay(listViewOn('crm_acount')); + + expect(rules(result.errors)).toEqual([LIST_VIEW_OBJECT_UNKNOWN]); + expect(result.errors[0].hint).toContain('Did you mean "crm_account"?'); + expect(result.errors[0].message).not.toContain('crm_account"'); + }); + + it('refuses on every rung the rule walks, each at the key the binding was read from', () => { + const rungs: Array<[string, AnyRec, RegExp]> = [ + ['container `list`', { name: 'board', object: 'nosuch_obj', list: { type: 'grid', columns: [{ field: 'name' }] } }, /\.objectName$|\.object$/], + ['container `list.data.object`', { name: 'board', object: 'crm_account', list: { type: 'grid', data: { provider: 'object', object: 'nosuch_obj' }, columns: [{ field: 'name' }] } }, /\.list\.data\.object$/], + ['container `listViews.*`', { name: 'board', object: 'crm_account', listViews: { all: { type: 'grid', data: { provider: 'object', object: 'nosuch_obj' }, columns: [{ field: 'name' }] } } }, /\.listViews\.all\.data\.object$/], + ['ViewItem record', { name: 'board', object: 'nosuch_obj', viewKind: 'list', config: { type: 'grid', columns: [{ field: 'name' }] } }, /\.object$/], + ]; + for (const [label, item, path] of rungs) { + const result = overlay(item); + expect(rules(result.errors), label).toEqual([LIST_VIEW_OBJECT_UNKNOWN]); + expect(result.errors[0].path, label).toMatch(path); + expect(result.advisories, label).toEqual([]); + } + }); + + it('refuses an OBJECT write whose built-in list view retargets an object that exists nowhere', () => { + const item = obj('crm_board', undefined, { + listViews: { foreign: { label: 'Foreign', data: { provider: 'object', object: 'nosuch_obj' }, columns: ['name'] } }, + }); + const result = runRuntimeAuthoringRules({ type: 'object', item, context: { objects: OBJECTS } }); + + expect(rules(result.errors)).toEqual([LIST_VIEW_OBJECT_UNKNOWN]); + expect(result.errors[0].path).toMatch(/listViews\.foreign\.data\.object$/); + }); + + it('a binding that RESOLVES keeps its field verdict — package, stranger, platform and overlay objects alike', () => { + for (const bound of ['crm_account', 'showcase_task', 'sys_audit_log', 'tenant_overlay']) { + expect(overlay(listViewOn(bound, ['name'])), bound).toMatchObject({ errors: [], advisories: [] }); + const refused = overlay(listViewOn(bound)); + expect(rules(refused.errors), bound).toEqual([LIST_VIEW_FIELD_UNKNOWN]); + expect(refused.errors[0].path, bound).toMatch(/columns\[1\]\.field$/); + expect(refused.advisories, bound).toEqual([]); + } + }); + + it('an EMPTY collection is no universe: nothing is judged, exactly as before', () => { + expect(overlay(listViewOn('nosuch_obj'), [])).toMatchObject({ errors: [], advisories: [] }); + expect(runRuntimeAuthoringRules({ type: 'view', item: listViewOn('nosuch_obj') })) + .toMatchObject({ errors: [], advisories: [] }); + }); + + it('a closure wins: the same body written into a package is the #24563 verdict, not this one', () => { + const scope: RuntimePackageScope = { packageId: OWN, dependencies: [] }; + const result = runRuntimeAuthoringRules({ type: 'view', item: listViewOn('nosuch_obj'), context: { objects: OBJECTS }, packageScope: scope }); + + expect(rules(result.errors)).toEqual([LIST_VIEW_OBJECT_OUTSIDE_CLOSURE]); + // And at the rule itself, when a caller states both. + expect( + validateListViewFieldRefs( + { objects: OBJECTS, views: [listViewOn('nosuch_obj')] }, + { packageScope: scope, wholeCollection: true }, + ).map((f) => f.rule), + ).toEqual([LIST_VIEW_OBJECT_OUTSIDE_CLOSURE]); + }); + + it('a stored sibling\'s dangling binding is not charged to a flow write', () => { + const dangling = obj('crm_board', OWN, { + listViews: { foreign: { label: 'Foreign', data: { provider: 'object', object: 'nosuch_obj' }, columns: ['name'] } }, + }); + const flow = { + name: 'crm_account_touched', + label: 'Touched', + status: 'active', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'On update', config: { objectName: 'crm_account', triggerType: 'record-after-update' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end', type: 'default', isDefault: false }], + }; + const result = runRuntimeAuthoringRules({ type: 'flow', item: flow, context: { objects: [...OBJECTS, dangling] } }); + + expect(rules([...result.errors, ...result.advisories])).not.toContain(LIST_VIEW_OBJECT_UNKNOWN); + // Non-vacuous: written itself, the sibling IS refused. + expect(rules(runRuntimeAuthoringRules({ type: 'object', item: dangling, context: { objects: OBJECTS } }).errors)) + .toEqual([LIST_VIEW_OBJECT_UNKNOWN]); + }); + + it('…nor to an object write that creates the object NEAREST to it — the suggestion moves, the fingerprint does not', () => { + const dangling = obj('crm_board', undefined, { + listViews: { typo: { label: 'Typo', data: { provider: 'object', object: 'crm_invoce' }, columns: ['name'] } }, + }); + const created = obj('crm_invoice'); + const result = runRuntimeAuthoringRules({ type: 'object', item: created, context: { objects: [...OBJECTS, dangling] } }); + + expect(result.errors).toEqual([]); + expect(rules(result.advisories)).not.toContain(LIST_VIEW_OBJECT_UNKNOWN); + // Non-vacuous: the suggestion really does differ between the two + // universes the differential compares. + const before = validateListViewFieldRefs({ objects: [...OBJECTS, dangling] }, { wholeCollection: true }); + const after = validateListViewFieldRefs({ objects: [...OBJECTS, dangling, created] }, { wholeCollection: true }); + expect(rules(before)).toEqual([LIST_VIEW_OBJECT_UNKNOWN]); + expect(rules(after)).toEqual([LIST_VIEW_OBJECT_UNKNOWN]); + expect(after[0]!.hint).not.toEqual(before[0]!.hint); + expect(after[0]!.message).toEqual(before[0]!.message); + }); +}); + +describe('the CLI reading is unchanged (#24668)', () => { + const stack = { name: 'p', objects: [...OBJECTS], views: [listViewOn('nosuch_obj')] }; + + it('the rule with no statement keeps skip 1', () => { + expect(validateListViewFieldRefs(stack)).toEqual([]); + }); + + it('the suite with no runtime write type keeps skip 1', () => { + expect(rules(validateReferenceIntegrity(stack))).not.toContain(LIST_VIEW_OBJECT_UNKNOWN); + // …and the same stack judged as a runtime `view` snapshot is the verdict above. + expect(rules(validateReferenceIntegrity(stack, { runtimeWriteType: 'view' }))).toContain(LIST_VIEW_OBJECT_UNKNOWN); + }); + + for (const command of ['validate', 'build', 'lint'] as const) { + it(`\`${command}\` reports nothing for the binding`, () => { + const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stack })); + expect(rules([...errors, ...advisories])).not.toContain(LIST_VIEW_OBJECT_UNKNOWN); + }); + } +}); diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index f38af8f0898..2c46439f551 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -210,8 +210,10 @@ * ladder a local "not in this stack ⇒ error" would not have. When the bound * object does not resolve, this rule skips the whole list view (skip 1 * below) so one typo yields one finding rather than one per position. - * ONE exception, and it is this rule's own: a binding judged under a - * package closure (the next section). + * TWO exceptions, both this rule's own and both at the runtime save door, + * where the snapshot's `objects` is a set the door can name: a binding + * judged under a package closure (the next section), and a binding judged + * against the whole collection (the section after it). * * ## [#24563] A binding outside the written package's closure is REFUSED * @@ -248,15 +250,61 @@ * ruling #9612 says it may not reference; the remedy the hint names — * declare the dependency — is what puts the object, and with it the field * judgement, inside the closure. - * - **Only under a stated closure.** The CLI commands and a write that names - * no package (an overlay write, the whole collection) pass no scope and - * keep skip 1 exactly as before: there an unresolved binding may come from + * - **Only under a stated closure.** The CLI commands pass no scope and keep + * skip 1 exactly as before: there an unresolved binding may come from * another package, and `defineStack`'s cross-reference check and - * `validateObjectReferences` own it. + * `validateObjectReferences` own it. A write that names no package passes + * no scope either; it is judged against the whole collection, which is the + * next section. * * Skip 2 is unchanged under a closure: an object that IS in the closure but * declares no readable field map is still unjudgeable, never a finding. * + * ## [#24668] A binding that exists nowhere in the whole collection is REFUSED + * + * A runtime write that names no package (an overlay write, `PUT + * /api/v1/meta/view/:name` with no `?package=`) is judged against the WHOLE + * collection: the host hands the gate every object the deployment serves — + * the registry's (every installed package's, enabled or not, and the + * platform's) folded with the stored rows, the merge `GET /meta/object` + * answers from — and nothing narrows it (`resolveWritePackageScope` answers + * no closure). Skip 1 read an unresolvable binding there as "another + * package's object", which is the CLI's question and not the door's: there is + * no other package for it to come from. So the list view was stored with + * every field reference unjudged — `200`, no advisory — and failed only at + * render, while the same body written into a package is refused + * `list-view-object-outside-closure` and a dataset over an unknown object is + * refused `object-reference-unknown`. + * + * So when the caller states the snapshot is that whole collection + * (`ListViewFieldRefOptions.wholeCollection`, which the reference-integrity + * suite sets for a runtime write with no closure in force), an unresolvable + * binding is reported here, once per list view, at the key the binding was + * read from, as {@link LIST_VIEW_OBJECT_UNKNOWN}: + * + * - **`error`**, so the save door refuses the publish-mode write (`422`) — + * the verdict the two doors above give. The door judges writes only: a + * stored row is judged at its next save, and a draft is never gated. + * - **The field positions stay unjudged**, as under a closure: there is no + * field map to judge them against. + * - **The nearest name is in the hint, never the message.** The gate's + * differential fingerprints `rule`, `where`, `path` and `message`, and the + * suggestion is computed over the snapshot's object names, which an + * `object` write changes by one. In the message, a stored object's list + * view bound to `crm_acount` would read differently in the candidate once + * an unrelated write creates `crm_account`, and that write would be + * refused for a finding it did not make. + * - **⛔ An EMPTY collection is not a whole one.** No deployment serves zero + * objects — the platform's own are always there — so an empty graph means + * the host supplied no universe, and the rule judges nothing, exactly as + * before. + * - **⛔ Never on the CLI.** `os validate` / `os build` / `os lint` do not + * state it: a stack there is one app, and an unresolved binding may be a + * dependency package's object the app's stack does not carry. + * + * A closure wins over the statement when both are given: it is the narrower + * set, and its remedy (declare the dependency) is the one that applies. + * * ## Skips — the same three every field-existence rule in this package takes * * Resolution is {@link resolveFieldPath}'s and its `unknowable` verdicts are @@ -298,6 +346,13 @@ export const LIST_VIEW_FIELD_UNKNOWN = 'list-view-field-unknown'; */ export const LIST_VIEW_OBJECT_OUTSIDE_CLOSURE = 'list-view-object-outside-closure'; +/** + * [#24668] A list view, judged against the whole collection the runtime save + * door serves, whose bound object exists nowhere in it — see the section of + * that name on this module. + */ +export const LIST_VIEW_OBJECT_UNKNOWN = 'list-view-object-unknown'; + /** * [#14282] A list-view field reference written as a DOTTED path at a position * whose name reaches a query door — where the door refuses it by name. See the @@ -336,10 +391,16 @@ export interface ListViewFieldRefFinding { * `packageScope` — [#24563] the package closure the runtime publish gate * narrowed `stack.objects` to (`RuntimePackageScope`), present only when one * was in force. Absent on the CLI commands and on a write that names no - * package, which keep skip 1 as before. + * package. + * + * `wholeCollection` — [#24668] `true` when `stack.objects` is the whole + * collection the deployment serves: the runtime publish gate with no closure + * in force. Absent on the CLI commands, which keep skip 1 as before. Ignored + * when `packageScope` is given. */ export interface ListViewFieldRefOptions { packageScope?: { readonly packageId: string; readonly dependencies: readonly string[] }; + wholeCollection?: boolean; } type AnyRec = Record; @@ -681,8 +742,13 @@ export function validateListViewFieldRefs( const graph: ObjectGraph = indexObjectGraph(stack); const scope = options?.packageScope; + // [#24668] The whole collection, stated — and only without a closure, which + // is the narrower set and wins. + const whole = !scope && options?.wholeCollection === true; // [#24563] Under a stated closure an empty graph is still a verdict: every - // object binding is then outside it, and is refused below. + // object binding is then outside it, and is refused below. [#24668] An empty + // WHOLE collection is not: no deployment serves zero objects, so it means + // the host supplied no universe (see the section of that name). if (graph.size === 0 && !scope) return findings; /** @@ -715,6 +781,28 @@ export function validateListViewFieldRefs( }); }; + /** + * [#24668] One list view whose binding exists nowhere in the whole + * collection — see the section of that name on this module. One finding per + * list view, at the key the binding was read from; the nearest name rides + * the hint so the differential's fingerprint does not move with the + * snapshot's object names. + */ + const reportUnknownObject = (object: string, where: string, path: string): void => { + findings.push({ + severity: 'error', + rule: LIST_VIEW_OBJECT_UNKNOWN, + where, + path, + message: + `binds object "${object}", which exists nowhere in this deployment (no installed package, platform ` + + `object or saved object has that name), so none of the view's field references are checked.`, + hint: + `Bind the view to an object that exists, or save the object "${object}" before you publish the view.` + + suggestName(object, graph.keys()), + }); + }; + /** * Judge ONE written reference at one position. * @@ -820,8 +908,11 @@ export function validateListViewFieldRefs( // [#24563] Except under a stated package closure, where the base object // was dropped by the closure or exists nowhere: refused once, and the // positions stay unjudged (see the section of that name on this module). + // [#24668] And against the stated whole collection, where it exists + // nowhere: refused once the same way. if (!graph.has(object)) { if (scope) reportOutsideClosure(scope, object, where, bindingPath); + else if (whole) reportUnknownObject(object, where, bindingPath); return; } if (!graph.get(object)) return; From deac6d147bb20af04991b7973582694b2cb09731 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:05:18 +0000 Subject: [PATCH 2/7] test(metadata-protocol): pin the overlay save door's whole-collection list-view verdict Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- .../protocol.runtime-authoring-gate.test.ts | 168 +++++++++++++++++- 1 file changed, 164 insertions(+), 4 deletions(-) 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 5b62ceefecd..19f6b4a9e45 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -3629,17 +3629,21 @@ describe('runtime authoring gate on VIEW writes into a package — the closure v } }); - it('(c) a write that names no package is unchanged — the overlay door refuses the stranger column and stays silent on nowhere', async () => { + it('(c) a write that names no package gets no closure verdict — the overlay door refuses the stranger column, and nowhere as nowhere', async () => { const overlay = makePackagedProtocol(); const err = await saveView(overlay.protocol, listView('showcase_task')).catch((e: any) => e); expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); expect(err.issues.map((i: any) => [i.rule, i.path])).toEqual([[FIELD_UNKNOWN, 'views[0].columns[1].field']]); expect(activeViewRows(overlay.rows)).toEqual([]); + // [#24668] No closure, so no closure verdict: the binding is judged + // against the whole collection and refused as existing nowhere (the + // block below pins that door in full). const nowhere = makePackagedProtocol(); - const result = await saveView(nowhere.protocol, listView('nosuch_obj')); - expect(result).toMatchObject({ success: true }); - expect(rulesOf(result.advisories)).toEqual([]); + const refused = await saveView(nowhere.protocol, listView('nosuch_obj')).catch((e: any) => e); + expect({ code: refused?.code, status: refused?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(rulesOf(refused.issues)).toEqual(['list-view-object-unknown']); + expect(activeViewRows(nowhere.rows)).toEqual([]); }); it('(d) the draft → active promotion refuses the out-of-closure binding, and the in-closure unknown field', async () => { @@ -3721,3 +3725,159 @@ describe('runtime authoring gate on VIEW writes into a package — the closure v expect(rulesOf(causal.issues)).toEqual([FIELD_UNKNOWN]); }); }); + +// [#24668] The overlay twin of the block above: a list view written with NO +// package is judged against the WHOLE collection the host serves — every +// registry object (every package's and the platform's) folded with the stored +// rows — so a binding missing from it exists nowhere, and the real +// `saveMetaItem` refuses it `422 INVALID_METADATA` at the binding +// (`list-view-object-unknown`), with nothing stored. The verdict the closure +// door gives the same body (`list-view-object-outside-closure`) and the dataset +// door gives an unknown base object (`object-reference-unknown`). A binding +// that resolves — a package's object, a platform object, a stored overlay +// object — is admitted with its field verdict unchanged. Drafts are not gated; +// a stored row is judged at its next save. +describe('runtime authoring gate on VIEW writes that name no package — the whole-collection verdict (#24668)', () => { + const OWN = 'com.acme.crm'; + const STRANGER = 'com.other.analytics'; + const PLATFORM = 'com.objectstack.platform'; + const UNKNOWN = 'list-view-object-unknown'; + const FIELD_UNKNOWN = 'list-view-field-unknown'; + + const object = (name: string, packageId?: string) => ({ + name, + label: name, + ...(packageId === undefined ? {} : { _packageId: packageId }), + fields: [{ name: 'title', type: 'text', label: 'Title' }], + }); + /** The card's body: a flattened list overlay bound to `bound`, one known and by default one unknown column. */ + const listView = (bound: string, columns: readonly string[] = ['title', 'zzz_nope']) => ({ + name: `${bound}.qavfy_r1`, + object: bound, + viewKind: 'list', + label: 'r1', + type: 'grid', + data: { provider: 'object', object: bound }, + columns: columns.map((field) => ({ field })), + }); + + /** A registry holding two packages' objects and a platform object; `tenant_ticket` lives only in the store. */ + function makeOverlayProtocol() { + const { engine, rows } = makeStubEngine(); + const packages: Record }> = { + [OWN]: { manifest: { name: OWN, version: '1.0.0' } }, + [STRANGER]: { manifest: { name: STRANGER, version: '1.0.0' } }, + }; + engine.registry.listItems = (type: string) => + type === 'object' + ? [object('crm_account', OWN), object('showcase_task', STRANGER), object('sys_widget_log', PLATFORM)] + : []; + engine.registry.getPackage = (id: string) => packages[id]; + // A tenant-authored object, stored and never registered: the gate's + // universe is the registry FOLDED with the store, so it is there. + rows.set(keyOf({ type: 'object', name: 'tenant_ticket', organization_id: null, state: 'active' }), { + id: 'r_obj', type: 'object', name: 'tenant_ticket', organization_id: null, state: 'active', + metadata: JSON.stringify(object('tenant_ticket')), + }); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + return { protocol, rows }; + } + + const saveView = (protocol: any, item: { name: string }, extra: Record = {}) => + protocol.saveMetaItem({ type: 'view', name: item.name, item, ...extra }); + const activeViewRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'view' && r.state === 'active'); + const rulesOf = (findings: any[] | undefined) => (findings ?? []).map((f: any) => f.rule); + /** A refused publish-mode write: the 422 envelope, carrying the one unknown-object issue at the binding. */ + const expectRefusedUnknown = (err: any, what: string) => { + expect(err, `${what}: the write resolved — the overlay door still accepts a binding that exists nowhere`).toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(rulesOf(err.issues), JSON.stringify(err.issues)).toEqual([UNKNOWN]); + expect(err.issues[0]).toMatchObject({ severity: 'error', path: 'views[0].data.object' }); + expect(err.issues[0].message).toContain('"nosuch_obj"'); + }; + + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + it('(a) REFUSES an active overlay save bound to an object that exists nowhere — 422 at the binding, nothing stored', async () => { + const { protocol, rows } = makeOverlayProtocol(); + + const err = await saveView(protocol, listView('nosuch_obj')).catch((e: any) => e); + + expectRefusedUnknown(err, 'overlay write'); + // A gate that refuses after persisting is a log line. + expect(activeViewRows(rows)).toEqual([]); + }); + + it('(a) the control: a binding that resolves is admitted — package, stranger, platform and stored overlay objects', async () => { + for (const bound of ['crm_account', 'showcase_task', 'sys_widget_log', 'tenant_ticket']) { + const { protocol, rows } = makeOverlayProtocol(); + + const result = await saveView(protocol, listView(bound, ['title'])); + + expect(result, bound).toMatchObject({ success: true }); + expect(rulesOf(result.advisories), bound).toEqual([]); + expect(activeViewRows(rows), bound).toHaveLength(1); + } + }); + + it('(b) a binding that resolves keeps its field verdict: an unknown column is refused as before', async () => { + for (const bound of ['crm_account', 'tenant_ticket']) { + const { protocol, rows } = makeOverlayProtocol(); + + const err = await saveView(protocol, listView(bound)).catch((e: any) => e); + + expect({ code: err?.code, status: err?.status }, bound).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(err.issues.map((i: any) => [i.rule, i.path]), bound).toEqual([[FIELD_UNKNOWN, 'views[0].columns[1].field']]); + expect(activeViewRows(rows), bound).toEqual([]); + } + }); + + it('(c) a DRAFT save of the same body is not gated; its promotion is judged and refused', async () => { + const { protocol, rows } = makeOverlayProtocol(); + + await expect(saveView(protocol, listView('nosuch_obj'), { mode: 'draft' })).resolves.toMatchObject({ success: true }); + const promoted = await protocol + .publishMetaItem({ type: 'view', name: 'nosuch_obj.qavfy_r1' }) + .catch((e: any) => e); + + expectRefusedUnknown(promoted, 'promotion'); + expect(activeViewRows(rows)).toEqual([]); + }); + + it('(d) a write naming a package the registry cannot produce narrows nothing — the whole-collection verdict', async () => { + const { protocol, rows } = makeOverlayProtocol(); + + const err = await saveView(protocol, listView('nosuch_obj'), { packageId: 'com.unregistered.pkg' }).catch((e: any) => e); + + expectRefusedUnknown(err, 'unresolvable package'); + expect(activeViewRows(rows)).toEqual([]); + }); + + it('(e) a stored view with the binding keeps loading; its next save is refused, the row untouched', async () => { + const { protocol, rows } = makeOverlayProtocol(); + // Stored before the crossing, seeded straight into the store because the + // save door now refuses this body. + const stored = listView('nosuch_obj', ['title']); + rows.set(keyOf({ type: 'view', name: stored.name, organization_id: null, state: 'active' }), { + id: 'r_seed', type: 'view', name: stored.name, organization_id: null, state: 'active', + metadata: JSON.stringify(stored), + }); + + // The boot path runs no authoring rule. + await expect(protocol.loadMetaFromDb()).resolves.toMatchObject({ errors: 0, invalid: 0 }); + + // The next save is a write, and is judged. + const err = await saveView(protocol, stored).catch((e: any) => e); + expectRefusedUnknown(err, 'next save of a stored row'); + expect(JSON.parse(activeViewRows(rows)[0]!.metadata)).toEqual(stored); + }); +}); From 6f8c25955ef0e02c0bb7a44e4dd056aea408526a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:11:18 +0000 Subject: [PATCH 3/7] test(metadata-protocol): seed the stored overlay object spec-valid Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- .../src/protocol.runtime-authoring-gate.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 19f6b4a9e45..727bab36873 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -3777,7 +3777,12 @@ describe('runtime authoring gate on VIEW writes that name no package — the who // universe is the registry FOLDED with the store, so it is there. rows.set(keyOf({ type: 'object', name: 'tenant_ticket', organization_id: null, state: 'active' }), { id: 'r_obj', type: 'object', name: 'tenant_ticket', organization_id: null, state: 'active', - metadata: JSON.stringify(object('tenant_ticket')), + metadata: JSON.stringify({ + name: 'tenant_ticket', + label: 'Tenant Ticket', + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, + }), }); const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; return { protocol, rows }; @@ -3873,7 +3878,7 @@ describe('runtime authoring gate on VIEW writes that name no package — the who }); // The boot path runs no authoring rule. - await expect(protocol.loadMetaFromDb()).resolves.toMatchObject({ errors: 0, invalid: 0 }); + await expect(protocol.loadMetaFromDb()).resolves.toMatchObject({ loaded: 2, errors: 0, invalid: 0 }); // The next save is a write, and is judged. const err = await saveView(protocol, stored).catch((e: any) => e); From a43b54e0b68a389939bd885a1a92bc0d5d8a2af8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:13:32 +0000 Subject: [PATCH 4/7] test(lint): the view door refuses an overlay's binding outside the live universe once Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- packages/lint/src/runtime-gate.view-writes.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/runtime-gate.view-writes.test.ts b/packages/lint/src/runtime-gate.view-writes.test.ts index ae25afc35c9..e35a999e69a 100644 --- a/packages/lint/src/runtime-gate.view-writes.test.ts +++ b/packages/lint/src/runtime-gate.view-writes.test.ts @@ -123,10 +123,15 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => { expect(errors, JSON.stringify(errors)).toEqual([]); }); - it('an overlay bound to an object outside the live universe is not judged', () => { - // Skip ① — the same cross-package posture every walked surface takes. + it('an overlay bound to an object outside the live universe: its field positions are not judged, its binding is', () => { + // Skip ① for the field axes — one typo is one finding, never one per + // position. [#24668] The binding itself is refused once: with no package + // named, the live universe is the whole collection, so an object missing + // from it exists nowhere (`runtime-gate.whole-collection.test.ts`). const { errors } = gate(overlay({ object: 'pkg_external_case', sort: 'no_such_field' })); - expect(errors, JSON.stringify(errors)).toEqual([]); + expect(errors.map((e) => [e.rule, e.path]), JSON.stringify(errors)).toEqual([ + ['list-view-object-unknown', 'views[0].object'], + ]); }); // ── [#14107] the rest of the same overlay's field surface ── From 39475b911325baf9b024cbae2d1021fbea8163e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:34:02 +0000 Subject: [PATCH 5/7] test(objectql): bind ten overlay-door fixtures to an object their harness registers Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- .../src/delete-meta-response-conformance.test.ts | 3 ++- .../src/environment-overlay-five-types.test.ts | 12 ++++++++++++ .../objectql/src/protocol-commit-history.test.ts | 6 +++++- .../objectql/src/protocol-metadata-row-actor.test.ts | 3 ++- .../src/protocol-publish-canonical-fold.test.ts | 3 ++- .../objectql/src/protocol-recorded-by-null.test.ts | 3 ++- .../objectql/src/protocol-revert-org-scope.test.ts | 3 ++- .../protocol-save-meta-repo-path-real-engine.test.ts | 3 ++- .../src/publish-meta-response-conformance.test.ts | 3 ++- .../src/save-meta-response-conformance.test.ts | 3 ++- 10 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/objectql/src/delete-meta-response-conformance.test.ts b/packages/objectql/src/delete-meta-response-conformance.test.ts index 2f35d6499f6..4501419e7ce 100644 --- a/packages/objectql/src/delete-meta-response-conformance.test.ts +++ b/packages/objectql/src/delete-meta-response-conformance.test.ts @@ -164,8 +164,9 @@ async function makeProtocol() { } // [#7741] the inline arm requires the object binding pair +// [#24668] Bound to `sys_metadata`, an object this harness registers: the overlay save door refuses a list view bound to an object that exists nowhere (it read `case`). const viewBody = (label: string) => ({ - name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list', + name: 'cases', type: 'grid', label, columns: ['id'], object: 'sys_metadata', viewKind: 'list', }); /** Keys the producer emitted that the schema refused to carry through. */ diff --git a/packages/objectql/src/environment-overlay-five-types.test.ts b/packages/objectql/src/environment-overlay-five-types.test.ts index 35fd673f30a..68cae4ebbe9 100644 --- a/packages/objectql/src/environment-overlay-five-types.test.ts +++ b/packages/objectql/src/environment-overlay-five-types.test.ts @@ -135,6 +135,18 @@ function overlayCatalogManifest(): Record { return { id: PKG, name: 'overlay_catalog', + // [#24668] The object the view and the dataset bind to, shipped by the + // package: the overlay save door refuses a list view bound to an object + // that exists nowhere, and `probe_obj` was declared by nothing. + objects: [{ + name: 'probe_obj', + label: 'Probe Object', + sharingModel: 'private', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + amount: { name: 'amount', label: 'Amount', type: 'number' }, + }, + }], views: [{ object: 'probe_obj', listViews: { diff --git a/packages/objectql/src/protocol-commit-history.test.ts b/packages/objectql/src/protocol-commit-history.test.ts index 2d0d4d30b26..a3059186350 100644 --- a/packages/objectql/src/protocol-commit-history.test.ts +++ b/packages/objectql/src/protocol-commit-history.test.ts @@ -912,8 +912,12 @@ describe('#6620 — revertCommit soft-removes a runtime-CREATED `object`', () => ], })]); await seedCreatedObject(protocol, 'myapp_invoice', APP_PKG); + // [#24668] Bound to the object this same build creates: once an object + // exists the save door judges the binding against the whole collection, + // and refuses one that exists nowhere (`gridBody` reads `case`). await protocol.saveMetaItem({ - type: 'view', name: 'myapp_case_grid', packageId: APP_PKG, item: gridBody('Cases'), + type: 'view', name: 'myapp_case_grid', packageId: APP_PKG, + item: { ...gridBody('Cases'), object: 'myapp_invoice', columns: ['id', 'name'] }, }); const res = await protocol.revertCommit({ commitId: 'cmt_new_build' }); diff --git a/packages/objectql/src/protocol-metadata-row-actor.test.ts b/packages/objectql/src/protocol-metadata-row-actor.test.ts index 376fb6a73e1..5222acd5066 100644 --- a/packages/objectql/src/protocol-metadata-row-actor.test.ts +++ b/packages/objectql/src/protocol-metadata-row-actor.test.ts @@ -126,7 +126,8 @@ const BOB = 'usr_bob'; const NAME = 'case_grid'; // [#21470] `name` is the row's: a body `name` that is not its row's is refused by the write doors. -const viewBody = (label: string) => ({ name: NAME, type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); +// [#24668] Bound to `sys_metadata`, an object this harness registers: the overlay save door refuses a list view bound to an object that exists nowhere (it read `case`). +const viewBody = (label: string) => ({ name: NAME, type: 'grid', label, columns: ['id'], object: 'sys_metadata', viewKind: 'list' }); describe('#23033 — the sys_metadata row records who created it and who last changed it', () => { let kernel: ObjectKernel; diff --git a/packages/objectql/src/protocol-publish-canonical-fold.test.ts b/packages/objectql/src/protocol-publish-canonical-fold.test.ts index 2b89868b762..d0ea71a2e9b 100644 --- a/packages/objectql/src/protocol-publish-canonical-fold.test.ts +++ b/packages/objectql/src/protocol-publish-canonical-fold.test.ts @@ -271,8 +271,9 @@ async function refusalOf(fn: () => Promise): Promise<{ code?: string; s const translationBody = (label: string) => ({ locale: 'zh-CN', name: 'zh_cn', label }); /** [#7741] the inline arm requires the object binding pair. */ +// [#24668] Bound to `sys_metadata`, an object this harness registers: the overlay save door refuses a list view bound to an object that exists nowhere (it read `case`). const viewBody = (label: string) => ({ - name: 'case_grid', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list', + name: 'case_grid', type: 'grid', label, columns: ['id'], object: 'sys_metadata', viewKind: 'list', }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/objectql/src/protocol-recorded-by-null.test.ts b/packages/objectql/src/protocol-recorded-by-null.test.ts index 1825aabef4e..e030e2494cf 100644 --- a/packages/objectql/src/protocol-recorded-by-null.test.ts +++ b/packages/objectql/src/protocol-recorded-by-null.test.ts @@ -187,7 +187,8 @@ function makeStubDriver() { } // [#21470] `name` is the row's: a body `name` that is not its row's is refused by the write doors. -const viewBody = (label: string, name = 'cases') => ({ name, type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair +// [#24668] Bound to `sys_metadata`, an object this harness registers: the overlay save door refuses a list view bound to an object that exists nowhere (it read `case`). +const viewBody = (label: string, name = 'cases') => ({ name, type: 'grid', label, columns: ['id'], object: 'sys_metadata', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair describe('#4556 — protocol write paths store NULL, not the sentinel string', () => { let engine: ObjectQL; diff --git a/packages/objectql/src/protocol-revert-org-scope.test.ts b/packages/objectql/src/protocol-revert-org-scope.test.ts index 589439a226d..97ed0b4de71 100644 --- a/packages/objectql/src/protocol-revert-org-scope.test.ts +++ b/packages/objectql/src/protocol-revert-org-scope.test.ts @@ -192,7 +192,8 @@ function makeStubDriver() { } const PKG = 'app.revertscope'; -const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair +// [#24668] Bound to `sys_metadata`, an object this harness registers: the overlay save door refuses a list view bound to an object that exists nowhere (it read `case`). +const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'sys_metadata', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair describe('#7559 — the revert reads the history row under the key the writer stored it with', () => { let engine: ObjectQL; diff --git a/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts b/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts index 3886a5f08e5..80c33d14ad3 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts @@ -138,7 +138,8 @@ function makeStubDriver() { } function viewBody(label: string) { - return { name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }; // [#7741] the inline arm requires the object binding pair + // [#24668] Bound to `sys_metadata`, an object this harness registers: the overlay save door refuses a list view bound to an object that exists nowhere (it read `case`). + return { name: 'cases', type: 'grid', label, columns: ['id'], object: 'sys_metadata', viewKind: 'list' }; // [#7741] the inline arm requires the object binding pair } describe('saveMetaItem — repository write path against real ObjectQL (PR-10d.4)', () => { diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts index c3f4d1d933f..11e3885544f 100644 --- a/packages/objectql/src/publish-meta-response-conformance.test.ts +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -163,7 +163,8 @@ async function makeProtocol() { return new ObjectStackProtocolImplementation(engine); } -const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair +// [#24668] Bound to `sys_metadata`, an object this harness registers: the overlay save door refuses a list view bound to an object that exists nowhere (it read `case`). +const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'sys_metadata', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair /** * A `seed` body, which is what makes `seedApplied` appear on the response. It diff --git a/packages/objectql/src/save-meta-response-conformance.test.ts b/packages/objectql/src/save-meta-response-conformance.test.ts index 5c78c653507..52e6b02041b 100644 --- a/packages/objectql/src/save-meta-response-conformance.test.ts +++ b/packages/objectql/src/save-meta-response-conformance.test.ts @@ -168,7 +168,8 @@ async function makeProtocol() { return new ObjectStackProtocolImplementation(engine); } -const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair +// [#24668] Bound to `sys_metadata`, an object this harness registers: the overlay save door refuses a list view bound to an object that exists nowhere (it read `case`). +const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'sys_metadata', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair /** Keys the producer emitted that the schema refused to carry through. */ function strippedKeys(raw: Record): string[] { From 54fb13a74358ac5744c0792a093437eb03ee8f65 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 04:58:44 +0000 Subject: [PATCH 6/7] test(rest): declare the object three overlay-door fixtures bind their view to Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- .../rest/src/meta-diff-default-range-labels.test.ts | 11 +++++++++++ packages/rest/src/meta-view-chart-binding.test.ts | 11 +++++++++++ .../rest/src/meta-view-overlay-options-bag.test.ts | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/packages/rest/src/meta-diff-default-range-labels.test.ts b/packages/rest/src/meta-diff-default-range-labels.test.ts index 3dc3a41c020..3fa372b151c 100644 --- a/packages/rest/src/meta-diff-default-range-labels.test.ts +++ b/packages/rest/src/meta-diff-default-range-labels.test.ts @@ -98,6 +98,17 @@ async function boot() { engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + // [#24668] The object the fixture view binds to: the overlay save door + // refuses a list view bound to an object that exists nowhere, and nothing + // here declared `lead`. + engine.registry.registerObject({ + name: 'lead', + label: 'Lead', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + owner: { name: 'owner', label: 'Owner', type: 'text' }, + }, + } as any, TEST_PACKAGE_ID); await engine.syncSchemas(); const protocol = new ObjectStackProtocolImplementation(engine as any); diff --git a/packages/rest/src/meta-view-chart-binding.test.ts b/packages/rest/src/meta-view-chart-binding.test.ts index b0086f4e0b3..39db8ac2c86 100644 --- a/packages/rest/src/meta-view-chart-binding.test.ts +++ b/packages/rest/src/meta-view-chart-binding.test.ts @@ -73,6 +73,17 @@ async function boot() { scope: 'system', objects: [SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject], }); + // [#24668] The object the fixture view binds to: the overlay save door + // refuses a list view bound to an object that exists nowhere, and nothing + // here declared `crm_lead`. + engine.registry.registerObject({ + name: 'crm_lead', + label: 'Lead', + fields: { + stage: { name: 'stage', label: 'Stage', type: 'text' }, + amount: { name: 'amount', label: 'Amount', type: 'number' }, + }, + } as any, 'objectstack-test'); await engine.syncSchemas(); const protocol = new ObjectStackProtocolImplementation(engine as any); diff --git a/packages/rest/src/meta-view-overlay-options-bag.test.ts b/packages/rest/src/meta-view-overlay-options-bag.test.ts index 9da29752fcc..e24559d2aed 100644 --- a/packages/rest/src/meta-view-overlay-options-bag.test.ts +++ b/packages/rest/src/meta-view-overlay-options-bag.test.ts @@ -73,6 +73,17 @@ async function boot() { scope: 'system', objects: [SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject], }); + // [#24668] The object the fixture view binds to: the overlay save door + // refuses a list view bound to an object that exists nowhere, and nothing + // here declared `showcase_task`. + engine.registry.registerObject({ + name: 'showcase_task', + label: 'Task', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + location: { name: 'location', label: 'Location', type: 'text' }, + }, + } as any, 'objectstack-test'); await engine.syncSchemas(); const protocol = new ObjectStackProtocolImplementation(engine as any); From 70d746c34ade6a0bedaf11b4d700340db2164c3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:17:20 +0000 Subject: [PATCH 7/7] docs(lint): the changeset and the validating-metadata sentence for the whole-collection list-view verdict Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- .changeset/24563-list-view-out-of-closure.md | 2 +- .../24668-overlay-list-view-unknown-object.md | 35 +++++++++++++++++++ .../docs/deployment/validating-metadata.mdx | 7 +++- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 .changeset/24668-overlay-list-view-unknown-object.md diff --git a/.changeset/24563-list-view-out-of-closure.md b/.changeset/24563-list-view-out-of-closure.md index ae959890d06..5b2cdcb8934 100644 --- a/.changeset/24563-list-view-out-of-closure.md +++ b/.changeset/24563-list-view-out-of-closure.md @@ -21,7 +21,7 @@ Clause-②: yes (narrowing) **Unchanged.** - A list view bound to an object inside the closure (the package's own, a declared dependency's, a platform/system object, an overlay row): same field verdicts, same refusals. -- A write that names no package (an overlay write) is judged against the whole collection, exactly as before. An object that exists nowhere stays unjudged there. +- A write that names no package (an overlay write) is judged against the whole collection, exactly as before. An object that exists nowhere gets no closure verdict there; #24668 refuses it as `list-view-object-unknown` (see that changeset). - The flow door: `flow-trigger-unknown-object` stays an advisory. - `os validate`, `os build` and `os lint`: no closure is stated, so the rule skips an unresolvable binding as before. - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. diff --git a/.changeset/24668-overlay-list-view-unknown-object.md b/.changeset/24668-overlay-list-view-unknown-object.md new file mode 100644 index 00000000000..f1c28ef479a --- /dev/null +++ b/.changeset/24668-overlay-list-view-unknown-object.md @@ -0,0 +1,35 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: the save door refuses a list view written with no package and bound to an object that exists nowhere (#24668) + +Clause-②: yes (narrowing) + +**Why.** A runtime write that names no package (an overlay write) is judged against the whole collection the deployment serves: every installed package's objects, the platform's, and the stored rows. Nothing narrows it. The list-view field rule still read a binding missing from that collection as "another package's object", which is the CLI's question, and skipped the whole list view. So a list view bound to a misspelled or deleted object was stored with every field reference unjudged: `200`, no advisory. It then failed only at render, because the data door answers `404 OBJECT_NOT_FOUND` for the object. The same body written into a package is refused `list-view-object-outside-closure`, and a dataset over an unknown object is refused `object-reference-unknown`. + +**What is refused.** A list view judged against the whole collection whose bound object exists nowhere in it is refused with one `list-view-object-unknown` issue. The issue is located at the key the binding is read from: `views[0].data.object` for a standalone list view, otherwise the container's `objectName` / `object`. An object whose built-in `listViews` entry retargets `data.object` to an object that exists nowhere is refused the same way. The list view's field references stay unjudged, because there is no field map to judge them against. The hint names the nearest existing object when one is close. + +**BREAKING — what moves for consumers.** This is an accept-set narrowing on the metadata save door, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is in `exit` mode, so the next version pass cuts 18.0.0 and this change ships in it). + +- FROM: a publish-mode write that names no package, of a list view bound to an object that exists nowhere, answered `200` and was stored, with no issue. This covers `PUT /api/v1/meta/view/:name` with no `?package=` (and `saveMetaItem` in publish mode), the promotion of such a draft (`POST /api/v1/meta/view/:name/publish`, `publishMetaItem`), and a write naming a package the registry cannot produce, which narrows nothing. +- TO: the same writes answer `422 INVALID_METADATA`, with the `list-view-object-unknown` issue at the binding, and nothing is stored. +- Fix: bind the view to an object that exists (the hint names the nearest one), or save the object first, then save the view again. Once the binding resolves, its fields are judged as before: an unknown `columns` field is refused `422 list-view-field-unknown`. Saving the view as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + +**The next save of a stored list view bound to an object that exists nowhere is refused with the fix; stored views keep serving until then.** The door judges writes only. A stored row is not re-judged at boot, and it loads and is served as before. + +**Unchanged.** + +- A list view bound to an object that exists (an installed package's, a platform object, a stored overlay object): same field verdicts, same refusals. +- A write into a package is judged against its closure: `list-view-object-outside-closure` as before. When both could apply, the closure verdict wins. +- `os validate`, `os build` and `os lint`: the rule skips an unresolvable binding as before. There a stack is one app, and an unresolved binding may be a dependency package's object. +- A host that supplies no object collection at all: nothing is judged, as before. +- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. +- Measured before crossing, through the booted door (`pnpm dev: -- --fresh`): the 49 views shipped in `examples/app-crm` (10), `app-showcase` (36) and `app-todo` (3), and the built-in list views of every object each stack serves, bind no object that exists nowhere. A probe list view bound to each object those stacks serve (82 in the CRM stack, one of them an overlay object saved with no package, then 109 and 76) was admitted every time. `examples/app-multi-package` ships no view. Deployed rows were not measured. + +**API.** + +- `validateListViewFieldRefs(stack, options?)` accepts `wholeCollection: true` in `ListViewFieldRefOptions`. The reference-integrity suite sets it for a runtime write with no package closure in force; `runAuthoringRules` never does. +- `LIST_VIEW_OBJECT_UNKNOWN` (`'list-view-object-unknown'`) is exported. + + diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 615d027c852..4165c1b8148 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -713,7 +713,12 @@ That changes what a rule can **resolve**, never which rules run, and nothing at this door walks `packages[]`. One rule is told the closure is in force: a list view bound to an object outside it is refused with `list-view-object-outside-closure` instead of having its field references -skipped (#24563). So "runs per package" names one thing at the three +skipped (#24563). With no closure in force — a write that names no package — +the context is the whole collection the deployment serves, so the same rule +refuses a list view bound to an object that exists nowhere in it with +`list-view-object-unknown` (#24668); the three CLI doors keep skipping such a +binding, because there it may be a dependency package's object. So "runs per +package" names one thing at the three CLI doors and another one here, and the `runtime publish` column says neither. A clean run walks the registry and reports timing: