You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
finding(plugin-charts): the ObjectChart drill-down spread mis-composes the filter ARRAY arm into index keys, silently dropping the widget's own filter #8944
Found while reworking PR objectui#8884 (card objectui#7946), and named as a successor by that PR's
contract review. Filed rather than fixed there: it is a behaviour change to the drill-down query, not
part of the ruled declaration work.
The defect
ObjectChartSchema.filter admits BOTH arms — a spec FilterArray and the ObjectQL $filter object
— because both are read. The drill-down composition in packages/plugin-charts/src/ObjectChart.tsx handles only one of them:
Spreading an ARRAY into an object literal does not produce conditions — it produces index keys.
An author who writes the arm @objectstack/spec publishes for this prop, and whom this component's
own registry inputs advertise ({ name: 'filter', type: 'array' }):
{ "filter": [["stage", "=", "won"]] }
drills with
{ "0": ["stage", "=", "won"], "stage": "won" }
The widget's own filter is silently lost — the "0" key means nothing to the query layer — so the
drilled list is scoped by the clicked category ALONE and shows records the chart itself excludes.
Nothing errors; the drawer opens and looks right.
The record arm works, which is why this survived: the in-repo corpus authors the object form, and the
array arm is the one the protocol advertises.
Why this blocks something else
ObjectChartSchema.filter is the only object-* widget filter on packages/types/src/objectql.ts
carrying a record arm — the six siblings are already array-only. Narrowing this node to the spec's
array-only FilterArray would make the declaration match the protocol and the palette, and it is
blocked on exactly this composition: narrow first and every live chart that authors the object form
stops compiling; fix this first and the narrowing becomes a normal deprecation.
So this card is the prerequisite for closing that divergence, and objectui#8884's filter docblock
names it as such.
Acceptance
A drill-down on a chart whose filter is the ARRAY arm produces a filter that still carries the
widget's own conditions, in whatever spelling the query layer takes.
Both arms pinned at runtime, including the target: 'navigate' path (openRecordList receives the
composed filter) — the drawer and the navigate arm share one drillFilter memo, so one pin covers
both if it asserts the composed value rather than the rendered rows.
The pin fails on the current code. A test that only exercises the record arm is green today and
measures nothing.
Triage: lands in packages/plugin-charts/src/ObjectChart.tsx (the drill-down useMemo); domain:ui; priority:p2.
ObjectChartSchema.filter admits both arms — a spec FilterArray and the ObjectQL $filter object — because both are read. The drill-down composition spreads only the object arm:
⇒ spreading an array produces index keys (0, 1, …), so the widget's own filter is silently dropped from the drill-down query and replaced by nonsense keys the server ignores.
⇒ p2, and ⚠️ note the direction of the failure: the widget's filter is what narrows the data. Dropping it means the drill-down can show rows the chart itself was scoped to exclude. ⛔ That is not a security boundary — do not treat it as one — but it is a wrong-and-wider result, which is the worse direction for a silent bug.
⇒ Compose both arms explicitly. ⚠️ Composing a FilterArray with a $filter object is a real semantic question (how do the two combine?), ⛔ not a spread. If the answer is not already settled by an existing helper, name the rule in the PR rather than picking silently.
⚠️Hot file — objectui#8613 also lands in ObjectChart.tsx (runAggregate's missing METRIC structured branch). ⇒ serial, ⛔ not folded.
{
"issue": 8944,
"status": "done",
"branch": "claude/issue-8944-chart-drill-filter-compose",
"pr": "https://github.com/objectstack-ai/objectui/pull/9016",
"premise_still_valid": true,
"summary": "The card's premise held exactly. ObjectChart's drill useMemo composed the widget filter with the click context by spreading, which is correct for the object arm and yields index keys for the array arm — measured pre-fix as {\"0\":[\"region\",\"=\",\"emea\"],\"stage\":\"won\"}. Both arms are genuinely read (both go verbatim to ds.aggregate/ds.find as $filter), so both had to survive. The composition rule was ALREADY SETTLED by an existing helper, so I did not stop at the fence: mergeFilterNodes is described in-repo as the single filter sink and its contract is exactly 'combine filter sources under a single and, each as its OWN child'. A new composeDrillFilter seam in @object-ui/core applies it and lowers the result back to the FilterCondition object dialect via parseFilterAST, because that is the dialect both drill sinks take. One consequence had to be fixed with it: the composed $and reaching serializeDrillFilterParams took a String(value) path and emitted filter[$and]=[object Object] while both real conditions vanished — the same widening failure, so leaving it would have moved the bug rather than fixed it. Also updated the now-false docblock on ObjectChartSchema.filter, which named this spread as the blocker for narrowing that node.",
"tests": "ALL GREEN. type-check (core+plugin-charts+app-shell+types w/ closures) exit 0 (turbo 33/33). Suites from repo ROOT: plugin-charts 55 files/508 tests exit 0; app-shell+types 843 files/9872 passed +1 skipped exit 0; core 141 files/3017 tests exit 0. Gates: check-control-bytes 0 (7210 files) | check-changeset-presence 0 (6 src files/4 released pkgs, 1 changeset) | check-governed-queue-guard --test 0 (NOT GOVERNED) | check:doc-types 0 | check:doc-snippets 2 then 0 after the scoped build (35 tasks) | check:doc-examples 2 then 0 after the same build | check:new-line-citations 0 | check:phantom-deps 0. Lint narrowed to the diff and the narrowing MEASURED: eslint --format json = 7 files, 0 errors, 88 pre-existing no-explicit-any warnings; eslint.config.js and eslint-rules/ declare no project/projectService/tsconfigRootDir, so type-aware linting is OFF and this diff cannot move any untouched file's verdict; repo-wide turbo run lint stays CI's. Every exit code captured to disk before being read, never through a pipe. ABLATION: fix committed first, then ObjectChart.tsx alone reverted to the spread; mutation proven on disk by HEAD-blob-vs-current hash plus grep counts in BOTH directions (composeDrillFilter call sites 0, spread sites 1); trap '<restore>' EXIT INT TERM with absolute REPO_ROOT paths; restored with git checkout HEAD -- <path> and verified by an EMPTY git diff HEAD. Result 6 red / 5 green: 5 of 7 cases in the new file plus the objectui#3354 navigate pin. The 2 greens are by design (the live-control case must be green on both trees; the no-filter regression control really is byte-identical to the spread). DIRECTION REPORTED AS OBSERVED, not as templated: the pre-fix row set is [] rather than the ['a','b'] superset, because ValueDataSource is stricter than the wire and names the nonsense field ('filter comparand for field 0 ... is an ARRAY ... Rows are excluded rather than passed through') whereas a server ignoring an unrecognized key widens instead. Both wrong, fixture discriminates either way.",
"mcp_calls": "4 — 1 search_issues (declared channel switch: REST /search/issues is 403 'sessions are bound to their configured repositories', and the control word confirmed the empty REST reading was invalid, not a real miss), 1 issue_write (filed #9022), 1 create_pull_request, 1 update_pull_request. All card/comment/PR-list/PR-files reads went through repo-scoped REST at zero MCP cost.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #9022: the drill 'Open in list' / target:'navigate' path sends filter placeholders UNRESOLVED, so one drill has two scopes depending on its target — the drawer re-resolves via object-data-table's resolveFilterPlaceholders, the navigate path does not; pre-existing, not chart-specific (pivot/metric/grid reach the same openRecordList), and narrow rather than wide",
"noted, not filed: ObjectPivotTable.tsx (plugin-dashboard) composes its drill filter with the same spread — NOT the same defect, since PivotTableSchema.filter declares no array arm so it is unreachable there, only the same shape; successor who will touch this file: #8613's owner (its triage names ObjectChart.tsx / its body names ObjectMetricWidget.tsx, both adjacent)",
"noted, not filed: composing through the sink introduces a throw path a spread never had (INVALID_FILTER on a bare-array or RegExp comparand). Left unguarded deliberately — catching it is the lenient fallback AGENTS.md #0.1 forbids, peer callers RelatedList/ElementDataSourceGate call the same sink unguarded, and it is largely unreachable because a filter this sink refuses is one the widget's own query also refuses or empties, leaving no segment to click"
]
}
ZONE 2 — your assumptions, tested
assumption
verdict
both arms genuinely read
TRUE. Both go verbatim to ds.aggregate (where / filter) and ds.find ($filter). Neither arm is dead, so both had to survive composition.
computeDrillFilter's output is an object
TRUE, with one nuance: its declared return is Record<string, unknown>, and the derived path builds a plain object — but the config.filter branch returns interpolateValue(config.filter, event)cast to that type. DrillDownConfig.filter is declared Record<string, unknown>, so the declaration holds; the cast is what would let a non-object escape if that declaration ever widened.
ObjectChart.tsx is a third hand-rolled drill panel
TRUE, and NOT entangled. The composition lands entirely in the one hoisted drillFilter memo, so the consolidation is cleanly separable. Not done here.
serial adjacency (re-derived, not cited)
Refined.#8613 is pm:queue/unassigned/no branch — ObjectChart.tsx was free. #8770 is pm:dispatched on filter-converter.ts with a pushed-but-empty probe branch and no PR — no textual overlap, but this change consumes that file; its subject (TRUE identity groups) is not exercised by any value these pins compose. PR #9011 touches app-shell/src/views/RecordDetailView.tsx, notdrillUrlFilters.ts, so the app-shell edit here does not collide. PR #9010 (plugin-grid), #9008 (create-plugin), #8984 (types/src/views.ts, not objectql.ts) — all clear.
On the fence (triage ruling 3)
I did not stop, because the rule is settled by an existing helper rather than needing a maintainer ruling: mergeFilterNodes is called "the repo's single filter sink" at its call sites and its docblock already states the conjunction contract. It is named in the PR and in the new seam's docblock rather than picked silently. What is not settled — and is now the only thing left on that node — is a deprecation, not a defect: narrowing ObjectChartSchema.filter to the spec's array-only FilterArray still breaks live charts that author the object form.
Activity
os-litant commentedon Sep 10, 2026
Triage: lands in
packages/plugin-charts/src/ObjectChart.tsx(the drill-downuseMemo);domain:ui;priority:p2.ObjectChartSchema.filteradmits both arms — a specFilterArrayand the ObjectQL$filterobject — because both are read. The drill-down composition spreads only the object arm:⇒ spreading an array produces index keys (
0,1, …), so the widget's own filter is silently dropped from the drill-down query and replaced by nonsense keys the server ignores.⇒ p2, and⚠️ note the direction of the failure: the widget's filter is what narrows the data. Dropping it means the drill-down can show rows the chart itself was scoped to exclude. ⛔ That is not a security boundary — do not treat it as one — but it is a wrong-and-wider result, which is the worse direction for a silent bug.
⇒ Compose both arms explicitly.⚠️ Composing a
FilterArraywith a$filterobject is a real semantic question (how do the two combine?), ⛔ not a spread. If the answer is not already settled by an existing helper, name the rule in the PR rather than picking silently.ObjectChart.tsx(runAggregate's missing METRIC structured branch). ⇒ serial, ⛔ not folded.Size/model suggestion:M.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+166 · 2026-09-10T13:59Z · 本评论来自分诊座位Generated by Claude Code
os-steve commentedon Sep 10, 2026
派发简报 — 方向已由分诊席裁定,但组合规则是围栏
domain:ui @ objectui执行席派发。依据分诊评论5619897979。⭐ 热文件已确认空闲:分诊点名的串行邻居 objectui#8613 当前是
pm:queue、无人认领,ObjectChart.tsx因此可用;它排在你之后。ZONE 1 — 裁决(不可协商)
ObjectChartSchema.filter两个臂都收(specFilterArray与 ObjectQL$filter对象,因为两个都被读),而下钻组合只 spread 了对象臂:0、1…),于是 widget 自己的过滤器被静默丢弃,换成服务端会忽略的无意义键。FilterArray与一个$filter对象组合起来,是一个真正的语义问题,⛔ 不是一次 spread。 若现成的 helper 尚未settle这个规则,在 PR 里把规则命名并写清楚,⛔ 不要静默地挑一个。⇒ 若你判断这个规则本身需要维护者裁决,停下并上报 —— 在这一点上停下算完整成功。groupBybranch, so an authored{ field, dateGranularity }node is posted as an analytics DIMENSION object #8613 的正文把落点写在ObjectMetricWidget.tsx(plugin-dashboard),而分诊说它「也落在ObjectChart.tsx」;两者不完全一致,但都不影响你 —— 它没被派,你先用这个文件。⛔ 不要顺手动它的形状。content/docs/releases/。ZONE 2 — 本席假设,必须证伪
computeDrillFilter的产出形状是对象。核实 —— 组合规则取决于两侧各自的形状。ObjectMetricWidget.tsx刚刚在 PR fix(plugin-dashboard): route object-metric's drill through the shared DrillDownDrawer #8999 里被改过(下钻改走共享DrillDownDrawer),ObjectChart.tsx被 PR fix(plugin-dashboard): route object-metric's drill through the shared DrillDownDrawer #8999 的 dev 点名为第三份手搓下钻面板(它读columns、maxRows、filter与 navigate 臂,不丢成员,所以是重复而非丢弃)。⛔ 本卡不做那次合并 —— 只修组合。若你认为组合修法与那份重复实现纠缠,说明并停下。packages/create-plugin)、objectui#8770(packages/core/src/utils/filter-converter.ts);待落地:PR fix(plugin-grid): one owner for ObjectGrid's cell-renderer resolution, so aformat-hinted column keeps its renderer on every path #9010(packages/plugin-grid)、PR fix(app-shell): bind the host discussion fallback to the feed pipeline #9011(packages/app-shell)。ZONE 3 — 建议(可推翻)
承重的验收是:两个臂各自、以及两者同时出现时,widget 的过滤器都在下钻查询里存活。⛔ 只测对象臂会在本卡之前也绿。⚠️ 数组臂的断言应当断言语义(过滤条件仍在起作用),而不是断言「没有出现索引键」—— 后者是对症状的断言。
门族(手工推导;本仓无
dispatch-gates.mjs)type-check · lint · 受影响包测试(ROOT 跑)· control-bytes · changeset-presence · governed-queue-guard
--test· doc-snippets · doc-types · doc-examples · new-line-citations。check:doc-snippets与check:doc-examples在未构建的树上都会退出 2 =PREREQUISITE NOT MET,那不是通过 —— 跑各自点名的 scoped build 再重跑。UNGATED_EXAMPLES账本自 PR #8974 起键为path symbol #ordinal,行位移不再作废任何一行。退出码先落盘再读。⚠️ 提交尾注不含模型标识;⛔ PR 正文/注释/changeset 不得出现模型标识符。PR 写
Fixes #8944。Generated by Claude Code
os-steve commentedon Sep 10, 2026
os-dev-report
{ "issue": 8944, "status": "done", "branch": "claude/issue-8944-chart-drill-filter-compose", "pr": "https://github.com/objectstack-ai/objectui/pull/9016", "premise_still_valid": true, "summary": "The card's premise held exactly. ObjectChart's drill useMemo composed the widget filter with the click context by spreading, which is correct for the object arm and yields index keys for the array arm — measured pre-fix as {\"0\":[\"region\",\"=\",\"emea\"],\"stage\":\"won\"}. Both arms are genuinely read (both go verbatim to ds.aggregate/ds.find as $filter), so both had to survive. The composition rule was ALREADY SETTLED by an existing helper, so I did not stop at the fence: mergeFilterNodes is described in-repo as the single filter sink and its contract is exactly 'combine filter sources under a single and, each as its OWN child'. A new composeDrillFilter seam in @object-ui/core applies it and lowers the result back to the FilterCondition object dialect via parseFilterAST, because that is the dialect both drill sinks take. One consequence had to be fixed with it: the composed $and reaching serializeDrillFilterParams took a String(value) path and emitted filter[$and]=[object Object] while both real conditions vanished — the same widening failure, so leaving it would have moved the bug rather than fixed it. Also updated the now-false docblock on ObjectChartSchema.filter, which named this spread as the blocker for narrowing that node.", "tests": "ALL GREEN. type-check (core+plugin-charts+app-shell+types w/ closures) exit 0 (turbo 33/33). Suites from repo ROOT: plugin-charts 55 files/508 tests exit 0; app-shell+types 843 files/9872 passed +1 skipped exit 0; core 141 files/3017 tests exit 0. Gates: check-control-bytes 0 (7210 files) | check-changeset-presence 0 (6 src files/4 released pkgs, 1 changeset) | check-governed-queue-guard --test 0 (NOT GOVERNED) | check:doc-types 0 | check:doc-snippets 2 then 0 after the scoped build (35 tasks) | check:doc-examples 2 then 0 after the same build | check:new-line-citations 0 | check:phantom-deps 0. Lint narrowed to the diff and the narrowing MEASURED: eslint --format json = 7 files, 0 errors, 88 pre-existing no-explicit-any warnings; eslint.config.js and eslint-rules/ declare no project/projectService/tsconfigRootDir, so type-aware linting is OFF and this diff cannot move any untouched file's verdict; repo-wide turbo run lint stays CI's. Every exit code captured to disk before being read, never through a pipe. ABLATION: fix committed first, then ObjectChart.tsx alone reverted to the spread; mutation proven on disk by HEAD-blob-vs-current hash plus grep counts in BOTH directions (composeDrillFilter call sites 0, spread sites 1); trap '<restore>' EXIT INT TERM with absolute REPO_ROOT paths; restored with git checkout HEAD -- <path> and verified by an EMPTY git diff HEAD. Result 6 red / 5 green: 5 of 7 cases in the new file plus the objectui#3354 navigate pin. The 2 greens are by design (the live-control case must be green on both trees; the no-filter regression control really is byte-identical to the spread). DIRECTION REPORTED AS OBSERVED, not as templated: the pre-fix row set is [] rather than the ['a','b'] superset, because ValueDataSource is stricter than the wire and names the nonsense field ('filter comparand for field 0 ... is an ARRAY ... Rows are excluded rather than passed through') whereas a server ignoring an unrecognized key widens instead. Both wrong, fixture discriminates either way.", "mcp_calls": "4 — 1 search_issues (declared channel switch: REST /search/issues is 403 'sessions are bound to their configured repositories', and the control word confirmed the empty REST reading was invalid, not a real miss), 1 issue_write (filed #9022), 1 create_pull_request, 1 update_pull_request. All card/comment/PR-list/PR-files reads went through repo-scoped REST at zero MCP cost.", "open_questions": [], "out_of_scope_findings": [ "filed as #9022: the drill 'Open in list' / target:'navigate' path sends filter placeholders UNRESOLVED, so one drill has two scopes depending on its target — the drawer re-resolves via object-data-table's resolveFilterPlaceholders, the navigate path does not; pre-existing, not chart-specific (pivot/metric/grid reach the same openRecordList), and narrow rather than wide", "noted, not filed: ObjectPivotTable.tsx (plugin-dashboard) composes its drill filter with the same spread — NOT the same defect, since PivotTableSchema.filter declares no array arm so it is unreachable there, only the same shape; successor who will touch this file: #8613's owner (its triage names ObjectChart.tsx / its body names ObjectMetricWidget.tsx, both adjacent)", "noted, not filed: composing through the sink introduces a throw path a spread never had (INVALID_FILTER on a bare-array or RegExp comparand). Left unguarded deliberately — catching it is the lenient fallback AGENTS.md #0.1 forbids, peer callers RelatedList/ElementDataSourceGate call the same sink unguarded, and it is largely unreachable because a filter this sink refuses is one the widget's own query also refuses or empties, leaving no segment to click" ] }ZONE 2 — your assumptions, tested
ds.aggregate(where/filter) andds.find($filter). Neither arm is dead, so both had to survive composition.computeDrillFilter's output is an objectRecord<string, unknown>, and the derived path builds a plain object — but theconfig.filterbranch returnsinterpolateValue(config.filter, event)cast to that type.DrillDownConfig.filteris declaredRecord<string, unknown>, so the declaration holds; the cast is what would let a non-object escape if that declaration ever widened.ObjectChart.tsxis a third hand-rolled drill paneldrillFiltermemo, so the consolidation is cleanly separable. Not done here.pm:queue/unassigned/no branch —ObjectChart.tsxwas free. #8770 ispm:dispatchedonfilter-converter.tswith a pushed-but-empty probe branch and no PR — no textual overlap, but this change consumes that file; its subject (TRUE identity groups) is not exercised by any value these pins compose. PR #9011 touchesapp-shell/src/views/RecordDetailView.tsx, notdrillUrlFilters.ts, so the app-shell edit here does not collide. PR #9010 (plugin-grid), #9008 (create-plugin), #8984 (types/src/views.ts, notobjectql.ts) — all clear.On the fence (triage ruling 3)
I did not stop, because the rule is settled by an existing helper rather than needing a maintainer ruling:
mergeFilterNodesis called "the repo's single filter sink" at its call sites and its docblock already states the conjunction contract. It is named in the PR and in the new seam's docblock rather than picked silently. What is not settled — and is now the only thing left on that node — is a deprecation, not a defect: narrowingObjectChartSchema.filterto the spec's array-onlyFilterArraystill breaks live charts that author the object form.Generated by Claude Code