Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .changeset/27445-dashboard-filter-binding-unknown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': minor
'@objectstack/spec': minor
---

feat(lint)!: `dashboard-filter-binding-unknown` refuses a widget `filterBindings` key that names no filter the dashboard declares, naming the widget, the key and the declared filters

Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse a dashboard whose widget `filterBindings` carries a key that names no declared filter (`{ regoin: false }` beside a `region` filter, or a `dateRange` key on a dashboard that declares no `dateRange`), which they accepted before. A draft still saves, and its `_diagnostics` read reports the same verdict. No accept set is widened; one rule id and its exported constant `DASHBOARD_FILTER_BINDING_UNKNOWN` are added.
Metadata-protocol: narrowed

<!-- adr-0087: registered ui-dashboard-filter-binding-unknown-refused -->

**BREAKING** accept-set narrowing at every authoring door for `dashboard`.

- **Before.** `PUT /api/v1/meta/dashboard/<name>?mode=draft` and the publish that followed both answered `200` for a widget binding `filterBindings: { no_such_filter: 'status' }`. The runtime reads a binding by looking up each declared filter's name, so the key was never read: a misspelled opt-out left the widget scoped by the filter it was meant to ignore, and a misspelled re-target left the filter on its default field.
- **Now.** The rule reports the key at `widgets[i].filterBindings.<key>` at `error`, naming the widget, the key and the declared filters, with the nearest declared name as a "Did you mean". A key is valid when it names a `globalFilters` entry (by its `name`, or its `field` when it has no `name`) or is `dateRange` on a dashboard that declares `dateRange`. It is not suppressible.
- **Who sees it.** A dashboard with a misspelled or stale binding key, for example one left behind after its filter was renamed or removed. No dashboard in this repository's examples or platform objects, and none in the public HotCRM app, binds an undeclared key.
- **Stored rows.** A `sys_metadata` dashboard written before this release with such a key still loads and renders as before: the rule judges the authoring path, not the load path. Its next save or publish is refused until the key is fixed.

**The remedy.** Rename the key to one of the declared filter names the finding lists, declare the filter on the dashboard, or delete the entry.
7 changes: 7 additions & 0 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ dashboard, widget, filter, field, and object. Opt a widget out with
`filterBindings: { <name>: false }`, or re-target the filter to one of that
object's own fields.

The keys of `filterBindings` are checked too. Each must name a filter the
dashboard declares (a `globalFilters[]` entry's `name`, or its `field` when it
has none, and `dateRange` only when the dashboard declares one). A key naming
anything else is never read at runtime, so a misspelled opt-out leaves the
widget filtered after all; `os validate` refuses it as
`dashboard-filter-binding-unknown`, listing the declared filters.

This is no longer only a CLI verdict. Since #7529 the same rule also runs at the
**runtime publish door** for `dashboard` writes, so a board published from
Studio, `PUT /api/v1/meta/*` or an MCP/AI author is refused there too — the same
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const result = DashboardSchema.parse(data);
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family and on the single-series pie/donut/funnel/treemap/sankey; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out. Each key must name a filter the dashboard declares (dateRange only when declared); lint rule dashboard-filter-binding-unknown refuses any other |
| **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget |

### Nested Shape: `Dashboard.dateRange`
Expand Down Expand Up @@ -176,7 +176,7 @@ Dashboard header action
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family and on the single-series pie/donut/funnel/treemap/sankey; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out. Each key must name a filter the dashboard declares (dateRange only when declared); lint rule dashboard-filter-binding-unknown refuses any other |
| **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget |

### Allowed Values: `DashboardWidget.type`
Expand Down
7 changes: 7 additions & 0 deletions content/docs/ui/dashboards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ They are two different namespaces:
| :--- | :--- | :--- |
| `globalFilters[].field` (after any `filterBindings` re-target) | The bound widget's `dataset.object` — a bare name against that object's own fields, a dotted `relationship.field` path hop by hop with its prefix declared in the dataset's `include` | `dashboard-filter-field-unknown`, severity **error** |
| `widgets[].dimensions[]` | The dataset's declared `dimensions`, by name | `widget-dimension-unknown`, severity **error** |
| `widgets[].filterBindings` keys | The dashboard's declared filters, by name: each `globalFilters[].name` (its `field` when unnamed), and `dateRange` only when the dashboard declares `dateRange` | `dashboard-filter-binding-unknown`, severity **error** |

