Skip to content
Merged
36 changes: 36 additions & 0 deletions .changeset/24563-list-view-out-of-closure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@objectstack/lint': minor
---

fix(lint)!: the save door refuses a list view written into a package and bound outside that package's closure (#24563)

Clause-②: yes (narrowing)

**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").

**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.

**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 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`), a package draft publish (`publishPackageDrafts`), and a package copy (`duplicatePackage`), which writes its copies through the same door.
- TO: `saveMetaItem` in publish mode, the draft promotion and `publishPackageDrafts` answer `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. In `duplicatePackage`, a copied list view whose bound object stays outside the copy package's closure is refused with the same rule and listed in its `failed[]`. One way that happens is when that object's own copy was refused.
- 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.

**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.

**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.
- 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.
- 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.

**API.**

- `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.
- `validateListViewFieldRefs(stack, options?)` accepts an optional `{ packageScope }` (`ListViewFieldRefOptions`, exported).
- `LIST_VIEW_OBJECT_OUTSIDE_CLOSURE` (`'list-view-object-outside-closure'`) is exported.

<!-- 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). -->
7 changes: 5 additions & 2 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -709,8 +709,11 @@ findings the item **added** (#4463). Its package dimension narrows that context
the written item's **package closure** — the written package, its declared
dependencies, and unconditionally both platform/system objects and rows carrying
no package provenance (#9612); a provenance it cannot read narrows nothing at all.
That changes what a rule can **resolve**, never what it judges, and nothing at
this door walks `packages[]`. So "runs per package" names one thing at the three
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
CLI doors and another one here, and the `runtime publish` column says neither.

A clean run walks the registry and reports timing:
Expand Down
17 changes: 17 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,23 @@ export interface AuthoringRuleContext {
* Read by `validateFlowApiTriggerSecret` only.
*/
restoredCredentialPaths?: ReadonlySet<string>;
/**
* [#24563] The package closure (ruling #9612, `RuntimePackageScope` in
* `runtime-gate.ts`) the runtime publish gate narrowed the snapshot's
* `objects` to — the written package and the packages its manifest
* declares in `dependencies`.
*
* Set only by the runtime publish gate, and only when a closure was in
* force; ABSENT on the three CLI commands and on a write that names no
* package, whose snapshots carry the whole collection. It is what lets a
* rule tell "outside what this package declared it may reference" from
* "not in this stack": an object missing from a narrowed snapshot is the
* first, one missing from a whole one may come from another package. Read
* by `validateListViewFieldRefs` only (through the reference-integrity
* suite), which refuses a list view bound outside the closure instead of
* skipping it.
*/
packageScope?: { readonly packageId: string; readonly dependencies: readonly string[] };
}

export interface AuthoringRule {
Expand Down
5 changes: 5 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,13 +490,18 @@ export type {
// position whose name reaches a query door (the `$select` projection, or the
// compiled filter), where that door refuses it by name — the loud-failing half
// #14107 recorded and left open.
// [#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.
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
LIST_VIEW_OBJECT_OUTSIDE_CLOSURE,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
ListViewFieldRefOptions,
ListViewFieldRefSeverity,
} from './validate-list-view-field-refs.js';

Expand Down
27 changes: 26 additions & 1 deletion packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,24 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// here: it measured equally clean (0 findings over the same corpus) but its
// crossing is its own adjudication with its own non-vacuity control, and
// this card was scoped to the two axes its title names.
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view', 'object'], run: validateListViewFieldRefs },
//
// [#24563] The one member that reads `packageScope`. Under a package
// closure the snapshot's `objects` are that closure (ruling #9612), so a
// binding missing from it is outside what the package declared it may
// reference, or nowhere at all — and the member's skip 1 used to read that as
// "another package's object" and judge nothing. It now refuses the binding
// instead (`list-view-object-outside-closure`, `error`: the verdict the
// dataset door gives an out-of-closure base object under the same closure;
// the flow door's trigger-object advisory is a separate question). The
// 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.
{
name: 'validateListViewFieldRefs',
runtimeTypes: ['flow', 'view', 'object'],
run: (stack, ctx) =>
validateListViewFieldRefs(stack, ctx?.packageScope ? { packageScope: ctx.packageScope } : undefined),
},
// [#15254] The same question, one surface IN: the field-name lists the
// OBJECT itself carries (`highlightFields`, `publicSharing.redactFields`),
// which the three list-view members above do not walk because they are not
Expand Down Expand Up @@ -657,6 +674,14 @@ export interface ReferenceIntegrityRunOptions {
* `path` at the key the author actually wrote.
*/
loweredHookRefs?: ReadonlySet<string>;
/**
* [#24563] The package closure the runtime publish gate narrowed
* `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.
*/
packageScope?: { readonly packageId: string; readonly dependencies: readonly string[] };
}

