Skip to content

Commit a0c24ab

Browse files
committed
fix(lint)!: refuse a package's list view bound outside its closure at the save door (#24563 patch round 1)
list-view-object-outside-closure is an error: a publish-mode write of a list view into a package whose bound object is outside the package's closure (or nowhere) is refused 422 INVALID_METADATA at the binding, with the declare-the-dependency hint. Both pin layers flip to the refusal; the in-closure, declared-dependency, platform-object, overlay and flow controls are unchanged. Changeset: minor, !, Clause-② yes (narrowing), FROM/TO/Fix, ADR-0087 not-required (no-migration-prescription). Fixtures that bound a package's view outside its closure are re-trued to an object the package may reach, assertions unchanged: the objectql publish-drafts conformance view (a platform object), the objectql duplicatePackage names-in-step source package (declares the third package it references), and the dogfood cross-package default container (the Studio-created package declares the showcase). Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a295d8 commit a0c24ab

11 files changed

Lines changed: 158 additions & 82 deletions

‎.changeset/24563-list-view-out-of-closure.md‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,35 @@
22
'@objectstack/lint': minor
33
---
44

5-
fix(lint): a list view written into a package and bound outside that package's closure is reported at the save door, not skipped (#24563)
5+
fix(lint)!: the save door refuses a list view written into a package and bound outside that package's closure (#24563)
66

7-
Clause-②: yes
7+
Clause-②: yes (narrowing)
88

9-
**Why.** The runtime save door judges a write into a package against that package's closure (ruling #9612): the package's own objects, the objects of the packages its manifest declares in `dependencies`, platform/system objects, and provenance-less overlay rows. Every other object is dropped from the snapshot before any rule runs. The list-view field rule read a binding missing from that snapshot as "an object this stack does not define" and skipped the whole list view. So a list view written into a package and bound to an object of a package it never declared (or to an object that exists nowhere) was stored with every field reference unjudged: `200`, no advisory, while the same body as an overlay write is refused `422 list-view-field-unknown` for an unknown `columns` field. No other rule judged the binding on a `view` write.
9+
**Why.** The runtime save door judges a write into a package against that package's closure (ruling #9612). The closure is the package's own objects, the objects of the packages its manifest declares in `dependencies`, platform/system objects, and provenance-less overlay rows; every other object is dropped from the snapshot before any rule runs. The list-view field rule read a binding missing from that snapshot as "an object this stack does not define" and skipped the whole list view. So a list view written into a package and bound to an object of a package it never declared, or to an object that exists nowhere, was stored with every field reference unjudged: `200`, no advisory. The same body as an overlay write is refused `422 list-view-field-unknown` for an unknown `columns` field. No other rule judged the binding on a `view` write, and such a view then fails at render ("This view's query was rejected").
1010

11-
**What changes.** At the runtime save door (Studio, REST `/meta`, MCP), a list view written into a package whose bound object is outside the package's closure now draws one `list-view-object-outside-closure` warning, located at the key the binding is read from (`views[0].data.object` for a standalone list view). It rides the save response's `advisories` and the `[Protocol] authoring advisory` log line. This covers a publish-mode save (`PUT /api/v1/meta/view/:name?package=…`, `saveMetaItem`), the promotion of a draft (`publishMetaItem`), and a package draft publish (`publishPackageDrafts`, on the published item's `advisories`). An object that exists nowhere draws the same warning. It is the verdict a flow's out-of-closure trigger object already gets at the same door (`flow-trigger-unknown-object`, `warning`): reported, not refused. The write is still stored, and the list view's field references stay unjudged until the binding is inside the closure. An object written into a package whose built-in `listViews` entry retargets `data.object` outside the closure draws the same warning.
11+
**What is refused.** A list view written into a package whose bound object is outside the package's closure is refused with one `list-view-object-outside-closure` 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`. The hint names the remedy. An object that exists nowhere is refused the same way. This is the verdict the dataset door already gives an out-of-closure base object under the same closure (`object-reference-unknown`). An object written into a package whose built-in `listViews` entry retargets `data.object` outside the closure is refused the same way. The list view's field references stay unjudged until the binding is inside the closure.
1212

13-
- Fix: declare a dependency on the package that defines the bound object in the `dependencies` of the writing package's manifest, or bind the view to an object inside the closure. Once the object is inside, its fields are judged: an unknown `columns` field is refused `422 list-view-field-unknown`, as at the overlay door.
13+
**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 open in `next` pre mode).
14+
15+
- FROM: a publish-mode write of a list view into a package, bound outside the package's closure, answered `200` and was stored, with no issue. This covers `PUT /api/v1/meta/view/:name?package=…` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/view/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`).
16+
- TO: it answers `422 INVALID_METADATA`, with the `list-view-object-outside-closure` issue at the binding, and nothing is stored. A package draft publish fails the item, and the batch with it.
17+
- Fix: declare a dependency on the package that defines the bound object in the `dependencies` of the writing package's manifest, or bind the view to an object the package may reach (its own, a declared dependency's, or a platform object). Then save again. Once the object is inside the closure its fields are judged: an unknown `columns` field is refused `422 list-view-field-unknown`, as at the overlay door. Saving the view as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.
18+
19+
**The next save of a stored list view bound outside its package's closure 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.
1420

1521
**Unchanged.**
1622

1723
- 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.
18-
- A write that names no package (an overlay write): judged against the whole collection, exactly as before. An object that exists nowhere stays silent there.
24+
- 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.
25+
- The flow door: `flow-trigger-unknown-object` stays an advisory.
1926
- `os validate`, `os build` and `os lint`: no closure is stated, so the rule skips an unresolvable binding as before.
20-
- Drafts are not gated.
21-
- Measured before the change: the 11 views shipped in this repository (`examples/app-crm`, `app-todo`, `app-showcase`, `app-multi-package`, the `create-objectstack` blank template, `qa/downstream-contract`) carry 39 list-view bindings. None is outside its package's closure. The door's `view` rule set finds nothing in any of them, with the package's closure or without it, before or after this change. None of the 97 platform built-in list views retargets `data.object`. Deployed rows were not measured.
27+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
28+
- Measured before crossing: the 11 views shipped in this repository carry 39 list-view bindings, in `examples/app-crm`, `app-todo`, `app-showcase` and `app-multi-package`, the `create-objectstack` blank template, and `qa/downstream-contract`. None is outside its package's closure, and the door's `view` rule set refuses none of them, with the package's closure or without it. None of the 97 platform built-in list views retargets `data.object`. Deployed rows were not measured.
2229

2330
**API.**
2431

