Skip to content

A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148

Description

@os-warren

Measured on @objectstack/cli / @objectstack/spec 17.2.0 while building a manager dashboard in the duly app (objectstack-ai/duly#10). Each mutation was applied to a real, shipping dashboard, confirmed on disk, and both gates re-run.

The interesting part is how narrow the remaining gap is: almost everything on this surface is already checked, including the sibling of one of the two misses.

What IS caught (no action needed — recorded so the gap below is precise)

mutated reference validate build rule
widgets[].datasetduly_stagnatoin 1 1 widget-dataset-unknown (+ "did you mean")
widgets[].dimensions[]business_unitt 1 1 widget-dimension-unknown
widgets[].values[]untouched_over_14 1 1 widget-measure-unknown
{token} inside widgets[].filter{14_days_hence} 1 1 filter-token-unknown, path-precise at dashboards[0].widgets[4].filter.due_date.$lte
dashboard-level dateRange.fieldnonexistent_column 1 1 dashboard-filter-field-unknown (#3365 / #3382)
nav dashboardNameduly_ghost 1 2 defineStack cross-reference validation

A — widgets[].filter keys are not resolved

// widget bound to dataset `duly_workload`, base object `duly_task`
filter: { due_daet: { $gte: '{today}', $lte: '{14_days_from_now}' } },
//        ^^^^^^^^ no such column on duly_task

validate0. build0. The condition reaches the engine, matches nothing, and the widget renders empty.

Two things make this sharper than a generic missing check:

  1. On the same node, the TOKEN is checked and the COLUMN is not. filter-token-unknown fires with the exact path …widgets[4].filter.due_date.$lte, so the traversal already walks into the filter tree and already knows the widget's dataset. Only the key resolution is missing.

  2. The identical resolution already exists one key over. dashboard-filter-field-unknown (Validate dashboard filter field-existence at build time (extend ADR-0021) #3365, shipped in feat(lint): validate dashboard filter field-existence at build time (#3365) #3382) resolves a dashboard-level filter's field against each widget's dataset base object, and its message even prints that object's field list:

    dashboard "duly_duty_health" › widget "not_moving_14d": inherits dashboard filter dateRange(nonexistent_column), but object duly_task (dataset "duly_stagnation") has no field nonexistent_column. … Object fields: subject, duty, owner, business_unit, …

    So dataset → base object → field set is already computed per widget at that point. widgets[].filter is the same field-existence invariant on the filter an author is more likely to write by hand, and it was simply not fed through the same rule.

B — options.sortBy is not checked against what the widget selects

dimensions: ['business_unit'],
values: ['untouched_over_14d'],
options: { sortBy: 'not_selected', sortOrder: 'asc' },

validate0. build0. The spec states the contract in its own words — "Order rows by this dimension or measure name — must be one this widget actually selects" (DashboardWidgetOptionsSchema.sortBy) — and DashboardWidgetOptions is one of the four keys deliberately declared because they reach the analytics query (framework#3588). The runtime falls back to ordering by the selected dimensions, so the authored order silently does not happen.

Why it matters beyond tidiness: for us the authored order is a product rule. A per-unit chart must be ordered by the unit dimension and never by the count, because ordering units by a count turns a workload chart into a league table. Today nothing but a hand-written repo test keeps that from silently degrading into "whatever the runtime picked".

Why silent-empty is the expensive failure here

The dashboard this came from leads with a "not moving" tile — open work untouched >14 days. An empty tile is indistinguishable from a healthy team: a missing number reads as zero, and zero is the answer the manager is hoping for. Every other class of authoring mistake on this surface already fails loudly at build; these two are the ones that ship.

Suggested fix

Both are one pass at the same site that already emits widget-dataset-unknown / widget-dimension-unknown / dashboard-filter-field-unknown:

  • A — for each widget with a filter, walk the condition's field KEYS (descending $and / $or / $not, skipping $-prefixed operator keys) and resolve each against the widget's dataset base object plus the platform system columns. Suggested rule id widget-filter-field-unknown, message shaped like dashboard-filter-field-unknown so the two read alike. A dotted path through a declared include should resolve or be explicitly out of scope, not silently pass.
  • B — error unless options.sortBy is in dimensions[] ∪ values[] for that widget. Suggested widget-sortby-unselected, listing what the widget does select.

Acceptance: both fail validate and build naming dashboard, widget, key and object; fixture coverage for the failing and the clean shape.

Happy to take either one if useful — the shape is mechanical and mirrors #3382.

Repo-local stopgap meanwhile: test/dashboard.test.ts in objectstack-ai/duly resolves both against the datasets barrel, and is written to be deleted when this lands.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions