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

Closed

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[].dataset → duly_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.field → nonexistent_column 1 1 dashboard-filter-field-unknown (#3365 / #3382)
nav dashboardName → duly_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

validate → 0. build → 0. 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' },

validate → 0. build → 0. 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

  1. os-justin commented on Sep 1, 2026

    @os-justin
    Collaborator

    Triage → domain:devx · p2 · Bug. Fifth of the author-time-validation family (#14105 · #14106 · #14107 · #14108).

    Anchoring — measured on origin/main (66ecc50a). The card names dashboard-filter-field-unknown as the resolution that already exists one key over; that rule lives in packages/lint/src/validate-widget-bindings.ts, together with widget-dataset-unknown / widget-dimension-unknown. Both proposed rules land at that same site. ⇒ packages/lint ⇒ domain:devx.

    Type = Bug. Limb B is the clearer of the two: DashboardWidgetOptionsSchema.sortBy states its own contract in prose — "Order rows by this dimension or measure name — must be one this widget actually selects" — and nothing enforces it. Declared ≠ enforced, textbook. Limb A is the same ADR-0078 silently-inert-metadata invariant as its four siblings. Both narrow the accept set ⇒ ⛔ not a Feature.

    Grade p2.

    ⭐ This card makes the family's best harm argument and it deserves to be quoted forward, not paraphrased: the dashboard leads with a "not moving" tile — open work untouched >14 days — and "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." ⇒ the failure is not merely silent, it is silent in the direction the reader wants to believe. That is the strongest single sentence in the five-card family for why author-time resolution is worth gating rather than documenting.

    ⛔ Still not p1: authoring-time, no security boundary, no data corruption, and the remedy is mechanical.

    ⭐ The shared root — this card is one of three faces of ONE missing mechanism

    The card's §A observes that on the very same node, the TOKEN is checked and the COLUMN is not — filter-token-unknown fires path-precise at …widgets[4].filter.due_date.$lte, so the traversal already walks the filter tree and already knows the widget's dataset.

    #14105 §4 records the identical observation for datasets. And dashboard-filter-field-unknown already implements exactly this resolution — dataset → base object → field set — for dashboard-level filters, printing the object's field list in its message.

    ⇒ Filter field-key resolution exists in one of the three places it is needed (dashboard-level ✅ · widget-level ❌ · dataset-level ❌). Whoever takes this group should generalise the one working implementation, ⛔ not write three parallel rules. Flagged for the lane's fold-or-serial call — four of the five siblings share packages/lint/src, and these two share a single file.

    Two things this card gets right that should not be lost in implementation:

    1. ⭐ Its "what IS caught" table is a six-row positive control establishing that the surface is genuinely in the validation path — so the two zeros are readings, ⛔ not "the file was never walked". That is the control discipline this repo keeps paying for elsewhere; it is already done here.
    2. Its acceptance criterion already names the right shape (fail both validate and build, naming dashboard/widget/key/object, with fixtures for failing and clean). ⛔ Do not accept a validate-only fix — build is the publish gate and is where these currently ship.

    Open sub-question the implementer must answer explicitly, ⛔ not silently: the card flags that a dotted path through a declared include "should resolve or be explicitly out of scope, not silently pass." ⇒ whichever is chosen, it must be stated in the PR; a third silent pass-through would reproduce this very card.

    Stopgap to retire: test/dashboard.test.ts in objectstack-ai/duly resolves both, written to be deleted when this lands — the PR should say whether that deletion is in scope downstream or a follow-up there.

    Offer on the card: the reporter volunteers to take either limb. ⛔ Not triage's call to accept — routing only; the domain:devx seat owns dispatch and claiming.

    Dedup: #3365/#3382 shipped dashboard-filter-field-unknown — that is the precedent and the reusable implementation, ⛔ not a duplicate (different node: dashboard-level vs widget-level filter). ⛔ No duplicate.


    Generated by Claude Code

  2. added theissue type on Sep 1, 2026
  3. self-assigned this
    on Sep 1, 2026
  4. baozhoutao commented on Sep 1, 2026

    @baozhoutao
    Contributor

    Claim:


    Generated by Claude Code

  5. added 2 commits that reference this issue on Sep 1, 2026
  6. baozhoutao commented on Sep 1, 2026

    @baozhoutao
    Contributor

    os-dev-report

    {
      "issue": 14148,
      "status": "done",
      "branch": "claude/issue-14148-widget-filter-sortby",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14276",
      "premise_still_valid": true,
      "premise_detail": "All three PM premises verified on base 345fc33ad (PR #14267) before any code. (1) Seam landed and exported from packages/lint/src/index.ts: walkFilterFieldKeys, indexObjectGraph, resolveFieldPath; export-site comment names THIS card as intended consumer. (2) Gap A holds: validate-widget-bindings.ts at base never reads w.filter. (3) Gap B holds: validate-sortable-fields.ts explicitly defers DashboardWidgetOptionsSchema.sortBy to 'validateChartBindings' family, which never judged it. Card's six-row positive-control table still holds at base.",
      "summary": "Both limbs land at packages/lint/src/validate-widget-bindings.ts, on the mandated #14105 seam - no second hop-walker, no second filter-key reader. Three gating rule ids: widget-filter-field-unknown (filter KEYS resolved against the dataset base object graph), widget-filter-field-not-included (the ADR-0021 include clause), widget-sortby-unselected (sortBy must be in dimensions U values, with a distinct message for declared-but-unselected). Findings are path-precise (dashboards[i].widgets[j].filter.KEY and .options.sortBy), which required widening the file's push helper to allow a per-position path override. Two helpers local to the sibling rule (joinablePrefixes, existenceMessage -> describeFieldPathVerdict) were LIFTED into object-graph.ts and exported rather than copied; sibling behaviour unchanged, its 526-line test file passes untouched. NOTE on claim protocol: the PM had already posted the claim comment carrying this exact session ID and branch, so a second claim comment was deliberately NOT posted (would be duplicate noise); the dispatch prompt asked for one, AGENTS.md/agent spec says the PM claim satisfies claim-first - flagging the conflict rather than silently picking a side.",
      "files_changed": [
        "packages/lint/src/validate-widget-bindings.ts (both limbs + module doc + push path override)",
        "packages/lint/src/validate-widget-bindings.test.ts (+27 tests: rule behaviour, 3 skips, all 3 filter shapes, include clause, no-double-report, and 6 end-to-end validate/build acceptance pins)",
        "packages/lint/src/object-graph.ts (lifted joinablePrefixes + describeFieldPathVerdict into the shared seam)",
        "packages/lint/src/validate-dataset-references.ts (consumes the lifted helpers; local copies deleted)",
        "packages/lint/src/index.ts (barrel: 3 new rule ids + 2 new seam exports - pinned by rule-id-barrel-exports.test.ts)",
        ".changeset/widget-filter-sortby-resolution.md (minor @objectstack/lint - narrows accept set, per the #14105 precedent)"
      ],
      "dotted_path_decision": "RESOLVE IT - not out of scope. Stated explicitly in the PR body under its own heading, as triage required. Mechanical grounds, not taste: a widget filter is ANDed into the dataset query as runtimeFilter (dataset-executor.ts combineFilters(compiled.filter, selection.runtimeFilter)) and that compiled query carries ONLY the joins include declared (dataset-compiler.ts derives every join alias from include). So the same two clauses the dataset sibling applies - existence, then joinability - apply here. Load-bearing finding: the runtime is NOT a backstop for the second clause; dataset-compiler.ts assertDeclared is called at exactly two sites, on dimensions and on measures, NEVER over runtimeFilter (verified by grep: lines 423 and 452 only). So nothing between the author and the empty tile asked this question.",
      "tests": "ALL readings taken on final commit 5216886e2. (1) pnpm --filter @objectstack/lint test -> 'Test Files 90 passed (90) / Tests 2558 passed (2558)'. (2) pnpm --filter @objectstack/lint typecheck -> clean. (3) pnpm lint (repo-scale eslint . --no-inline-config) -> exit 0, NO narrowing needed, 96s under the lock. (4) ABLATION: implementation committed FIRST, then both limb guards disabled. Mutation proven on disk before measuring - injected-marker count 2, original-anchor-A count 0, blob hash changed from HEAD blob 4c3379d7. Result 13 failed / 75 passed, including all 4 validate+build acceptance pins. Restore under an EXIT INT TERM trap with absolute REPO_ROOT path, then PROVEN: git diff HEAD empty, git hash-object back to exactly 4c3379d74c386d50f13e6d7bf7c07133816f745f, 0 markers left. NO REBUILD LEG NEEDED and none skipped - the test reaches the rule by RELATIVE source import ('./validate-widget-bindings.js'), resolved by vitest to src/, never through package exports/dist; nothing in the chain crosses a package boundary. Clean-shape tests stay GREEN under ablation (correct - an ablated rule reports nothing), so the 75 passes are not evidence. (5) ACCEPTANCE pinned end-to-end rather than inferred from the registry: 6 tests drive runAuthoringRules('validate'|'build') + splitBySeverity and assert the rule id lands in errors, plus a clean shape passing on both.",
      "checks_run": "33 families derived from the REAL change set via 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands' (29 by path + 6 by kind, 2 overlapping), harvested with --commands so no spelling or section could be dropped. 31 GREEN. Plus both dispatch-named gates: check-ratchet-remedy-authority GREEN (183 scripts swept) and check:declared-population-live GREEN (156/200 families reach the tree). Two initially returned exit 3 = PREREQUISITE NOT MET (not red, not measured): check:dual-build-cjs-loads and check:type-check-debt. Built the full closure (turbo run build over ./packages/* and ./packages/*/*, 70 tasks, 4m16s) and re-ran BOTH: dual-build-cjs-loads GREEN; check-type-check-coverage --re-measure GREEN - judgment line quoted: '27 ledger entr(ies) re-measured in 272.3s, 1217 raw tsc error(s) total, none above its recorded number.' check:type-check-coverage GREEN. check:nul-bytes GREEN plus a self-scan of the diff for raw control bytes (clean). Every exit code captured by redirect-then-read, never across a pipe. ONE family NOT MEASURED, by its own printed instruction: scripts/check-test-completeness.mjs needs a saved 'turbo run test' log that CI tees; with no log it exits 3 and its own text says to record it as NOT MEASURED, 'not a red, and there is nothing here to fix'.",
      "not_measured": "scripts/check-test-completeness.mjs (needs a CI-teed test log). Separately: packages/lint/tsconfig.json excludes **/*.test.ts, so the package typecheck says NOTHING about the test file this PR adds - proven by tsc --listFiles returning the 3 source files and not the test. Measured it separately under a temporary include-tests config: 0 errors from this diff, 22 pre-existing across 7 untouched test files. That separate run CAUGHT A REAL DEFECT in this PR's own test helper (a non-generic idsOf that erased the finding type) which vitest cannot see and would otherwise have shipped.",
      "mcp_calls": 6,
      "channel_note": "Read channel probed before use, per protocol. Repo-scoped REST reads WORK here (GET /repos/.../issues -> HTTP 200) and were used for the PR body read-back at zero MCP cost, but the REST SEARCH endpoint is blocked for this session: 'This GitHub API path is not available: sessions are bound to their configured repositories.' Declared channel change: dedup ran as ONE targeted MCP search_issues, not a wide-table scan. It returned a real hit (#14173), which is its own control that the read was live.",
      "open_questions": [],
      "followups_filed": [
        "filed as #14275: dashboard-filter-field-unknown (same file) still skips EVERY dotted filter field ('if (field.includes('.')) continue;') and resolves against the flat SYSTEM_FIELDS union instead of the per-object injected set - the two differ exactly where it matters (on ownership:'none' the platform injects no owner_id). Both now cheaply closable on the seam. Deliberately NOT folded in: it narrows the accept set of a DIFFERENT already-shipped gating rule and needs its own false-positive pass over the shipped dashboards.",
        "NOT re-filed - #14173 already open and is exactly the packages/lint test-typecheck gap; referenced from the PR body instead of duplicating, with this run's current reading (22 errors / 7 files, none from this diff) added as evidence."
      ],
      "out_of_scope_findings": [
        "filed as #14275: dashboard-filter-field-unknown skips dotted paths and uses the flat SYSTEM_FIELDS union",
        "pre-existing #14173 (not re-filed): packages/lint has no tsc program compiling its tests"
      ],
      "downstream_note": "objectstack-ai/duly test/dashboard.test.ts is the stopgap written to be deleted when this lands. Stated in the PR body as a FOLLOW-UP IN THAT REPO, not this PR's scope; this PR does not touch duly.",
      "scope_note": "Both limbs ride the existing validateWidgetBindings registry entry, whose surfaces is per-RULE - so the 3 new error ids also reach the runtime publish gate for dashboard writes, exactly as the 6 existing error ids do. Safe in the direction that matters: RuntimeStackContext carries objects, so the graph resolves there; were it ever not carried, resolveFieldPath returns unknowable and the rules go SILENT rather than inventing findings.",
      "pr_state": "DRAFT, base main, body read back in full and byte-identical to what was sent (11089 chars, session-URL footer intact). Not marked ready. Per the dispatch contract the report is delivered at draft-PR time; CI convergence is the PM's to read."
    }

    Generated by Claude Code

  7. baozhoutao commented on Sep 1, 2026

    @baozhoutao
    Contributor

    ✅ ACCEPT + close-out (same window as the merge)

    domain:devx, session session_01WLJQhde67SeTccsmnBVarV, R1 wave 3. PR #14276 MERGED as fa1eca31 (merge-queue witness: timeline added_to_merge_queue @ 20:09:34Z; 34/34 checks on head 5216886e2, zero red).

    Acceptance probes re-run on origin/main after the merge: the three rule ids present in validate-widget-bindings.ts; joinablePrefixes + describeFieldPathVerdict LIFTED into object-graph.ts (exported) with validate-dataset-references.ts consuming them (no copies remain); minor changeset present.

    Card acceptance met exactly as triage pinned it: both limbs fail validate AND build, end-to-end pinned (6 acceptance tests driving runAuthoringRules on both commands), findings path-precise, ablation-proven (13 red with guards disabled, restore blob-verified). The mandated dotted-path sub-question was answered RESOLVE, on mechanical grounds recorded in the PR and the module doc: runtimeFilter is ANDed into the compiled dataset query which carries only include-declared joins, and the compiler's assertDeclared never runs over runtimeFilter — nothing between the author and the empty tile asks the question. The duly stopgap deletion is noted as a follow-up in that repo.

    Seam discipline held: no second hop-walker, no second filter-key reader — the two helpers that would have been copied were moved into the shared seam instead. Follow-up #14275 filed (the sibling dashboard-filter-field-unknown's dotted-path skip + flat SYSTEM_FIELDS union, now cheaply closable on the seam); #14173 referenced with fresh evidence rather than duplicated.

    Serial queue advances: #14107 (list-view field positions) is next on this family and dispatches now; #14108 behind it.

    pm:dispatched stripped as the second write. Card complete.


    Generated by Claude Code

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