2532
- `AuthoringRuleContext` gains an optional `packageScope` (`{ packageId, dependencies }`). Only the runtime publish gate sets it, and only when it narrowed the snapshot's objects to a package closure. `runAuthoringRules` never sets it.
2633
- `validateListViewFieldRefs(stack, options?)` accepts an optional `{ packageScope }` (`ListViewFieldRefOptions`, exported).
2734
- `LIST_VIEW_OBJECT_OUTSIDE_CLOSURE` (`'list-view-object-outside-closure'`) is exported.
35+
36+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the metadata save door of a list view written into a package and bound to an object outside that package's dependency closure: 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 package to declare as a dependency (or which object to bind), 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). -->

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,7 +711,7 @@ dependencies, and unconditionally both platform/system objects and rows carrying
711711
no package provenance (#9612); a provenance it cannot read narrows nothing at all.
712712
That changes what a rule can **resolve**, never which rules run, and nothing at
713713
this door walks `packages[]`. One rule is told the closure is in force: a list
714-
view bound to an object outside it draws the advisory
714+
view bound to an object outside it is refused with
715715
`list-view-object-outside-closure` instead of having its field references
716716
skipped (#24563). So "runs per package" names one thing at the three
717717
CLI doors and another one here, and the `runtime publish` column says neither.

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ export interface AuthoringRuleContext {
385385
* "not in this stack": an object missing from a narrowed snapshot is the
386386
* first, one missing from a whole one may come from another package. Read
387387
* by `validateListViewFieldRefs` only (through the reference-integrity
388-
* suite), which reports a list view bound outside the closure instead of
388+
* suite), which refuses a list view bound outside the closure instead of
389389
* skipping it.
390390
*/
391391
packageScope?: { readonly packageId: string; readonly dependencies: readonly string[] };

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ export type {
491491
// compiled filter), where that door refuses it by name — the loud-failing half
492492
// #14107 recorded and left open.
493493
// [#24563] Its THIRD: a list view judged under a package closure whose bound
494-
// object is outside that closure — reported (advisory) where skip 1 judged
494+
// object is outside that closure — refused (`error`) where skip 1 judged
495495
// nothing; `ListViewFieldRefOptions` is how the runtime gate states the closure.
496496
export {
497497
validateListViewFieldRefs,

‎packages/lint/src/reference-integrity-suite.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,10 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
311311
// closure the snapshot's `objects` are that closure (ruling #9612), so a
312312
// binding missing from it is outside what the package declared it may
313313
// reference, or nowhere at all — and the member's skip 1 used to read that as
314-
// "another package's object" and judge nothing. It now reports the binding
315-
// instead (`list-view-object-outside-closure`, `warning`: the verdict a
316-
// flow's out-of-closure trigger object gets at the same door). The
314+
// "another package's object" and judge nothing. It now refuses the binding
315+
// instead (`list-view-object-outside-closure`, `error`: the verdict the
316+
// dataset door gives an out-of-closure base object under the same closure;
317+
// the flow door's trigger-object advisory is a separate question). The
317318
// missing-collection argument above is unchanged: the binding is judged
318319
// against `stack.objects`, the collection every per-write snapshot carries,
319320
// and the gate states the scope only when it narrowed that collection.

‎packages/lint/src/runtime-gate.package-closure.test.ts‎

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -314,48 +314,49 @@ describe('the view door under a package closure (#24563)', () => {
314314
const whole = (item: AnyRec) => runRuntimeAuthoringRules({ type: 'view', item, context: { objects } });
315315
const rules = (fs: AnyRec[]) => fs.map((f) => f.rule);
316316

317-
it('REPORTS a binding into a package the written one never declared — advisory, the flow verdict', () => {
317+
it('REFUSES a binding into a package the written one never declared — at the binding, with the remedy', () => {
318318
const result = scoped(listViewOn('other_0_0'));
319319

320-
// The verdict a flow's out-of-closure trigger object gets at this door
321-
// (`flow-trigger-unknown-object`, pinned above): reported, not refused.
322-
expect(result.errors).toEqual([]);
323-
expect(result.advisories).toHaveLength(1);
324-
expect(result.advisories[0]).toMatchObject({ severity: 'warning', rule: OUTSIDE });
325-
expect(result.advisories[0].path).toMatch(/^views(\[0\]|\.other_0_0\.probe_list)\.data\.object$/);
320+
// An `error`, so the door refuses the write: the dataset door's verdict
321+
// for an out-of-closure base object under the same closure. The flow
322+
// door's `flow-trigger-unknown-object` (pinned above) stays advisory.
323+
expect(result.advisories).toEqual([]);
324+
expect(result.errors).toHaveLength(1);
325+
expect(result.errors[0]).toMatchObject({ severity: 'error', rule: OUTSIDE });
326+
expect(result.errors[0].path).toMatch(/^views(\[0\]|\.other_0_0\.probe_list)\.data\.object$/);
326327
// The fix names the remedy and the package it applies to.
327-
expect(result.advisories[0].hint).toContain(OWN);
328-
expect(result.advisories[0].hint).toContain('dependencies');
328+
expect(result.errors[0].hint).toContain(OWN);
329+
expect(result.errors[0].hint).toContain('dependencies');
329330
// ⛔ The field positions stay unjudged: judging them against the whole
330331
// collection would judge the package against what ruling #9612 says it may
331332
// not reference.
332-
expect(rules(result.advisories)).not.toContain(FIELD_UNKNOWN);
333+
expect(rules(result.errors)).not.toContain(FIELD_UNKNOWN);
333334
});
334335

335-
it('REPORTS a binding to an object that exists nowhere the same way', () => {
336+
it('REFUSES a binding to an object that exists nowhere the same way', () => {
336337
const result = scoped(listViewOn('nosuch_obj'));
337338

338-
expect(result.errors).toEqual([]);
339-
expect(rules(result.advisories)).toEqual([OUTSIDE]);
339+
expect(result.advisories).toEqual([]);
340+
expect(rules(result.errors)).toEqual([OUTSIDE]);
340341
});
341342

342-
it('REPORTS it under a closure that carries no object at all — an empty graph is still a verdict', () => {
343+
it('REFUSES it under a closure that carries no object at all — an empty graph is still a verdict', () => {
343344
const result = runRuntimeAuthoringRules({
344345
type: 'view',
345346
item: listViewOn('nosuch_obj'),
346347
context: { objects: [] },
347348
packageScope: SCOPE,
348349
});
349350

350-
expect(rules(result.advisories)).toEqual([OUTSIDE]);
351+
expect(rules(result.errors)).toEqual([OUTSIDE]);
351352
});
352353

353354
it('REFUSES an unknown column on an object of the written package — the field verdict is unchanged', () => {
354355
const result = scoped(listViewOn('crm_account'));
355356

356357
expect(rules(result.errors)).toEqual([FIELD_UNKNOWN]);
357358
expect(result.errors[0].path).toMatch(/columns\[1\]\.field$/);
358-
expect(rules(result.advisories)).not.toContain(OUTSIDE);
359+
expect(result.advisories).toEqual([]);
359360
});
360361

361362
it('admits a known column on an object of the written package', () => {
@@ -366,8 +367,8 @@ describe('the view door under a package closure (#24563)', () => {
366367
});
367368

368369
it('keeps a DECLARED DEPENDENCY inside the closure — known column admitted, unknown refused', () => {
369-
// The discriminating case: a report here would mean the dependency limb
370-
// never reached the rule.
370+
// The discriminating case: a refusal of the binding here would mean the
371+
// dependency limb never reached the rule.
371372
expect(scoped(listViewOn('core_user', ['name']))).toMatchObject({ errors: [], advisories: [] });
372373
expect(rules(scoped(listViewOn('core_user')).errors)).toEqual([FIELD_UNKNOWN]);
373374
});
@@ -381,7 +382,7 @@ describe('the view door under a package closure (#24563)', () => {
381382

382383
it('keeps a provenance-less overlay object inside the closure — its field verdict is unchanged', () => {
383384
expect(rules(scoped(listViewOn('tenant_overlay')).errors)).toEqual([FIELD_UNKNOWN]);
384-
expect(rules(scoped(listViewOn('tenant_overlay')).advisories)).not.toContain(OUTSIDE);
385+
expect(scoped(listViewOn('tenant_overlay')).advisories).toEqual([]);
385386
});
386387

387388
it('a write that names no package is unchanged — the whole collection, field verdict and silence alike', () => {
@@ -398,21 +399,21 @@ describe('the view door under a package closure (#24563)', () => {
398399

399400
it('the flow verdict is unchanged, and a stored object\'s out-of-closure list view is not charged to it', () => {
400401
// A context object of the written package whose built-in list view
401-
// retargets an object outside the closure: the rule now reports that list
402+
// retargets an object outside the closure: the rule now refuses that list
402403
// view, in the baseline and the candidate alike, so it cancels — a flow
403-
// publish is not charged with a stored sibling's binding (#4463 D4).
404+
// publish is not refused for a stored sibling's binding (#4463 D4).
404405
const retargeting = obj('crm_board', OWN, {
405406
listViews: { foreign: { label: 'Foreign', data: { provider: 'object', object: 'other_0_0' }, columns: ['name'] } },
406407
});
407408
const withSibling = [...objects, retargeting];
408409
const flow = (item: AnyRec) =>
409410
verdict(runRuntimeAuthoringRules({ type: 'flow', item, context: { objects: withSibling }, packageScope: SCOPE }));
410411

411-
// Non-vacuous: written itself, that object IS reported — the list view
412-
// rides the object door too, at the same advisory.
412+
// Non-vacuous: written itself, that object IS refused — the list view
413+
// rides the object door too, at the same verdict.
413414
const asWritten = runRuntimeAuthoringRules({ type: 'object', item: retargeting, context: { objects }, packageScope: SCOPE });
414-
expect(asWritten.errors).toEqual([]);
415-
expect(rules(asWritten.advisories)).toContain(OUTSIDE);
415+
expect(rules(asWritten.errors)).toContain(OUTSIDE);
416+
expect(rules(asWritten.advisories)).not.toContain(OUTSIDE);
416417

417418
expect(flow(FLOW_INTO_OWN_PACKAGE)).toEqual([]);
418419
const undeclared = flow(FLOW_INTO_UNDECLARED);

0 commit comments

Comments
 (0)