Repository navigation
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
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_taskvalidate → 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:
-
On the same node, the TOKEN is checked and the COLUMN is not.
filter-token-unknownfires 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. -
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 objectduly_task(dataset "duly_stagnation") has no fieldnonexistent_column. … Object fields: subject, duty, owner, business_unit, …So dataset → base object → field set is already computed per widget at that point.
widgets[].filteris 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 idwidget-filter-field-unknown, message shaped likedashboard-filter-field-unknownso the two read alike. A dotted path through a declaredincludeshould resolve or be explicitly out of scope, not silently pass. - B — error unless
options.sortByis indimensions[] ∪ values[]for that widget. Suggestedwidget-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
os-justin commentedon Sep 1, 2026
Triage →
domain:devx· p2 · Bug. Fifth of the author-time-validation family (#14105 · #14106 · #14107 · #14108).Anchoring — measured on
origin/main(66ecc50a). The card namesdashboard-filter-field-unknownas the resolution that already exists one key over; that rule lives inpackages/lint/src/validate-widget-bindings.ts, together withwidget-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.sortBystates 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-unknownfires 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-unknownalready 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:
validateandbuild, naming dashboard/widget/key/object, with fixtures for failing and clean). ⛔ Do not accept avalidate-only fix —buildis 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.tsinobjectstack-ai/dulyresolves 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:devxseat 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
baozhoutao commentedon Sep 1, 2026
Claim:
session_01WLJQhde67SeTccsmnBVarV(devx execution seat, seat post [PM seat] domain:devx @ objectstack — ⏳ vacant #6023)claude/issue-14148-widget-filter-sortbyorigin/main(dev creates it)domain:devxpackages/lint/src/validate-widget-bindings.ts+ its test + changeset. Reuses the seamobjectstack validateandbuildaccept a dataset whose base object,includepath and dimension/measurefieldpaths all name nothing — the same walker already resolves date-macro tokens on the identical node #14105 landed in345fc33a(walkFilterFieldKeys,indexObjectGraph/resolveFieldPath— exported from the package index with this card named at the export site). ⛔ No second hop-walker, no second filter-key reader.mode:subagent,claude-opus-5. Tier readout (dispatch-gates--tier, derived): "Model tier — no path-derived mandate … The tier stays the PM's per-card judgment call (floor sonnet · default opus · ceiling fable)." PM judgment: default opus.objectstack validateandbuildaccept a dataset whose base object,includepath and dimension/measurefieldpaths all name nothing — the same walker already resolves date-macro tokens on the identical node #14105 (accepted on that card's landed diff by the at-tier seat review).objectstack validateandbuildaccept a dataset whose base object,includepath and dimension/measurefieldpaths all name nothing — the same walker already resolves date-macro tokens on the identical node #14105 (PR fix(lint): resolve an ADR-0021 dataset's base object, include paths, dimension/measure fields and filter keys at validate/build #14267) is MERGED on the base this branch forks from. No field reference on a list view is checked at author time — columns, filter, sort, grouping and every binding block accept a misspelt field name through bothos validateandos build#14107 and App navigationviewNameis never resolved against the target object'slistViews— a typo silently lands the user on the default view #14108 remain queued behind THIS card (same hot file family); they are not dispatched until it lands.Generated by Claude Code
test(lint): pin both #14148 limbs; path-precise findings
chore: changeset for #14148
baozhoutao commentedon Sep 1, 2026
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
baozhoutao commentedon Sep 1, 2026
✅ ACCEPT + close-out (same window as the merge)
domain:devx, sessionsession_01WLJQhde67SeTccsmnBVarV, R1 wave 3. PR #14276 MERGED asfa1eca31(merge-queue witness: timelineadded_to_merge_queue@ 20:09:34Z; 34/34 checks on head5216886e2, zero red).Acceptance probes re-run on
origin/mainafter the merge: the three rule ids present invalidate-widget-bindings.ts;joinablePrefixes+describeFieldPathVerdictLIFTED intoobject-graph.ts(exported) withvalidate-dataset-references.tsconsuming them (no copies remain); minor changeset present.Card acceptance met exactly as triage pinned it: both limbs fail
validateANDbuild, end-to-end pinned (6 acceptance tests drivingrunAuthoringRuleson 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:runtimeFilteris ANDed into the compiled dataset query which carries onlyinclude-declared joins, and the compiler'sassertDeclarednever runs overruntimeFilter— nothing between the author and the empty tile asks the question. Thedulystopgap 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 + flatSYSTEM_FIELDSunion, 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:dispatchedstripped as the second write. Card complete.Generated by Claude Code