Two consequences follow, and their asymmetry is the part that surprises authors:

Expand Down Expand Up @@ -547,6 +548,12 @@ Binding precedence: an explicit `filterBindings` entry (string override or
`false` opt-out) → the filter's legacy `targetWidgets` allow-list → the
filter's own `field`.

Each key must name a filter the dashboard declares. The dashboard looks a
binding up by the declared filter's name, so a misspelled key (`regoin` for
`region`) is never read and the widget keeps the filter it was meant to drop
or re-target. `os validate` and the publish door refuse such a key with
`dashboard-filter-binding-unknown`, naming the declared filters.

### In the Console

- **The filter bar.** The date range and each global filter render as controls above the
Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export {
// field, now that it is resolved on the object graph rather than skipped
// whenever it was dotted.
DASHBOARD_FILTER_FIELD_NOT_INCLUDED,
// [#27445] A `filterBindings` KEY naming no filter the dashboard declares:
// the runtime reads a binding by the declared filter's name, so the entry is
// never read and the widget keeps the filter it meant to drop or re-target.
DASHBOARD_FILTER_BINDING_UNKNOWN,
// [#14148] The widget's OWN two references, at the same site: the keys of its
// presentation-scope `filter` (resolved on the #14105 object-graph seam, with
// the ADR-0021 `include` clause its `runtimeFilter` really is subject to) and
Expand Down
20 changes: 20 additions & 0 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,25 @@ const DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = {
],
};

const DASHBOARD_FILTER_BINDING_UNKNOWN_EXPLANATION: RuleExplanation = {
rule: 'dashboard-filter-binding-unknown',
covers: 'which keys a widget\'s filterBindings may name',
paragraphs: [
'A widget\'s `filterBindings` maps a dashboard filter\'s NAME to one of the widget\'s own fields ' +
'(re-target) or to `false` (opt out). The names a widget may bind are the ones its dashboard ' +
'declares: each `globalFilters` entry under its `name` (its `field` when `name` is omitted), and ' +
'the reserved name `dateRange` when the dashboard declares `dateRange`.',
'The runtime reads a binding by looking up each declared filter\'s name, so a key that names no ' +
'declared filter is never read. A misspelled opt-out leaves the widget scoped by the filter it ' +
'was meant to ignore, and a misspelled re-target leaves the filter on its default field. Either ' +
'way the widget counts different rows than the author wrote, with nothing on the page to say so, ' +
'which is why the rule is an error and is not suppressible.',
'To fix it, rename the key to a declared filter name (the finding names the nearest one), declare ' +
'the filter on the dashboard, or delete the entry. A `dateRange` key on a dashboard that declares ' +
'no `dateRange` is reported the same way: there is no date range for it to bind.',
],
};

const DASHBOARD_FILTER_FIELD_NOT_INCLUDED_EXPLANATION: RuleExplanation = {
rule: 'dashboard-filter-field-not-included',
covers: 'why a filter\'s relationship prefix must be included',
Expand Down Expand Up @@ -5177,6 +5196,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
[WIDGET_LEGACY_ANALYTICS_SHAPE_EXPLANATION.rule]: WIDGET_LEGACY_ANALYTICS_SHAPE_EXPLANATION,
[DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION.rule]: DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION,
[DASHBOARD_FILTER_FIELD_NOT_INCLUDED_EXPLANATION.rule]: DASHBOARD_FILTER_FIELD_NOT_INCLUDED_EXPLANATION,
[DASHBOARD_FILTER_BINDING_UNKNOWN_EXPLANATION.rule]: DASHBOARD_FILTER_BINDING_UNKNOWN_EXPLANATION,
[DASHBOARD_FILTER_FIELD_UNPROVISIONED_EXPLANATION.rule]: DASHBOARD_FILTER_FIELD_UNPROVISIONED_EXPLANATION,
[WIDGET_FILTER_FIELD_UNKNOWN_EXPLANATION.rule]: WIDGET_FILTER_FIELD_UNKNOWN_EXPLANATION,
[WIDGET_FILTER_FIELD_NOT_INCLUDED_EXPLANATION.rule]: WIDGET_FILTER_FIELD_NOT_INCLUDED_EXPLANATION,
Expand Down
27 changes: 24 additions & 3 deletions packages/lint/src/runtime-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,7 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
expect(dangling.errors).toHaveLength(1);
});

it('ALL FIVE error ids are reachable at the door — the class the 2026-08-15 ruling accepted', () => {
it('ALL SIX error ids are reachable at the door — the class the 2026-08-15 ruling accepted', () => {
// `surfaces` is per-rule, so the flip enforces the rule's whole error set,
// not just `widget-dataset-unknown`. The ruling accepted the class as one
// coherent "this board cannot render" set — this test makes that
Expand Down Expand Up @@ -897,6 +897,14 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
dashboard([cleanWidget()], { dateRange: { field: 'close_date' } }),
['dashboard-filter-field-unknown'],
],
[
// [#27445] The sixth id joins through the same per-rule `surfaces`: a
// binding key the runtime never reads leaves the widget counting rows
// the author scoped out, so it is refused at the door with the five.
'filterBindings key naming no declared dashboard filter',
dashboard([cleanWidget({ filterBindings: { no_such_filter: false } })]),
['dashboard-filter-binding-unknown'],
],
];

const seen = new Set<string>();
Expand All @@ -909,9 +917,10 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
for (const e of errors) seen.add(e.rule);
}

// Non-vacuous, and the ruling's whole accept-set as #15463 left it:
// exactly the five. `chart-field-unknown` is deliberately absent.
// Non-vacuous, and the ruling's whole accept-set as #15463 left it plus
// #27445's id: exactly the six. `chart-field-unknown` is deliberately absent.
expect([...seen].sort()).toEqual([
'dashboard-filter-binding-unknown',
'dashboard-filter-field-unknown',
'widget-dataset-unknown',
'widget-dimension-unknown',
Expand All @@ -921,6 +930,18 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
expect(seen.has('chart-field-unknown')).toBe(false);
});

it('[#27445] a binding to a DECLARED filter passes the door; a misspelled key is refused at its path', () => {
const declared = { globalFilters: [{ name: 'region', field: 'region', type: 'select' }] };
const ok = gateDashboard(dashboard([cleanWidget({ filterBindings: { region: false } })], declared));
expect(ok.errors, JSON.stringify(ok.errors)).toEqual([]);

const typo = gateDashboard(dashboard([cleanWidget({ filterBindings: { regoin: false } })], declared));
expect(typo.errors.map((e) => e.rule)).toEqual(['dashboard-filter-binding-unknown']);
expect(typo.errors[0].path).toBe('dashboards[0].widgets[0].filterBindings.regoin');
expect(typo.errors[0].message).toContain('(declared: region)');
expect(typo.errors[0].hint).toContain('Did you mean "region"?');
});

it('the demoted `chart-field-unknown` still RUNS at the door — on the advisory channel (#15463)', () => {
// The other half of the accept-set narrowing: the exact body that used to
// 422 now publishes, and the finding reaches the author on the 2xx response
Expand Down
126 changes: 126 additions & 0 deletions packages/lint/src/validate-widget-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
DASHBOARD_FILTER_FIELD_UNKNOWN,
DASHBOARD_FILTER_FIELD_UNPROVISIONED,
DASHBOARD_FILTER_FIELD_NOT_INCLUDED,
DASHBOARD_FILTER_BINDING_UNKNOWN,
WIDGET_FILTER_FIELD_UNKNOWN,
WIDGET_FILTER_FIELD_NOT_INCLUDED,
WIDGET_SORTBY_UNSELECTED,
Expand Down Expand Up @@ -862,6 +863,131 @@ describe('validateWidgetBindings (dashboard-filter-field-unknown, issue #3365)',
});
});

