Skip to content
Merged
2 changes: 1 addition & 1 deletion .changeset/24563-list-view-out-of-closure.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/lint': minor
---
Expand All @@ -21,7 +21,7 @@
**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.
Expand Down
35 changes: 35 additions & 0 deletions .changeset/24668-overlay-list-view-unknown-object.md
Original file line number Diff line number Diff line change
@@ -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:<app> -- --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.

<!-- adr-0087: not-required (no-migration-prescription) a refusal at the metadata save door of a list view written with no package and bound to an object that exists nowhere in the collection the deployment serves: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored list view with such a binding keeps loading until it is next saved, and the repair is the author's choice of which object to bind (or to save the object first), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not a declaration or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
7 changes: 6 additions & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 21 additions & 2 deletions packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down Expand Up @@ -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[] };
}
Expand Down
11 changes: 6 additions & 5 deletions packages/lint/src/runtime-gate.package-closure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
11 changes: 8 additions & 3 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down
Loading
Loading