From d4f6cfce005378797a6a12cb8fd7866dbcac29b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:47:22 +0000 Subject: [PATCH 1/3] fix(lint): judge filter keys on another package's dataset against its object under a package closure Claude-Session: https://claude.ai/code/session_01Utp6CZYtWqXwEewuPdKQAJ Co-authored-by: Claude --- .../src/runtime-gate.package-closure.test.ts | 127 ++++++++++++++++++ packages/lint/src/runtime-gate.ts | 91 ++++++++++++- 2 files changed, 217 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/runtime-gate.package-closure.test.ts b/packages/lint/src/runtime-gate.package-closure.test.ts index e6724439718..01d635977b0 100644 --- a/packages/lint/src/runtime-gate.package-closure.test.ts +++ b/packages/lint/src/runtime-gate.package-closure.test.ts @@ -28,6 +28,7 @@ */ import { describe, expect, it } from 'vitest'; import { + datasetBoundObjectNames, narrowObjectsToPackageClosure, runRuntimeAuthoringRules, type RuntimePackageScope, @@ -643,3 +644,129 @@ describe('the dataset door under a package closure, and the shared prescription expect(dataset.hint).toBe(`${clause}point it at an object inside that closure`); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #30005 — a dashboard or report on ANOTHER package's dataset, under a closure. +// ───────────────────────────────────────────────────────────────────────────── + +describe('a dataset outside the closure: its filter keys are judged against its object (#30005)', () => { + /** + * The filer's shape: a runtime package (`OWN`, which declares no dependency + * on the showcase) builds on `showcase_task_metrics`, a dataset the showcase + * package ships over its own `showcase_task`. `datasets` is never narrowed, + * so the binding resolves; before this card the object fell out of the + * closure and both filter-key rules took their "object absent" skip. + */ + const SHOWCASE = 'com.example.showcase'; + const objects = [ + ...packagedTenant(2), + obj('showcase_task', SHOWCASE, { + fields: { + name: { type: 'text', label: 'Name' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Done', value: 'done' }] }, + estimate_hours: { type: 'number', label: 'Estimate' }, + }, + }), + ]; + const datasets = [ + { + name: 'showcase_task_metrics', + label: 'Task Metrics', + object: 'showcase_task', + _packageId: SHOWCASE, + dimensions: [{ name: 'status', label: 'Status', field: 'status', type: 'string' }], + measures: [ + { name: 'task_count', label: 'Tasks', aggregate: 'count' }, + { name: 'est_hours', label: 'Estimated Hours', aggregate: 'sum', field: 'estimate_hours' }, + ], + }, + ]; + const context = { objects, datasets }; + const board = (filter: AnyRec): AnyRec => ({ + name: 'runtime_ops_board', + label: 'Runtime Ops', + widgets: [ + { id: 'tasks', type: 'metric', title: 'Tasks', dataset: 'showcase_task_metrics', values: ['task_count'], filter }, + ], + }); + const report = (runtimeFilter: AnyRec): AnyRec => ({ + name: 'runtime_task_report', + label: 'Runtime Tasks', + type: 'summary', + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['est_hours'], + runtimeFilter, + }); + const gate = (type: string, item: AnyRec, scope: RuntimePackageScope | undefined = SCOPE) => + runRuntimeAuthoringRules({ type, item, context, ...(scope ? { packageScope: scope } : {}) }); + const at = (fs: AnyRec[]) => fs.map((f) => [f.rule, f.path]); + + it('⭐ LIT — the filer\'s repro: a widget key the dataset\'s object lacks is REFUSED at the widget path', () => { + const result = gate('dashboard', board({ no_such_field: 'x' })); + expect(at(result.errors)).toEqual([ + ['widget-filter-field-unknown', 'dashboards[0].widgets[0].filter.no_such_field'], + ]); + expect(result.errors[0]!.severity).toBe('error'); + }); + + it('⭐ LIT — the same key on a report `runtimeFilter` is REFUSED at its path', () => { + const result = gate('report', report({ no_such_field: 'x' })); + expect(at(result.errors)).toEqual([ + ['report-filter-field-unknown', 'reports[0].runtimeFilter.no_such_field'], + ]); + }); + + it('⭐ LIT — a joined block on that dataset: the block key and the container key are both judged', () => { + const joined = { + name: 'runtime_joined', + label: 'Joined', + type: 'joined', + runtimeFilter: { nope: 1 }, + blocks: [ + { name: 'b1', type: 'summary', dataset: 'showcase_task_metrics', rows: ['status'], values: ['est_hours'], runtimeFilter: { also_nope: 1 } }, + ], + }; + expect(at(gate('report', joined).errors)).toEqual([ + ['report-filter-field-unknown', 'reports[0].runtimeFilter.nope'], + ['report-filter-field-unknown', 'reports[0].blocks[0].runtimeFilter.also_nope'], + ]); + }); + + it('⭐ CONTROL — a key the dataset door binds still publishes, on the widget and on the report', () => { + for (const filter of [{ status: 'done' }, { estimate_hours: { $gte: 1 } }, { created_at: { $gte: '{today}' } }]) { + expect(gate('dashboard', board(filter)).errors, JSON.stringify(filter)).toEqual([]); + expect(gate('report', report(filter)).errors, JSON.stringify(filter)).toEqual([]); + } + }); + + it('the scoped verdict is now the whole-collection one — the closure no longer hides the finding', () => { + for (const [type, item] of [['dashboard', board({ no_such_field: 'x' })], ['report', report({ no_such_field: 'x' })]] as const) { + expect(verdict(gate(type, item))).toEqual(verdict(gate(type, item, undefined))); + expect(verdict(gate(type, item))).toHaveLength(1); + } + }); + + it('a dataset that resolves to nothing re-admits nothing — `widget-dataset-unknown` keeps the typo', () => { + const dangling = { ...board({ no_such_field: 'x' }) }; + dangling.widgets = [{ ...dangling.widgets[0], dataset: 'no_such_dataset' }]; + expect(gate('dashboard', dangling).errors.map((f) => f.rule)).toEqual(['widget-dataset-unknown']); + }); + + it('DARK — only a dashboard or report re-admits: a flow or a list view outside the closure is still refused', () => { + // Limb 5 is keyed on the written TYPE. A flow trigger and a list view name + // their object directly, and the closure's verdict on them is the ruling's. + const flow = gate('flow', flowOn('on_task', 'showcase_task')); + expect(flow.errors.map((f) => f.rule)).toContain('flow-trigger-unknown-object'); + const view = listViewOn('showcase_task', ['name']); + view.chart = { chartType: 'bar', dataset: 'showcase_task_metrics', dimensions: ['status'], values: ['task_count'] }; + expect(gate('view', view).errors.map((f) => f.rule)).toContain('list-view-object-outside-closure'); + }); + + it('datasetBoundObjectNames reads the two bound positions and nothing else', () => { + expect([...datasetBoundObjectNames('dashboard', board({}), datasets)]).toEqual(['showcase_task']); + expect([...datasetBoundObjectNames('report', report({}), datasets)]).toEqual(['showcase_task']); + expect(datasetBoundObjectNames('view', { chart: { dataset: 'showcase_task_metrics' } }, datasets).size).toBe(0); + expect(datasetBoundObjectNames('dashboard', board({}), undefined).size).toBe(0); + }); +}); diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index fb3f08909b0..e6b081e330d 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -418,12 +418,26 @@ export interface RuntimePackageScope { * 3. it belongs to the **written package** itself; * 4. it belongs to one of that package's **declared dependencies**. * + * [#30005] A fifth limb, stated by the caller and empty unless it says so: + * + * 5. it is the **base object of a dataset the written item binds** + * (`datasetBoundObjects`, which {@link datasetBoundObjectNames} computes + * for a dashboard or report write). `datasets` is never narrowed — a + * dashboard may bind another package's dataset, and the dataset door binds + * it at run time against the dataset's own object, closure or no closure — + * so a filter key on that binding is judged against that object. Without + * this limb the object fell out of the snapshot while its dataset stayed, + * and `widget-filter-field-unknown` / `report-filter-field-unknown` took + * their "object absent" skip: a misspelled key published and every reader + * got an empty tile or an error panel. + * * Pure, allocation-light, and total: a non-object member is kept rather than * inspected, because deciding it is not this function's job. */ export function narrowObjectsToPackageClosure( objects: readonly unknown[], scope: RuntimePackageScope | undefined, + datasetBoundObjects: ReadonlySet = NO_NAMES, ): readonly unknown[] { if (!packageScopeInForce(scope)) return objects; const reachable = new Set([scope.packageId, ...scope.dependencies]); @@ -436,12 +450,79 @@ export function narrowObjectsToPackageClosure( if (typeof owner !== 'string' || owner === '' || owner === OVERLAY_PROVENANCE_SENTINEL) return true; // Limbs 3 and 4. if (reachable.has(owner)) return true; + // Limb 5 — reached through a dataset the written item binds (#30005). + const name = (entry as AnyRec).name; + if (typeof name === 'string' && datasetBoundObjects.has(name)) return true; // Limb 1 — checked last only because it is the rarest, never because it is // the weakest: it is the one limb with no escape. return isSystemObject(entry as AnyRec); }); } +const NO_NAMES: ReadonlySet = new Set(); + +/** + * The base objects of the datasets a written dashboard or report binds (#30005) + * — limb 5 of {@link narrowObjectsToPackageClosure}. + * + * Read off the item at the positions the two dataset filter-key rules read: + * a dashboard's `widgets[].dataset` (`validateWidgetBindings`, whose + * dashboard-level filters resolve through the same widget datasets), a + * report's `dataset` and each joined block's `dataset` + * (`validateReportRuntimeFilters`). Each name is resolved against the carried + * `datasets` collection, which is never narrowed; a name that resolves to + * nothing contributes nothing (`widget-dataset-unknown` / + * `chart-dataset-unknown` own that typo). + * + * ⛔ Every other write type answers the empty set, and that is the point, not + * an omission. Limb 5 re-admits an object the package did not declare, and a + * write that names an OBJECT directly is judged against the closure on + * purpose: a flow trigger, a list view or a dataset's base object outside it + * is refused (`flow-trigger-unknown-object` at `error` under a closure, + * `list-view-object-outside-closure`, the closure fix line on + * `object-reference-unknown`). A list view's `chart.dataset` re-admitting the + * object the view lists would wave that verdict through. A dashboard or report + * reaches an object only THROUGH a dataset, and the dataset door is what binds + * it, so the re-admitted object is exactly the universe the door resolves the + * filter in. + * + * Only the base object: a dotted key whose hop lands on an object the snapshot + * still does not carry stays the rules' "unknowable" skip — never a refusal. + */ +export function datasetBoundObjectNames( + type: string, + item: unknown, + datasets: readonly unknown[] | undefined, +): ReadonlySet { + if (type !== 'dashboard' && type !== 'report') return NO_NAMES; + if (!item || typeof item !== 'object' || !Array.isArray(datasets) || datasets.length === 0) return NO_NAMES; + const body = item as AnyRec; + const bound = new Set(); + const note = (v: unknown): void => { + if (typeof v === 'string' && v !== '') bound.add(v); + }; + if (type === 'dashboard') { + for (const w of Array.isArray(body.widgets) ? body.widgets : []) { + if (w && typeof w === 'object') note((w as AnyRec).dataset); + } + } else { + note(body.dataset); + for (const b of Array.isArray(body.blocks) ? body.blocks : []) { + if (b && typeof b === 'object') note((b as AnyRec).dataset); + } + } + if (bound.size === 0) return NO_NAMES; + const objects = new Set(); + for (const ds of datasets) { + if (!ds || typeof ds !== 'object') continue; + const { name, object } = ds as AnyRec; + if (typeof name === 'string' && bound.has(name) && typeof object === 'string' && object !== '') { + objects.add(object); + } + } + return objects; +} + /** * Whether `scope` narrows anything (#9612): a stated, non-empty package id. * @@ -769,9 +850,17 @@ export function buildRuntimeWriteSnapshotSet( // ⛔ Applied to BOTH passes, necessarily. The gate's verdict is // candidate MINUS baseline, so narrowing one side and not the other would // not be a smaller input — it would be a different question. + // + // [#30005] `datasets` stays whole, so the objects a written dashboard or + // report binds THROUGH a dataset are kept beside the closure (limb 5, + // {@link datasetBoundObjectNames}) — in both passes, by the same rule. const raw = (args.context?.[key] ?? []) as readonly AnyRec[]; const collection = key === 'objects' - ? (narrowObjectsToPackageClosure(raw, args.packageScope) as readonly AnyRec[]) + ? (narrowObjectsToPackageClosure( + raw, + args.packageScope, + datasetBoundObjectNames(args.type, item, args.context?.datasets), + ) as readonly AnyRec[]) : raw; if (key === stackKey) { baseline[key] = collection.filter((o) => !itemName || o?.name !== itemName); From b37c73da8aff9c8189ccf7fd3ba23d579823de3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:50:40 +0000 Subject: [PATCH 2/3] fix(lint): keep narrowObjectsToPackageClosure's public signature; changeset Claude-Session: https://claude.ai/code/session_01Utp6CZYtWqXwEewuPdKQAJ Co-authored-by: Claude --- ...30005-cross-closure-dataset-filter-keys.md | 20 ++++++++++++ .../src/runtime-gate.package-closure.test.ts | 5 +++ packages/lint/src/runtime-gate.ts | 32 +++++++++++++------ 3 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 .changeset/30005-cross-closure-dataset-filter-keys.md diff --git a/.changeset/30005-cross-closure-dataset-filter-keys.md b/.changeset/30005-cross-closure-dataset-filter-keys.md new file mode 100644 index 00000000000..49d9c1b8d4e --- /dev/null +++ b/.changeset/30005-cross-closure-dataset-filter-keys.md @@ -0,0 +1,20 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: under a package closure, a dashboard widget `filter` or report `runtimeFilter` on another package's dataset is judged against that dataset's object, so a key the object lacks is refused at publish (#30005) + +Clause-②: no (narrowing) — the runtime publish door, for a write that names its package, refuses a dashboard widget `filter` key or a report `runtimeFilter` key on a dataset outside the package's closure when the dataset's object has no such field, a body it accepted before. Nothing is widened: `narrowObjectsToPackageClosure` keeps its signature and its four-limb answer, and no export is added to any package entry. +Metadata-protocol: narrowed +Skills-docs: none in `skills/**`, which teaches neither rule nor the closure. `content/docs/deployment/validating-metadata.mdx` names the closure's four limbs and does not yet say that a dashboard or report write also carries the base objects of the datasets it binds; that page is outside this change and is reported to the owning seat. The rules' `os explain` entries are unchanged and still describe the verdict. + + + +**BREAKING** accept-set narrowing at the runtime publish door for `dashboard` and `report` writes (the per-item publish and the package draft publish; Studio, REST and MCP authors), for a write whose package is known. `os validate`, `os build` and `os lint` are unchanged: they never narrow to a closure. + +- **Before.** The publish gate narrows the live `objects` to the written package's closure (the package, its declared dependencies, platform objects, rows with no package) but carries every dataset. A runtime package's dashboard on `showcase_task_metrics`, a dataset the showcase package ships over `showcase_task`, therefore found the dataset but not its object, and `widget-filter-field-unknown` and `report-filter-field-unknown` skipped the filter as "object not in this snapshot". `filter: { no_such_field: 'x' }` published `200`, and every reader saw an empty tile or an error panel. +- **Now.** A dashboard or report write also carries the base object of each dataset it binds (`widgets[].dataset`; a report's `dataset` and each joined block's `dataset`), which is the object the dataset query resolves the filter against at run time. The same key is refused `422 INVALID_METADATA` at `dashboards[0].widgets[0].filter.no_such_field` (or `reports[0].runtimeFilter.no_such_field`), the verdict the same key already got on a dataset inside the closure or with no closure in force. +- **Accepted, as before.** Every key the object has, a registry-injected system column, a dotted relationship path, every `$` operator. A dotted key whose hop lands on an object the snapshot still does not carry is not judged. +- **Not changed.** Only dashboard and report writes carry the extra objects. A flow trigger, a list view or a dataset base object outside the closure is still refused as before (`flow-trigger-unknown-object`, `list-view-object-outside-closure`, `object-reference-unknown`), and a list view's `chart.dataset` brings no object in. + +**What an author does.** Nothing for a board or report whose filter keys name real fields. A refused key never filtered anything, so there is no rewrite that keeps what the author meant: name a field of the dataset's object. The refusal names the key and the nearest field. diff --git a/packages/lint/src/runtime-gate.package-closure.test.ts b/packages/lint/src/runtime-gate.package-closure.test.ts index 01d635977b0..c07a14bebd4 100644 --- a/packages/lint/src/runtime-gate.package-closure.test.ts +++ b/packages/lint/src/runtime-gate.package-closure.test.ts @@ -763,6 +763,11 @@ describe('a dataset outside the closure: its filter keys are judged against its expect(gate('view', view).errors.map((f) => f.rule)).toContain('list-view-object-outside-closure'); }); + it('the public `narrowObjectsToPackageClosure` stays the four-limb closure — limb 5 is the write\'s', () => { + const names = (narrowObjectsToPackageClosure(objects, SCOPE) as AnyRec[]).map((o) => o.name); + expect(names).not.toContain('showcase_task'); + }); + it('datasetBoundObjectNames reads the two bound positions and nothing else', () => { expect([...datasetBoundObjectNames('dashboard', board({}), datasets)]).toEqual(['showcase_task']); expect([...datasetBoundObjectNames('report', report({}), datasets)]).toEqual(['showcase_task']); diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index e6b081e330d..c6fdac31637 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -400,6 +400,9 @@ export interface RuntimePackageScope { dependencies: readonly string[]; } +/** The empty limb-5 set: no object is reached through a dataset (#30005). */ +const NO_NAMES: ReadonlySet = new Set(); + /** * `objects` reduced to the written item's package closure (#9612). * @@ -421,11 +424,12 @@ export interface RuntimePackageScope { * [#30005] A fifth limb, stated by the caller and empty unless it says so: * * 5. it is the **base object of a dataset the written item binds** - * (`datasetBoundObjects`, which {@link datasetBoundObjectNames} computes - * for a dashboard or report write). `datasets` is never narrowed — a - * dashboard may bind another package's dataset, and the dataset door binds - * it at run time against the dataset's own object, closure or no closure — - * so a filter key on that binding is judged against that object. Without + * ({@link datasetBoundObjectNames}, for a dashboard or report write; the + * snapshot builder states it, this public spelling never does). + * `datasets` is never narrowed — a dashboard may bind another package's + * dataset, and the dataset door binds it at run time against the + * dataset's own object, closure or no closure — so a filter key on that + * binding is judged against that object. Without * this limb the object fell out of the snapshot while its dataset stayed, * and `widget-filter-field-unknown` / `report-filter-field-unknown` took * their "object absent" skip: a misspelled key published and every reader @@ -437,7 +441,19 @@ export interface RuntimePackageScope { export function narrowObjectsToPackageClosure( objects: readonly unknown[], scope: RuntimePackageScope | undefined, - datasetBoundObjects: ReadonlySet = NO_NAMES, +): readonly unknown[] { + return narrowObjectsToWriteClosure(objects, scope, NO_NAMES); +} + +/** + * {@link narrowObjectsToPackageClosure} with limb 5 stated — the one + * implementation of both; the public spelling states an empty limb 5, so its + * signature and answer are the four-limb ones they always were (#30005). + */ +function narrowObjectsToWriteClosure( + objects: readonly unknown[], + scope: RuntimePackageScope | undefined, + datasetBoundObjects: ReadonlySet, ): readonly unknown[] { if (!packageScopeInForce(scope)) return objects; const reachable = new Set([scope.packageId, ...scope.dependencies]); @@ -459,8 +475,6 @@ export function narrowObjectsToPackageClosure( }); } -const NO_NAMES: ReadonlySet = new Set(); - /** * The base objects of the datasets a written dashboard or report binds (#30005) * — limb 5 of {@link narrowObjectsToPackageClosure}. @@ -856,7 +870,7 @@ export function buildRuntimeWriteSnapshotSet( // {@link datasetBoundObjectNames}) — in both passes, by the same rule. const raw = (args.context?.[key] ?? []) as readonly AnyRec[]; const collection = key === 'objects' - ? (narrowObjectsToPackageClosure( + ? (narrowObjectsToWriteClosure( raw, args.packageScope, datasetBoundObjectNames(args.type, item, args.context?.datasets), From ae7fa514e5fccd5ed58307428b2783bbb3ce63c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:03:35 +0000 Subject: [PATCH 3/3] fix(lint): name the fifth closure limb in the docs, the six rules in the changeset, and pin the door's 422 Claude-Session: https://claude.ai/code/session_01Utp6CZYtWqXwEewuPdKQAJ Co-authored-by: Claude --- ...30005-cross-closure-dataset-filter-keys.md | 4 +- .../docs/deployment/validating-metadata.mdx | 6 ++ ...col.dashboard-dataset-publish-gate.test.ts | 95 +++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/.changeset/30005-cross-closure-dataset-filter-keys.md b/.changeset/30005-cross-closure-dataset-filter-keys.md index 49d9c1b8d4e..7e41c11f0c3 100644 --- a/.changeset/30005-cross-closure-dataset-filter-keys.md +++ b/.changeset/30005-cross-closure-dataset-filter-keys.md @@ -6,7 +6,7 @@ fix(lint)!: under a package closure, a dashboard widget `filter` or report `runt Clause-②: no (narrowing) — the runtime publish door, for a write that names its package, refuses a dashboard widget `filter` key or a report `runtimeFilter` key on a dataset outside the package's closure when the dataset's object has no such field, a body it accepted before. Nothing is widened: `narrowObjectsToPackageClosure` keeps its signature and its four-limb answer, and no export is added to any package entry. Metadata-protocol: narrowed -Skills-docs: none in `skills/**`, which teaches neither rule nor the closure. `content/docs/deployment/validating-metadata.mdx` names the closure's four limbs and does not yet say that a dashboard or report write also carries the base objects of the datasets it binds; that page is outside this change and is reported to the owning seat. The rules' `os explain` entries are unchanged and still describe the verdict. +Skills-docs: updated here — `content/docs/deployment/validating-metadata.mdx` (the fourth-door paragraph) now names the fifth closure limb for dashboard and report writes; `skills/**` teaches neither the rules nor the closure. @@ -14,6 +14,8 @@ Skills-docs: none in `skills/**`, which teaches neither rule nor the closure. `c - **Before.** The publish gate narrows the live `objects` to the written package's closure (the package, its declared dependencies, platform objects, rows with no package) but carries every dataset. A runtime package's dashboard on `showcase_task_metrics`, a dataset the showcase package ships over `showcase_task`, therefore found the dataset but not its object, and `widget-filter-field-unknown` and `report-filter-field-unknown` skipped the filter as "object not in this snapshot". `filter: { no_such_field: 'x' }` published `200`, and every reader saw an empty tile or an error panel. - **Now.** A dashboard or report write also carries the base object of each dataset it binds (`widgets[].dataset`; a report's `dataset` and each joined block's `dataset`), which is the object the dataset query resolves the filter against at run time. The same key is refused `422 INVALID_METADATA` at `dashboards[0].widgets[0].filter.no_such_field` (or `reports[0].runtimeFilter.no_such_field`), the verdict the same key already got on a dataset inside the closure or with no closure in force. +- **Which rules now judge such a dataset.** Every rule that resolves a dashboard's or report's filter against the dataset's object, all six already judging datasets inside the closure: `widget-filter-field-unknown`, `widget-filter-field-not-included`, `dashboard-filter-field-unknown`, `dashboard-filter-field-not-included`, `dashboard-filter-field-unprovisioned` and `report-filter-field-unknown`. +- **A dimension name as a widget filter key.** On a dataset outside the closure, a widget `filter` key that names a dimension of the dataset but no field of its object is now refused with `widget-filter-field-unknown`, as it already was on a dataset inside the closure. The dataset query binds such a key; the correction of that verdict, for both cases, is tracked in #30006. - **Accepted, as before.** Every key the object has, a registry-injected system column, a dotted relationship path, every `$` operator. A dotted key whose hop lands on an object the snapshot still does not carry is not judged. - **Not changed.** Only dashboard and report writes carry the extra objects. A flow trigger, a list view or a dataset base object outside the closure is still refused as before (`flow-trigger-unknown-object`, `list-view-object-outside-closure`, `object-reference-unknown`), and a list view's `chart.dataset` brings no object in. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 9f9718bd209..794abfc6d4b 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -737,6 +737,12 @@ 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. +A dashboard or report write also keeps the base object of each dataset it binds +(a widget's `dataset`; a report's `dataset` and each joined block's), because the +datasets are never narrowed and the dataset query resolves a filter against that +object whichever package ships it: a filter key the object lacks is refused there +just as it is on a dataset inside the closure (#30005). No other write type keeps +it, so a flow or a list view never reaches an outside object through a dataset. That changes what a rule can **resolve**, never which rules run, and nothing at this door walks `packages[]`. Two rules are told the closure is in force. A flow whose trigger object is outside it is refused with `flow-trigger-unknown-object` diff --git a/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts b/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts index f2379b3fa8e..dbac1413073 100644 --- a/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts @@ -487,6 +487,101 @@ describe('#29900 — a report `runtimeFilter` key resolves at the publish door', }); }); +// ───────────────────────────────────────────────────────────────────────────── +// #30005 — another package's dataset, under the written package's closure +// ───────────────────────────────────────────────────────────────────────────── +// +// The filer's reproduction at the door: a runtime package (`app.runtime`, which +// declares no dependency on the showcase) saves a dashboard on +// `showcase_task_metrics`, a dataset the showcase package ships over its own +// `showcase_task`. The write names its package, so the gate narrows `objects` +// to that package's closure; `datasets` stays whole. Before the fix the dataset +// resolved, its object did not, and the misspelled key saved `200`. + +const RUNTIME_PKG = 'app.runtime'; + +/** The harness, with a showcase-stamped object and dataset and an installed runtime package. */ +function makeClosureProtocol() { + const { engine, rows } = makeStubEngine(); + const realListItems = engine.registry.listItems; + engine.registry.listItems = (type: string) => { + if (type === 'object') { + return [ + ...realListItems('object'), + { + name: 'showcase_task', + _packageId: 'com.example.showcase', + fields: [ + { name: 'name', type: 'text' }, + { name: 'status', type: 'text' }, + ], + }, + ]; + } + if (type === 'dataset') { + return [ + ...realListItems('dataset'), + { + name: 'showcase_task_metrics', + object: 'showcase_task', + _packageId: 'com.example.showcase', + dimensions: [{ name: 'status', field: 'status' }], + measures: [{ name: 'task_count', aggregate: 'count' }], + }, + ]; + } + return realListItems(type); + }; + // The written package is installed and declares no dependencies, so its + // closure is itself plus platform and unpackaged rows. + (engine.registry as any).getPackage = (id: string) => + id === RUNTIME_PKG ? { manifest: { id: RUNTIME_PKG } } : undefined; + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test'); + return { protocol, rows }; +} + +const crossPackageBoard = (filter: Record) => ({ + name: 'runtime_ops_board', + label: 'Runtime Ops', + widgets: [ + { id: 'tasks', type: 'metric', title: 'Tasks', dataset: 'showcase_task_metrics', values: ['task_count'], filter }, + ], +}); + +describe('#30005 — a filter key on another package\'s dataset, under the written package\'s closure', () => { + 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('the filer\'s repro: the save is REFUSED 422 INVALID_METADATA at the widget path, and nothing is stored', async () => { + const { protocol, rows } = makeClosureProtocol(); + const err = await protocol + .saveMetaItem({ type: 'dashboard', name: 'runtime_ops_board', item: crossPackageBoard({ no_such_field: 'x' }), packageId: RUNTIME_PKG }) + .then(() => null, (e: any) => e); + expect({ status: err?.status, code: err?.code }).toEqual({ status: 422, code: 'INVALID_METADATA' }); + expect(err.issues.map((i: any) => [i.rule, i.path])).toContainEqual([ + 'widget-filter-field-unknown', + 'dashboards[0].widgets[0].filter.no_such_field', + ]); + expect(dashboardRows(rows).filter((r) => r.state === 'active')).toEqual([]); + }); + + it('CONTROL — a key the dataset\'s object has saves under the same closure', async () => { + const { protocol, rows } = makeClosureProtocol(); + const saved = await protocol.saveMetaItem({ + type: 'dashboard', name: 'runtime_ops_board', item: crossPackageBoard({ status: 'done' }), packageId: RUNTIME_PKG, + }); + expect(saved.success, JSON.stringify(saved?.issues ?? saved)).toBe(true); + expect(dashboardRows(rows).map((r) => r.state)).toContain('active'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // #16225 — the stub answers the table it was asked about, and only that one // ─────────────────────────────────────────────────────────────────────────────