describe('dashboard-filter-binding-unknown (#27445)', () => {
const only = (findings: ReturnType<typeof validateWidgetBindings>) =>
findings.filter((f) => f.rule === DASHBOARD_FILTER_BINDING_UNKNOWN);

/**
* The card's probe, on a board that declares both kinds of filter: the
* built-in `dateRange` and a `globalFilters` entry named `region`. `dash`
* overrides the dashboard tail; `widget` the single widget.
*/
function stack(dash: Record<string, unknown> = {}, widget: Record<string, unknown> = {}) {
return {
objects: [{ name: 'deal', fields: [
{ name: 'region', type: 'select' },
{ name: 'sales_region', type: 'select' },
{ name: 'signed_at', type: 'date' },
] }],
datasets: [{ name: 'deal_metrics', object: 'deal',
dimensions: [{ name: 'region', field: 'region' }],
measures: [{ name: 'deal_count', aggregate: 'count' }] }],
dashboards: [{
name: 'deal_board',
dateRange: { field: 'signed_at' },
globalFilters: [{ name: 'region', field: 'region', type: 'select' }],
widgets: [{ id: 'kpi_deals', type: 'metric', dataset: 'deal_metrics', values: ['deal_count'], ...widget }],
...dash,
}],
};
}

it("refuses the card's body: a key naming no declared filter, opt-out spelling", () => {
const findings = only(validateWidgetBindings(stack({}, { filterBindings: { regoin: false } })));
expect(findings).toHaveLength(1);
const f = findings[0];
expect(f.severity).toBe('error');
expect(f.path).toBe('dashboards[0].widgets[0].filterBindings.regoin');
expect(f.message).toBe(
'widget "kpi_deals" binds `filterBindings.regoin`, but dashboard "deal_board" declares no filter ' +
'named `regoin` (declared: dateRange, region), so the binding is never read',
);
expect(f.hint).toBe('Rename the key to a declared filter name, or delete the entry. Did you mean "region"?');
});

it('refuses the re-target spelling too, one finding per unknown key', () => {
const findings = only(validateWidgetBindings(stack({}, {
filterBindings: { region: 'sales_region', no_such_filter: 'status', other: false },
})));
expect(findings.map((f) => f.path)).toEqual([
'dashboards[0].widgets[0].filterBindings.no_such_filter',
'dashboards[0].widgets[0].filterBindings.other',
]);
});

it('accepts every key that names a declared filter, dateRange included', () => {
expect(only(validateWidgetBindings(stack({}, {
filterBindings: { dateRange: false, region: 'sales_region' },
})))).toEqual([]);
});

it('a globalFilter with no `name` is bound by its `field`', () => {
const findings = validateWidgetBindings(stack(
{ dateRange: undefined, globalFilters: [{ field: 'region', type: 'select' }] },
{ filterBindings: { region: false } },
));
expect(only(findings)).toEqual([]);
});

it('a custom `name` replaces the field as the key: binding the raw field is refused', () => {
const findings = only(validateWidgetBindings(stack(
{ globalFilters: [{ name: 'deal_region', field: 'region', type: 'select' }] },
{ filterBindings: { region: false } },
)));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('(declared: dateRange, deal_region)');
});

it('`dateRange` is valid only on a dashboard that declares one', () => {
const findings = only(validateWidgetBindings(stack(
{ dateRange: undefined },
{ filterBindings: { dateRange: false } },
)));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('declares no filter named `dateRange` (declared: region)');
expect(findings[0].hint).toBe('Declare `dateRange` on the dashboard, or delete the `dateRange` entry.');
});

it('a dashboard with no filters at all refuses every key and says so', () => {
const findings = only(validateWidgetBindings(stack(
{ dateRange: undefined, globalFilters: undefined },
{ filterBindings: { region: false } },
)));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('declares no filters, so the binding is never read');
expect(findings[0].hint).toBe("Declare the filter in the dashboard's `globalFilters`, or delete the entry.");
});

it('is judged even when the widget binds no dataset, and is not suppressible', () => {
const findings = only(validateWidgetBindings(stack({}, {
dataset: undefined,
filterBindings: { regoin: false },
suppressWarnings: [DASHBOARD_FILTER_BINDING_UNKNOWN],
})));
expect(findings).toHaveLength(1);
});

it('is silent on a widget with no filterBindings', () => {
expect(only(validateWidgetBindings(stack()))).toEqual([]);
});

for (const command of ['validate', 'build'] as const) {
it(`gates \`${command}\``, () => {
const { errors } = splitBySeverity(runAuthoringRules(command, {
normalized: stack({}, { filterBindings: { regoin: false } }),
}));
expect(errors.map((f) => f.rule)).toContain(DASHBOARD_FILTER_BINDING_UNKNOWN);
});
}

it('has an `os explain` entry naming the reserved key and the fix', () => {
const text = explainRule(DASHBOARD_FILTER_BINDING_UNKNOWN)!.paragraphs.join('\n');
expect(text).toContain('`dateRange`');
expect(text).toContain('`field` when `name` is omitted');
expect(text).toContain('never read');
});
});

describe('validateWidgetBindings (dashboard-filter-field-unprovisioned, issue #8340)', () => {
const only = (findings: ReturnType<typeof validateWidgetBindings>) =>
findings.filter((f) => f.rule === DASHBOARD_FILTER_FIELD_UNPROVISIONED);
Expand Down
Loading
Loading