Found while implementing #14148 (widget filter keys + options.sortBy). Not fixed there: it is a behaviour change to a different, already-shipped rule, and folding it into that PR would have widened its accept-set narrowing without its own measurement.
The two gaps
dashboard-filter-field-unknown lives in packages/lint/src/validate-widget-bindings.ts and resolves a dashboard-level filter's effective field against each widget's dataset base object. Two limitations are now visible because the code immediately beside it does neither:
1. Every dotted path is skipped outright.
// A relationship path (`account.region`) is resolved by the query
// engine, not a base column, so it can't be checked here -- skip it.
if (field.includes('.')) continue;
That comment was accurate when written — nothing in packages/lint could walk relationship hops. It is no longer: resolveFieldPath (object-graph.ts, landed in #14267 for #14105) walks hops through declared relationship fields and returns a discriminated verdict, and joinablePrefixes expands a dataset's include into the set ADR-0021 actually joins. #14148 uses both at the sibling position (a widget's own filter keys) precisely so a dotted path resolves rather than silently passing.
So a dashboard-level filter re-targeted via filterBindings: { dateRange: 'account.signed_at' } is unjudged today whether or not account exists, whether or not signed_at exists on it, and whether or not account is declared in the dataset's include.
2. It resolves against the object-independent SYSTEM_FIELDS union rather than the per-object injected set.
if (objectFields.has(field) || SYSTEM_FIELDS.has(field)) {
object-graph.ts deliberately does not do this, and states why: the two differ exactly where it matters — on ownership: 'none' the platform injects no owner_id, so a filter naming it there is a real defect that the union answers as resolvable. injectedColumnsFor(obj) is the per-object answer and is already exported.
Why it matters rather than being tidiness
The consequence is the one that rule exists to prevent, and it is stated in its own module doc: a dashboard-level filter is ANDed into every widget's analytics query (#2501), so a field that does not resolve either crashes the widget at query time or narrows it to nothing. Both gaps are the silent half. A dashboard filter is authored once and broadcast to every widget on the board, so a single unjudged dotted re-target degrades the whole dashboard, not one tile.
Suggested shape
Migrate the check onto the seam, mirroring what #14148 does one key over: resolveFieldPath for existence (skipping isUnjudgeable verdicts), then the joinablePrefixes clause for a dotted path, keeping dashboard-filter-field-unknown as the existence id. Note dashboard-filter-field-unprovisioned (#8340) sits on the same branch and reads the resolved-but-unprovisioned case, so the migration has to keep that second question answerable — FieldPathVerdict's injected marker is what carries it.
⚠️ This narrows the accept set on a shipped gating rule, so it needs its own fixture pass over the shipped dashboards before landing, not just unit tests — the same false-positive budget #14267 paid.
Not a duplicate
#3365 / #3382 shipped this rule; this is the two limitations it shipped with, both now cheaply closable. #14148 closes the sibling position (a widget's own filter) and explicitly does not touch this one.
Found while implementing #14148 (widget
filterkeys +options.sortBy). Not fixed there: it is a behaviour change to a different, already-shipped rule, and folding it into that PR would have widened its accept-set narrowing without its own measurement.The two gaps
dashboard-filter-field-unknownlives inpackages/lint/src/validate-widget-bindings.tsand resolves a dashboard-level filter's effective field against each widget's dataset base object. Two limitations are now visible because the code immediately beside it does neither:1. Every dotted path is skipped outright.
That comment was accurate when written — nothing in
packages/lintcould walk relationship hops. It is no longer:resolveFieldPath(object-graph.ts, landed in #14267 for #14105) walks hops through declared relationship fields and returns a discriminated verdict, andjoinablePrefixesexpands a dataset'sincludeinto the set ADR-0021 actually joins. #14148 uses both at the sibling position (a widget's ownfilterkeys) precisely so a dotted path resolves rather than silently passing.So a dashboard-level filter re-targeted via
filterBindings: { dateRange: 'account.signed_at' }is unjudged today whether or notaccountexists, whether or notsigned_atexists on it, and whether or notaccountis declared in the dataset'sinclude.2. It resolves against the object-independent
SYSTEM_FIELDSunion rather than the per-object injected set.object-graph.tsdeliberately does not do this, and states why: the two differ exactly where it matters — onownership: 'none'the platform injects noowner_id, so a filter naming it there is a real defect that the union answers as resolvable.injectedColumnsFor(obj)is the per-object answer and is already exported.Why it matters rather than being tidiness
The consequence is the one that rule exists to prevent, and it is stated in its own module doc: a dashboard-level filter is ANDed into every widget's analytics query (#2501), so a field that does not resolve either crashes the widget at query time or narrows it to nothing. Both gaps are the silent half. A dashboard filter is authored once and broadcast to every widget on the board, so a single unjudged dotted re-target degrades the whole dashboard, not one tile.
Suggested shape
Migrate the check onto the seam, mirroring what #14148 does one key over:
resolveFieldPathfor existence (skippingisUnjudgeableverdicts), then thejoinablePrefixesclause for a dotted path, keepingdashboard-filter-field-unknownas the existence id. Notedashboard-filter-field-unprovisioned(#8340) sits on the same branch and reads the resolved-but-unprovisioned case, so the migration has to keep that second question answerable —FieldPathVerdict'sinjectedmarker is what carries it.Not a duplicate
#3365 / #3382 shipped this rule; this is the two limitations it shipped with, both now cheaply closable. #14148 closes the sibling position (a widget's own
filter) and explicitly does not touch this one.