Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/30005-cross-closure-dataset-filter-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@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: 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.

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: DashboardWidgetSchema.filter and ReportSchema.runtimeFilter keep their shape and meaning, and a dataset outside the closure now gets the verdict a dataset inside it already got. A refused key never served the board or the report (every reader saw an empty tile or a Dataset query failed panel), so there is no rewrite that keeps what the author meant; the author names a real field, and the refusal says which. -->

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

**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.
6 changes: 6 additions & 0 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
132 changes: 132 additions & 0 deletions packages/lint/src/runtime-gate.package-closure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
*/
import { describe, expect, it } from 'vitest';
import {
datasetBoundObjectNames,
narrowObjectsToPackageClosure,
runRuntimeAuthoringRules,
type RuntimePackageScope,
Expand Down Expand Up @@ -643,3 +644,134 @@ 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('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']);
expect(datasetBoundObjectNames('view', { chart: { dataset: 'showcase_task_metrics' } }, datasets).size).toBe(0);
expect(datasetBoundObjectNames('dashboard', board({}), undefined).size).toBe(0);
});
});
105 changes: 104 additions & 1 deletion packages/lint/src/runtime-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set<string>();

/**
* `objects` reduced to the written item's package closure (#9612).
*
Expand All @@ -418,12 +421,39 @@ 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**
* ({@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
* 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,
): 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<string>,
): readonly unknown[] {
if (!packageScopeInForce(scope)) return objects;
const reachable = new Set<string>([scope.packageId, ...scope.dependencies]);
Expand All @@ -436,12 +466,77 @@ 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);
});
}

/**
* 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<string> {
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<string>();
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<string>();
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.
*
Expand Down Expand Up @@ -769,9 +864,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[])
? (narrowObjectsToWriteClosure(
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);
Expand Down
Loading
Loading