/**
Expand Down
152 changes: 152 additions & 0 deletions packages/lint/src/runtime-gate.package-closure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
runRuntimeAuthoringRules,
type RuntimePackageScope,
} from './runtime-gate.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';

type AnyRec = Record<string, any>;

Expand Down Expand Up @@ -281,3 +282,154 @@ describe('ablations — the equivalence assertions above can fail (#9612)', () =
expect(mutilated[0]).toContain('flow-trigger-unknown-object');
});
});

// ─────────────────────────────────────────────────────────────────────────────
// #24563 — the VIEW door under a package closure: a list view bound outside the
// closure is reported, never skipped; one bound inside keeps its field verdict.
// ─────────────────────────────────────────────────────────────────────────────

/**
* A flattened list overlay — the shape `PUT /api/v1/meta/view` carries and the
* gate snapshots as `views: [item]` — bound to `object` with one known
* (`name`, every fixture object declares it) and optionally 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 })),
};
}

describe('the view door under a package closure (#24563)', () => {
const OUTSIDE = 'list-view-object-outside-closure';
const FIELD_UNKNOWN = 'list-view-field-unknown';
const objects = [...packagedTenant(3), obj('tenant_overlay', undefined)];
const scoped = (item: AnyRec, scope: RuntimePackageScope = SCOPE) =>
runRuntimeAuthoringRules({ type: 'view', item, context: { objects }, packageScope: scope });
const whole = (item: AnyRec) => runRuntimeAuthoringRules({ type: 'view', item, context: { objects } });
const rules = (fs: AnyRec[]) => fs.map((f) => f.rule);

it('REFUSES a binding into a package the written one never declared — at the binding, with the remedy', () => {
const result = scoped(listViewOn('other_0_0'));

// An `error`, so the door refuses the write: the dataset door's verdict
// for an out-of-closure base object under the same closure. The flow
// door's `flow-trigger-unknown-object` (pinned above) stays advisory.
expect(result.advisories).toEqual([]);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatchObject({ severity: 'error', rule: OUTSIDE });
expect(result.errors[0].path).toMatch(/^views(\[0\]|\.other_0_0\.probe_list)\.data\.object$/);
// The fix names the remedy and the package it applies to.
expect(result.errors[0].hint).toContain(OWN);
expect(result.errors[0].hint).toContain('dependencies');
// ⛔ The field positions stay unjudged: judging them against the whole
// collection would judge the package against what ruling #9612 says it may
// not reference.
expect(rules(result.errors)).not.toContain(FIELD_UNKNOWN);
});

it('REFUSES a binding to an object that exists nowhere the same way', () => {
const result = scoped(listViewOn('nosuch_obj'));

expect(result.advisories).toEqual([]);
expect(rules(result.errors)).toEqual([OUTSIDE]);
});

it('REFUSES it under a closure that carries no object at all — an empty graph is still a verdict', () => {
const result = runRuntimeAuthoringRules({
type: 'view',
item: listViewOn('nosuch_obj'),
context: { objects: [] },
packageScope: SCOPE,
});

expect(rules(result.errors)).toEqual([OUTSIDE]);
});

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

expect(rules(result.errors)).toEqual([FIELD_UNKNOWN]);
expect(result.errors[0].path).toMatch(/columns\[1\]\.field$/);
expect(result.advisories).toEqual([]);
});

it('admits a known column on an object of the written package', () => {
const result = scoped(listViewOn('crm_account', ['name']));

expect(result.errors).toEqual([]);
expect(result.advisories).toEqual([]);
});

it('keeps a DECLARED DEPENDENCY inside the closure — known column admitted, unknown refused', () => {
// The discriminating case: a refusal of the binding here would mean the
// dependency limb never reached the rule.
expect(scoped(listViewOn('core_user', ['name']))).toMatchObject({ errors: [], advisories: [] });
expect(rules(scoped(listViewOn('core_user')).errors)).toEqual([FIELD_UNKNOWN]);
});

it('keeps system objects inside the closure — by name prefix and by isSystem flag', () => {
for (const name of ['sys_audit_log', 'platform_ledger']) {
expect(scoped(listViewOn(name, ['name'])), name).toMatchObject({ errors: [], advisories: [] });
expect(rules(scoped(listViewOn(name)).errors), name).toEqual([FIELD_UNKNOWN]);
}
});

it('keeps a provenance-less overlay object inside the closure — its field verdict is unchanged', () => {
expect(rules(scoped(listViewOn('tenant_overlay')).errors)).toEqual([FIELD_UNKNOWN]);
expect(scoped(listViewOn('tenant_overlay')).advisories).toEqual([]);
});

it('a write that names no package is unchanged — the whole collection, field verdict and silence alike', () => {
// ⛔ 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).
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: [] });
// 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: [] });
});

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

// Non-vacuous: written itself, that object IS refused — the list view
// rides the object door too, at the same verdict.
const asWritten = runRuntimeAuthoringRules({ type: 'object', item: retargeting, context: { objects }, packageScope: SCOPE });
expect(rules(asWritten.errors)).toContain(OUTSIDE);
expect(rules(asWritten.advisories)).not.toContain(OUTSIDE);

expect(flow(FLOW_INTO_OWN_PACKAGE)).toEqual([]);
const undeclared = flow(FLOW_INTO_UNDECLARED);
expect(undeclared).toHaveLength(1);
expect(undeclared[0]).toContain('flow-trigger-unknown-object');
});

it('the CLI reading is unchanged: no scope stated, skip 1 judges nothing', () => {
// `os validate` / `os build` / `os lint` hand the rule no closure.
expect(validateListViewFieldRefs({ objects, views: [listViewOn('nosuch_obj')] })).toEqual([]);
// And the same stack under a stated closure is the verdict above.
expect(
validateListViewFieldRefs(
{ objects, views: [listViewOn('nosuch_obj')] },
{ packageScope: SCOPE },
).map((f) => f.rule),
).toEqual([OUTSIDE]);
});
});
Loading
Loading