…reading it
`ObjectChartSchema.filter` admits both a spec `FilterArray` and the ObjectQL
`$filter` object, and both are read. The drill seam composed them by spreading
the widget's filter into an object literal, which is correct for the object arm
and silent nonsense for the array arm: spreading an array yields index keys, so
an authored `FilterArray` drilled as `{ '0': [...], stage: 'won' }` and the
widget's own conditions were dropped for a key the query layer ignores.
The direction matters: the widget's filter is what narrows, so dropping it made
the drilled list a superset of what the chart itself was scoped to.
Compose through a new `composeDrillFilter` seam in `@object-ui/core`, which
applies the rule `widget.filter AND drill.filter` via `mergeFilterNodes` — the
repo's single filter sink, whose contract already states it — and lowers the
result back to the FilterCondition object dialect with `parseFilterAST`. A lone
surviving source lowers back to the flat object the spread produced, so a chart
with no filter of its own drills exactly as before.
`serializeDrillFilterParams` learns to flatten that `$and` into the flat
`filter[...]` params its own read side already ANDs back together; without it a
composed filter took the `String(value)` path and emitted a bogus `filter[$and]`
while both real conditions vanished.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
Fixes #8944
The defect
ObjectChartSchema.filteradmits BOTH a specFilterArray([['region','=','emea']]) and the ObjectQL$filterobject ({ region: 'emea' }), and both are read — both travel verbatim tods.aggregate/ds.findas$filter. The drill seam composed them by spreading the widget's filter into an object literal:Spreading an ARRAY yields index keys, so an authored
FilterArraydrilled as{ '0': ['region','=','emea'], stage: 'won' }— the widget's own conditions replaced by a key the query layer ignores. Nothing errored; the drawer opened and looked right.Direction of the failure. The widget's filter is what NARROWS. Dropping it made the drilled list a superset — it showed records the chart itself was scoped to exclude. Not a security boundary, but the worse direction for a silent bug.
The composition rule — named, and taken from an existing helper
The two are independent filter SOURCES and a drill must satisfy both: the click context only says which bucket of the widget's scope the user asked for, so it may narrow that scope and may never widen it.
This rule is not picked here. It is the contract
mergeFilterNodes(@object-ui/core) already states — "combine filter sources under a singleand, each as its OWN child" — the sink every other multi-source filter in this repo already goes through (ObjectView,RelatedList,LineItemsPanel,RecordPickerDialog,ElementDataSourceGate,buildEffectiveFilter).toFilterNodebeneath it already lowers all three filter shapes in circulation, which is exactly what makes the array arm survive.A new
composeDrillFilterseam in@object-ui/core(besidecomputeDrillFilter) applies that sink at the drill seam and documents the rule in one place, then lowers the composed node back to theFilterConditionobject dialect withparseFilterAST— the spec's single lowering sink — because that is the dialect both drill sinks take (the drawer hands the value toobject-data-table'sfilter, andDrillNavigationContext.openRecordListdeclaresRecordof unknown and serializes it tofilter[...]URL params).Measured, so the compatibility claim is not a guess:
filter[['region','=','emea']]{ $and: [{ region: 'emea' }, { stage: 'won' }] }{ region: 'emea' }{ $and: [{ region: 'emea' }, { stage: 'won' }] }{ stage: 'won' }— flat, identical to the spread{ "0": ["region","=","emea"], "stage": "won" }A lone surviving source lowers back to exactly the flat object the spread produced, so a chart with no filter of its own drills byte-identically to before; only a genuinely composed pair gains the
$and.The one consequence that needed fixing with it
The composed
$andreachesserializeDrillFilterParams(app-shell) throughtarget: 'navigate'and the drawer's "Open in list". That writer took theString(value)path for it —$andholds an ARRAY, so it was neithernullnor a non-array object — and emitted a bogusfilter[$and]=[object Object],[object Object]while both real conditions vanished. That is the outcome that function's own contract says it never produces, and it fails in the same widening direction as the card.It now flattens a top-level (and nested)
$andinto the flatfilter[...]params its own READ side already ANDs back together, and skips a bare array comparand rather than stringifying it. Purely additive: no shape it handled before changes.Assertions
Per-arm and both-together, asserted as semantics rather than as the absence of index keys. The composed filter is run through
ValueDataSource(a real matcher for both$filterdialects) over a fixture built so each source excludes a different row:region = emea) selectsa, cstage = won) selectsa, b— the superset a dropped widget filter producesa— the only correct answerBoth single-source answers are asserted as live controls so the fixture cannot go vacuous silently. Cases: array arm; multi-condition array arm; object arm; no-filter regression control; widget array arm conjoined with an authored
drillDown.filter; and the drawer sink proving it drills by the same composed value as the navigate arm.Ablation
Reverting
ObjectChart.tsxalone to the spread, with the rest of the change in place (mutation proven on disk by blob hash + grep counts in both directions, restored viagit checkout HEAD --and verified by an emptygit diff HEAD):6 red / 5 green — 5 of the 7 cases in the new file, plus the objectui#3354 navigate pin. The two that stay green are by design: the live-control case (green on both trees, that is its job) and the no-filter regression control (single-source composition really is byte-identical to the spread).
[], not the['a','b']superset, and the difference is worth stating rather than smoothing over.ValueDataSourceis stricter than the wire: it names the nonsense field explicitly —— so in the in-memory matcher the index key excludes everything, while a server that simply ignores an unrecognized key widens instead. Both are wrong and the fixture discriminates either way; the card describes the wire direction, and this is the matcher direction.
Gates
type-check(4 packages + closures)33 successful, 33 totalno-explicit-anywarningsplugin-chartssuite (root)app-shell+typessuites (root)coresuite (root)check-control-bytescheck-changeset-presencecheck-governed-queue-guard --testcheck:doc-typescheck:doc-snippets35 successful); it exits 2 = PREREQUISITE NOT MET on an unbuilt treecheck:doc-examplescheck:new-line-citationscheck:phantom-deps@objectstack/spec/dataimport is declared bycoreLint was narrowed to the diff, and the narrowing is measured rather than assumed: file count read from
eslint --format json(7), andeslint.config.js/eslint-rules/declare noproject/projectService/tsconfigRootDir, so type-aware linting is not enabled and this diff cannot move the verdict of any file it does not touch. The repo-wideturbo run lintfarm stays CI's run.Acceptance notes
groupBybranch, so an authored{ field, dateGranularity }node is posted as an analytics DIMENSION object #8613 — serial, different defect, and untouched here. Re-derived against currently-open PRs rather than from a snapshot: finding(plugin-dashboard): the METRIC path has no structured-groupBybranch, so an authored{ field, dateGranularity }node is posted as an analytics DIMENSION object #8613 ispm:queue, unassigned, with no branch.pm:dispatchedonfilter-converter.tswith a pushed-but-empty probe branch and no PR yet. This change does not edit that file, but it consumes it (mergeFilterNodesandtoFilterNodefrom it). Its subject — the TRUE identity groups{ $and: [] }/{ $or: [{}] }— is not exercised by any value these pins compose, so the pins here are insensitive to it either way.DrillDownDrawerconsolidation is NOT done here.ObjectChart.tsxremains a third hand-rolled drill panel (as PR fix(plugin-dashboard): route object-metric's drill through the shared DrillDownDrawer #8999's dev noted). The composition turned out not to be entangled with that duplication — it lands entirely in the one hoisteddrillFiltermemo — so consolidating stays a separate card.ObjectChartSchema.filterto the spec's array-onlyFilterArrayis still not done, and is now a deprecation rather than a defect: the read that forced the record arm to be declared is gone, but live charts author the object form and narrowing stops them compiling. That key's docblock is updated to stop naming a bug as the blocker.mergeFilterNodesrefuses comparands the wire also refuses (a bare array on=, aRegExp) with anINVALID_FILTER/ 400 envelope, where a spread never threw. Left unguarded deliberately: catching it would be the lenient renderer-side fallback AGENTS.md #0.1 forbids, the peer callers (RelatedList,ElementDataSourceGate) call the same sink unguarded, and the path is largely unreachable — a filter this sink refuses is one the widget's own query also refuses or empties, so there are no segments to click.target: 'navigate'path sends filter placeholders UNRESOLVED, so one drill has two scopes depending on its target #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 throughobject-data-table, the navigate path does not). Pre-existing, not chart-specific, and orthogonal to this composition — it is about what the composed value still carries.ObjectPivotTable.tsx(plugin-dashboard) composes its drill filter with the same spread. Not the same defect —PivotTableSchema.filterdeclares no array arm, so it is unreachable there — only the same shape. Left alone rather than filed: it would be a no-op change on a file whose next owner is finding(plugin-dashboard): the METRIC path has no structured-groupBybranch, so an authored{ field, dateGranularity }node is posted as an analytics DIMENSION object #8613's successor work.🤖 Generated with Claude Code
https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB