From dbad0654e5c18b04d103d99d9933d3bf9b5f36d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:33:11 +0000 Subject: [PATCH 1/2] wip(lint): dashboard-filter-binding-unknown refuses a filterBindings key naming no declared dashboard filter (#27445) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzeTeGTp7gp3ECrLPMf --- packages/lint/src/index.ts | 4 + packages/lint/src/rule-explanations.ts | 20 +++ packages/lint/src/runtime-gate.test.ts | 27 +++- .../lint/src/validate-widget-bindings.test.ts | 126 ++++++++++++++++++ packages/lint/src/validate-widget-bindings.ts | 50 +++++++ .../protocol.draft-publish-verdict.test.ts | 48 +++++++ ...ashboard-filter-binding-unknown-refused.ts | 38 ++++++ packages/spec/src/ui/dashboard.zod.ts | 9 +- 8 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-dashboard-filter-binding-unknown-refused.ts diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 542241bd752..7ae8960877b 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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 diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 7b45b3727ae..67a70a91800 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -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', @@ -5177,6 +5196,7 @@ export const RULE_EXPLANATIONS: Readonly> = 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, diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index 9e84d6ab6e4..c289524b536 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -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 @@ -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(); @@ -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', @@ -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 diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index 9063fab647f..3b60081c6b2 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -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, @@ -862,6 +863,131 @@ describe('validateWidgetBindings (dashboard-filter-field-unknown, issue #3365)', }); }); +describe('dashboard-filter-binding-unknown (#27445)', () => { + const only = (findings: ReturnType) => + 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 = {}, widget: Record = {}) { + 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) => findings.filter((f) => f.rule === DASHBOARD_FILTER_FIELD_UNPROVISIONED); diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index 605ce133dcc..a00a229cecb 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -69,6 +69,19 @@ import { * `filter`, at the position one level up — a separate id because the fix is a * different edit (declare the join, versus point the filter somewhere real) * and because the family keeps one id per class. + * - `dashboard-filter-binding-unknown` (#27445) — a widget `filterBindings` + * KEY names no filter the dashboard declares. The runtime looks each binding + * up BY the declared filter's name (objectui `resolveBoundField`: + * `widget.filterBindings?.[def.name]`), so a key no def carries is never read: + * a typo'd opt-out (`{ regoin: false }`) leaves the widget scoped by the very + * filter the author opted it out of, and a typo'd re-target leaves the filter + * on its default field. The key's whole meaning is WHICH rows the widget + * counts, which is why this is an error and not the warning tier + * `chart-field-unknown` took for its ignored keys (#15463): those re-dress + * the same numbers, this one changes them. The declared names are the ones + * {@link dashboardFilterDefs} builds, so the reserved `dateRange` key is + * valid only on a dashboard that declares `dateRange` — the runtime, this + * file and `@objectstack/verify`'s window proof all build that def only then. * * Advisory rules — severity `warning`, build stays green: * @@ -435,6 +448,11 @@ export const DASHBOARD_FILTER_FIELD_UNPROVISIONED = 'dashboard-filter-field-unpr * prefix is not declared in the bound dataset's `include`. */ export const DASHBOARD_FILTER_FIELD_NOT_INCLUDED = 'dashboard-filter-field-not-included'; +/** + * [#27445] A widget `filterBindings` key that names no filter the dashboard + * declares (`dateRange` counts only when the dashboard declares one). + */ +export const DASHBOARD_FILTER_BINDING_UNKNOWN = 'dashboard-filter-binding-unknown'; /** [#14148] A key of the widget's OWN `filter` that resolves to no column. */ export const WIDGET_FILTER_FIELD_UNKNOWN = 'widget-filter-field-unknown'; /** [#14148] A widget filter key whose relationship prefix is not in `include`. */ @@ -837,6 +855,38 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { } } + // ── (a-1) every filterBindings key names a declared filter (#27445) ── + // Judged before the dataset, because it needs none: the declared names + // are the dashboard's alone. The runtime reads a binding BY the def's + // name, so a key no def carries is never read, and the widget keeps the + // filter it meant to drop or re-target. Errored, not warned: what the + // key decides is which rows the widget counts. The fix is the author's + // own edit (rename or delete the entry), so no suppression is offered. + const bindings = w.filterBindings; + if (bindings && typeof bindings === 'object' && !Array.isArray(bindings)) { + const declared = dashFilterDefs.map((d) => d.name); + for (const key of Object.keys(bindings)) { + if (declared.includes(key)) continue; + push({ + severity: 'error', + rule: DASHBOARD_FILTER_BINDING_UNKNOWN, + path: `${path}.filterBindings.${key}`, + message: + `widget "${widgetId}" binds \`filterBindings.${key}\`, but dashboard "${dashName}" ` + + (declared.length > 0 + ? `declares no filter named \`${key}\` (${declaredFew(declared, 'filters')}), ` + : `declares no filters, `) + + `so the binding is never read`, + hint: + key === DATE_RANGE_FILTER_NAME + ? `Declare \`dateRange\` on the dashboard, or delete the \`dateRange\` entry.` + : declared.length > 0 + ? `Rename the key to a declared filter name, or delete the entry.${suggestName(key, declared)}` + : `Declare the filter in the dashboard's \`globalFilters\`, or delete the entry.`, + }); + } + } + // ── (a) dataset reference resolves ── const dsName = typeof w.dataset === 'string' ? w.dataset : undefined; const dataset = dsName ? datasets.get(dsName) : undefined; diff --git a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts index 8edf32595bd..3a3342cd0d7 100644 --- a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts +++ b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts @@ -289,6 +289,23 @@ const CASES: Array<{ rule: string; type: string; item: { name: string } & Record widgets: [{ id: 'kpi_deals', type: 'metric', title: 'Deals', dataset: 'deals_live', values: ['deal_count'] }], }, }, + { + // [#27445] Not one of #26707's six: a widget `filterBindings` key that + // names no filter the dashboard declares (`regoin` for `region`). It + // saved, read valid:true and PUBLISHED before; the new rule's verdict + // rides the same draft-read and publish seams as its five siblings. + rule: 'dashboard-filter-binding-unknown', + type: 'dashboard', + item: { + name: 'deal_binding_board', + label: 'Deal Binding Board', + globalFilters: [{ name: 'region', field: 'name', type: 'select' }], + widgets: [{ + id: 'kpi_deals', type: 'metric', title: 'Deals', dataset: 'deals_live', + values: ['deal_count'], filterBindings: { regoin: false }, + }], + }, + }, { // Reproduction 6: a dataset whose include names no relationship. rule: 'dataset-include-unknown', @@ -440,3 +457,34 @@ describe('a draft reads the verdict its publish gives (#26707)', () => { ); }); }); + +describe('[#27445] a stored dashboard carrying an undeclared filterBindings key', () => { + const board = CASES.find((c) => c.rule === 'dashboard-filter-binding-unknown')!.item; + + it('still loads unchanged — the rule judges the authoring path, never the read', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + // A row written before the rule existed, stored verbatim the way + // `saveMetaItem` stores one: no gate ran when it landed. + await engine.insert('sys_metadata', { + type: 'dashboard', name: board.name, organization_id: null, package_id: PKG, + state: 'active', metadata: JSON.stringify(board), + }); + expect([...rows.values()].map((r) => r.name)).toContain(board.name); + + const read: any = await protocol.getMetaItem({ type: 'dashboard', name: board.name, packageId: PKG } as any); + expect(read.item.widgets[0].filterBindings).toEqual({ regoin: false }); + }); + + it('is refused on its next save, naming the key and the declared filters', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const err: any = await (protocol as any) + .saveMetaItem({ type: 'dashboard', name: board.name, item: board, packageId: PKG }) + .then(() => null, (e: unknown) => e); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issue = err.issues.find((i: any) => i.rule === 'dashboard-filter-binding-unknown'); + expect(issue.message).toContain('`filterBindings.regoin`'); + expect(issue.message).toContain('(declared: region)'); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-filter-binding-unknown-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-filter-binding-unknown-refused.ts new file mode 100644 index 00000000000..ed4331e87e5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-filter-binding-unknown-refused.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// An accept-set narrowing with no conversion: only the author knows which +// declared filter a misspelled key meant, or whether the entry should go. +export const entry: SemanticMigration = { + id: 'ui-dashboard-filter-binding-unknown-refused', + surface: 'dashboard.widgets[].filterBindings — a KEY that names no filter the dashboard ' + + 'declares (no `globalFilters` entry under that name, its `field` when unnamed, and not ' + + '`dateRange` on a dashboard that declares `dateRange`)', + replacement: 'the key renamed to a declared filter name (the refusal names the nearest one), the ' + + 'filter declared on the dashboard, or the entry deleted. A `dateRange` key binds only on a ' + + 'dashboard that declares `dateRange`.', + reason: + 'The schema types `filterBindings` as an open record, so any key parsed, and no author-time rule ' + + 'checked the keys against the dashboard: `dashboard-filter-field-unknown` judges only the ' + + 'EFFECTIVE field of each declared filter. The runtime reads a binding by looking up each ' + + 'declared filter\'s name (`widget.filterBindings?.[def.name]` in the dashboard renderer), so a ' + + 'key no filter carries is never read. A misspelled opt-out (`{ regoin: false }`) therefore ' + + 'leaves the widget scoped by the filter it was meant to ignore, and a misspelled re-target ' + + 'leaves the filter on its default field: the widget counts different rows than the author ' + + 'wrote and the page says nothing. The lint rule `dashboard-filter-binding-unknown` now refuses ' + + 'such a key at `os validate`, `os build` and the metadata publish door (`422 ' + + 'INVALID_METADATA`); a draft still saves and its `_diagnostics` report the same verdict. ' + + 'There is no mechanical rewrite: a conversion cannot know which filter a misspelled key ' + + 'meant, and deleting the entry on the author\'s behalf would hide the opt-out they believe is ' + + 'in force. A stored `sys_metadata` dashboard carrying such a key still loads and renders as ' + + 'before (the rule runs on the authoring path, not the load path); its next save or publish is ' + + 'refused until the key is fixed.', + acceptanceCriteria: + 'Every dashboard widget\'s `filterBindings` keys each name a filter that dashboard declares: ' + + 'a `globalFilters[].name` (or the entry\'s `field` when it has no `name`), or `dateRange` on a ' + + 'dashboard that declares `dateRange`. `os validate` reports no ' + + '`dashboard-filter-binding-unknown` finding, and each affected dashboard publishes. Measured ' + + 'before the change: no dashboard in this repository\'s examples, platform objects, docs or ' + + 'skills, nor in the public HotCRM app, binds an undeclared key.', +}; diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 2260cb9ce70..b9873204205 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -1389,9 +1389,16 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ * - false → opt this widget out of that filter * - absent → default binding: the filter's own `field` * (dateRange: `dateRange.field ?? 'created_at'`) + * + * Every key must name a filter the dashboard declares — a `globalFilters` + * entry's `name` (its `field` when unnamed), or `dateRange` when the + * dashboard declares `dateRange`. The runtime reads a binding by the declared + * filter's name, so any other key is never read; the lint rule + * `dashboard-filter-binding-unknown` (severity error) refuses it at + * `os validate` / `os build` and at the metadata publish door. */ filterBindings: z.record(z.string(), z.union([z.string(), z.literal(false)])).optional() - .describe("Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out") + .describe("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") .meta({ title: 'Filter Bindings' }), /** From 4759433d4c2601f6e4a20f1d2c105391232cc7f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:40:03 +0000 Subject: [PATCH 2/2] wip: docs regen, changeset, stored-row pin --- .../27445-dashboard-filter-binding-unknown.md | 20 ++++++++++++++++ .../docs/deployment/validating-metadata.mdx | 7 ++++++ content/docs/references/ui/dashboard.mdx | 4 ++-- content/docs/ui/dashboards.mdx | 7 ++++++ .../protocol.draft-publish-verdict.test.ts | 24 +++++++++++++------ 5 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 .changeset/27445-dashboard-filter-binding-unknown.md diff --git a/.changeset/27445-dashboard-filter-binding-unknown.md b/.changeset/27445-dashboard-filter-binding-unknown.md new file mode 100644 index 00000000000..7a0f8eec891 --- /dev/null +++ b/.changeset/27445-dashboard-filter-binding-unknown.md @@ -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 + + + +**BREAKING** accept-set narrowing at every authoring door for `dashboard`. + +- **Before.** `PUT /api/v1/meta/dashboard/?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.` 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. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index b67af7fc897..a3065fc4e1b 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -57,6 +57,13 @@ dashboard, widget, filter, field, and object. Opt a widget out with `filterBindings: { : 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 diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index f0d7e167c9e..22d19cafba6 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -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` | optional | Widget specific configuration | -| **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | +| **filterBindings** | `Record` | 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` @@ -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` | optional | Widget specific configuration | -| **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | +| **filterBindings** | `Record` | 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` diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index 80acba5e26c..1d40758963b 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -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: @@ -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 diff --git a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts index 3a3342cd0d7..386ed0eacb5 100644 --- a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts +++ b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts @@ -102,6 +102,12 @@ function matchesMetadataWhere(r: Row, where: Record): boolean { continue; } if (v === undefined) continue; + // The active read asks `type: { $in: [type, plural] }`; no other + // operator reaches this stub. + if (v && typeof v === 'object' && Array.isArray((v as { $in?: unknown }).$in)) { + if (!(v as { $in: unknown[] }).$in.includes((r as any)[k])) return false; + continue; + } if ((r as any)[k] !== v) return false; } return true; @@ -464,13 +470,17 @@ describe('[#27445] a stored dashboard carrying an undeclared filterBindings key' it('still loads unchanged — the rule judges the authoring path, never the read', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - // A row written before the rule existed, stored verbatim the way - // `saveMetaItem` stores one: no gate ran when it landed. - await engine.insert('sys_metadata', { - type: 'dashboard', name: board.name, organization_id: null, package_id: PKG, - state: 'active', metadata: JSON.stringify(board), - }); - expect([...rows.values()].map((r) => r.name)).toContain(board.name); + // A row written while no rule judged it: the save path with the + // authoring gate switched off, which stores the body verbatim exactly + // as a write made before this rule existed did. + process.env.OS_ALLOW_UNLINTED_METADATA_WRITES = '1'; + try { + const saved: any = await (protocol as any).saveMetaItem({ type: 'dashboard', name: board.name, item: board, packageId: PKG }); + expect(saved.success).toBe(true); + } finally { + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + } + expect([...rows.values()].filter((r) => r.name === board.name).map((r) => r.state)).toEqual(['active']); const read: any = await protocol.getMetaItem({ type: 'dashboard', name: board.name, packageId: PKG } as any); expect(read.item.widgets[0].filterBindings).toEqual({ regoin: false });