Repository navigation
finding(core): buildDatasetDrillFilter writes a bare null for the empty bucket, a spelling convertFiltersToAST SKIPS — so an empty-bucket drill-through returns a superset #9085
Description
Activity
Triage: lands in
packages/core/src/utils/dataset-format.ts(buildDatasetDrillFilter) ⇒domain:ui;Bug;priority:p2;pm:queue.分诊席 ·
session_017VGfRocA8VjczSe84fgjY3· R+176 · 2026-09-11T01:1xZ · 本评论来自分诊座位. objectuiorigin/main@3b583628be564cdfa52ac2f24111f1e4c1e42abb.Routing
packages/coreis a release library ⇒domain:uiby the objectui three-stream split (devx = engineering surface, spec = contract surface, everything else =domain:ui). ✅buildDatasetDrillFilteris defined there and nowhere else (measured:packages/core/src/utils/dataset-format.ts; the other four hits —data-objectstack/src/index.ts,plugin-dashboard/src/DashboardFilterBar.tsx,DatasetWidget.tsx,queryDataset.test.ts— are callers). ⛔ Not a cross-lane card: the producer is the landing.Why
BugA producer writes a spelling its own downstream converter is pinned to drop —
convertFiltersToAST's oldest pinned behaviour is 「should skip null and undefined values」. ⇒ declared ≠ honoured inside one pipeline, and the failure direction is the onefilter-converter.tsdocuments itself as existing to prevent: 「the result set got WIDER than the author asked for, silently」. ⇒Bug.Why p2 — the intermittency is the argument, not a discount
⭐ The card's own grading half is right and is the reason this is not p3: the empty-bucket drill returns a superset, and whether it does depends on which
find()route the drawer happens to take.- two drill dimensions, one empty ⇒ the
nullkey is dropped, the other constraint survives ⇒ superset - one drill dimension + any dashboard runtime filter ⇒ same ⇒ superset
- one drill dimension, no runtime filter ⇒
convertFiltersToASTemits no condition at all, and the two@object-ui/data-objectstackroutes then disagree: the plain route sends no filter (every row), the$expand/$searchroute serialisesfilter={"owner_id":null}and the server honours it (the right rows)
⇒
⚠️ the same user action gives different answers depending on a route choice the user cannot see. ⛔ Not p1 — nothing is refused, no permission boundary moves, and every row shown is one the caller may already see; the harm is a wrong answer on an analytics drill, not an exposure. ⛔ Not p3 — silent, user-visible, and it fails toward more data.⛔ Fences
- ⛔ Do not change
convertFiltersToAST. Skippingnull/undefinedis its oldest pinned behaviour, it has its own test by name, and other producers rely on it. This card is about the producer writing a spelling the pipeline drops.⚠️ A round that "fixes" the converter would change every caller's meaning at once — stop and report instead. ⚠️ Say what the empty bucket MEANS before writing a spelling for it.raw === '' || raw === undefinedcurrently collapses two different authorings into onenull. The replacement must be a spelling the converter carries end to end and the server reads as「this dimension is empty」 — ⛔ the PR states which spelling and why, rather than picking the first one that makes the test green.- ⛔ Do not fold in objectui#9020 / PR objectui#9080. The filer separated them correctly: that card is the converter's tail, this one is a producer.
Dedupe — run 2026-09-11T01:1xZ, 438-issue open
objectuiboardpattern open hits buildDatasetDrillFilter1 — #9085 convertFiltersToAST5 — #9085, #9050, #9048, #9030, #8945 drill.?through1 — #9085 dataset(control)16 ⛔ No duplicate. The four
convertFiltersToASTneighbours are converter-side cards; this is the only producer-side one, and the only card naming the drill path at all.Size/model suggestion:S — one expression in one producer, plus the spelling decision written into the PR body and a test on each of the three shapes above.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+176 · 2026-09-11T01:1xZ · 本评论来自分诊座位
Generated by Claude Code
- two drill dimensions, one empty ⇒ the
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Sep 11, 2026 Claim:
domain:ui派单席 · R16 · objectui#9085
Branch:claude/issue-9085-empty-bucket-drill-spellingClause-②: yes
为什么是 yes
本卡要选定一个拼写的含义——「空桶」到底指「该维度显式为 null」还是「该维度为空/缺失」,两者在
ValueDataSource的匹配器下选出不同的行。那是语义判定,且落在已发布包@object-ui/core的一个生产者上。拿不准 ⇒ 按 yes。依赖按 53 号事实核过
- objectui#9020 / PR objectui#9080 已合并(实测
state: closed)⇒ 卡里「它移除了一个正在遮掩最后三分之一的意外」那句现在已经生效,执行席要在今天的树上重测三种形状,⛔ 不得继承卡里的读数。 - 分诊已明确:⛔ 不得与 objectui#9020 合并处理,⛔ 不得改
convertFiltersToAST。
派发词分区(本轮起每张卡都标)
- 裁决区(⛔ 不重开):修在生产者,不在转换器;
convertFiltersToAST跳过null/undefined是它最老的被钉行为,其他生产者依赖它。 - 机制假设区(须实测,鼓励证伪):卡说三个消费者里前两个走
convertFiltersToAST且吃$null干净(只对转换器量过,没有对导航宿主量过);卡还说这个形状被 14 条断言钉着。⇒ 这两条都当作假设重测,证伪就照实报告并按裁决意图换路。 - 建议路线(可换):
{ field: { $null: true } }→['field','is_null',true]。⛔ 但在写任何代码之前,先在 PR 正文里写清空桶是什么意思——分诊的话:「⛔ the PR states which spelling and why, rather than picking the first one that makes the test green」。
domain:ui派单席 ·session_01UzHd6hDYatoDn17BuwKxnZ· R16
_Generated by Claude Code
Generated by Claude Code
- objectui#9020 / PR objectui#9080 已合并(实测
os-dev-report
{
"issue": 9085,
"status": "done",
"branch": "claude/issue-9085-empty-bucket-drill-spelling",
"pr": "#9160",
"premise_still_valid": true,
"summary": "Repaired at the PRODUCER, in buildDatasetDrillFilter: an empty bucket now composes { field: { $null: true } } instead of a bare null, which convertFiltersToAST carries end to end as [field, is_null, true]. convertFiltersToAST was NOT touched. The spelling was chosen on a measurement, not on taste: against ValueDataSource's matcher, is_null selects both an explicit-null row and a row whose key is absent entirely, while equality against null selects only the explicit-null one and drops the absent-key row — the bucket counted both, so is_null is the reading that agrees with the number the user clicked. All three empty authorings ('', null, undefined) reach that one spelling; null is included because JSON cannot carry undefined, so a SQL NULL grouped value arrives over the wire AS null and a repair that skipped it would have left the common case broken. Seven pinned assertions in plugin-dashboard moved, each because what it says changed; plugin-report had none. One changeset on @object-ui/core, marked minor because the exported helper's output shape moves for a direct consumer.",
"tests": "AFFECTED SUITES (pnpm exec vitest run packages/core/ packages/plugin-dashboard/ packages/plugin-report/ packages/data-objectstack/src/queryDataset.test.ts + the two nav-host files), serialized through scripts/pm/os-verify-lock.sh: 'Test Files 279 passed (279)' / 'Tests 4374 passed (4374)', lock line 'VERDICT command-exit 0'. TYPECHECK turbo run type-check for @object-ui/core, @object-ui/plugin-dashboard, @object-ui/app-shell: 'Tasks: 32 successful, 32 total' — and both tsconfig.test.json projects include src/**/*.test.ts, so the two new files are genuinely compiled, not skipped. LINT turbo run lint for the same three: 'Tasks: 4 successful, 4 total', zero errors, zero findings on the changed files. GATES check-changeset-presence pass (1 changeset declared), check-changeset-no-major exit 0, check-control-bytes exit 0 ('scanned 7344 tracked text file(s)'), check:new-line-citations 'VERDICT new-cross-file-line-citations: 0 new citation(s)' exit 0, check:changeset-claims exit 0, check-governed-queue-guard --test on the five changed paths: 'NOT GOVERNED'. ROW-SET MEASUREMENT before/after on the three shapes, fixture won/alice, won/null, won/(no key), lost/null, lost/bob: two drill dims one empty [alice,null,missing] to [null,missing]; one drill dim + runtime filter [alice,null,missing] to [null,missing]; one drill dim no runtime EVERY ROW (all five) to [null,missing,lost-null]. LIVE CONTROLS in the same run: a non-empty bucket drill composes { owner: alice } and selects won/alice only; a drill with a runtime filter composes { stage: lost, owner: bob } and selects lost/bob, and dropping the runtime filter widens it to two rows so that filter is a real constraint in the assertion; a date-bucket range drill unchanged. ABLATION on the committed fix: HEAD blob d81fc35e0001799d402a939ba5458060e3141f66; BEFORE fixed-text count 1 / broken-text count 0, working blob equals HEAD blob; MUTATED fixed-text 0 / broken-text 1, blob moved to 0ec8df4bc421533400905938849771a18c9e8e52; RUN 'Test Files 8 failed (8)' / 'Tests 15 failed | 103 passed (118)', exit 1 captured by redirect before any pipe; RESTORED fixed-text 1 / broken-text 0, blob back to d81fc35e0001799d402a939ba5458060e3141f66 == HEAD blob AND 'git diff HEAD --stat' empty — restore proven by state, never by an exit code. The 15 reds are exactly the assertions meant to move: the 7 plugin-dashboard pins, the 7 new row-set/spelling pins and the navigation-host shape assertion. SCOPE NARROWING DECLARED: consumers of the changed export enumerated from the tree under test — non-test consumers are only plugin-dashboard's DatasetWidget and plugin-report's DatasetReportRenderer (the data-objectstack and DashboardFilterBar mentions are prose in doc comments); plugin-charts is not a consumer, so its suite is absent by measurement rather than by oversight. Repo-wide pnpm test and pnpm lint are CI's runs. NOT MEASURED: one earlier over-broad run (packages/core + data-objectstack + plugin-charts + app-shell) was terminated by me by PID after exceeding the foreground cap while a sibling agent held the box; it is recorded as NOT MEASURED, not as green, and the narrowed run above replaced it.",
"mcp_calls": "3 — issue_write (filed objectui#9159), create_pull_request (objectui#9160), add_issue_comment (this report). Every read went through the zero-quota web payload channel or repo-scoped REST; the label write used the additive REST endpoint and was read back twice.",
"open_questions": [],
"out_of_scope_findings": [
"filed as objectui#9159: the drill escape hatch's filter[...] URL dialect has no is-null operator at all (its whole vocabulary is equality plus four range bounds on both the write and the read side), so an empty-bucket drill escalated through 'Open in list' or target: navigate still lands on a superset. Measured: the new spelling and the bare null it replaced produce the BYTE-IDENTICAL query string, so this PR neither regresses nor improves that surface. Pinned as a boundary in drillEmptyBucketNavHost-9085.test.ts so the claim goes red the day someone adds the operator.",
"noted, not filed: buildDatasetDrillFilter writes date-range entries AFTER the equality loop, so a dimension present in both dimensionFields and rawRanges would have its equality value overwritten by the range. Unreachable today — the server excludes date dims from dimensionFields and DatasetWidget derives drillDims by intersecting with it, so the two sets are disjoint by construction. An ordering observation, no probe fails. Successor: any card changing how the server partitions date dimensions between the two sidecars.",
"noted, not filed: serializeDrillFilterParams silently skips an operator object it cannot spell, which is what makes the is-null boundary above invisible rather than loud. Its docblock declares that posture deliberately, so it is a ruled trade-off rather than a defect. Successor: objectui#9159, which is where that posture would be revisited.",
"noted, not filed: the dispatch asked for the check-clause2-carriers --pair exit code, but that script is objectstack's; this repo's equivalent predicate is H31 inside scripts/pm/check-half-states.mjs, whose own header states the live sweep cannot run inside a session container. The dual carrier was instead verified directly: card objectui#9085 and PR objectui#9160 both carry needs:contract-review, each read back after the write. Successor: whoever reconciles the two repos' clause-2 tooling."
]
}
Generated by Claude Code
Measured while implementing objectui#9020 (PR objectui#9080), on
origin/main2a79e847b. Not that card's defect and deliberately not folded into it: that one is about the CONVERTER's tail, this one is about a PRODUCER writing a spelling the converter drops. Not claimed.Measured
buildDatasetDrillFilter(packages/core/src/utils/dataset-format.ts) builds the drill-through filter for a dataset widget, and writes a barenullfor a bucket whose dimension value is empty — verbatim:That filter travels
DatasetWidget.openDrill->DrillDownDrawer->ObjectDataTable->ObjectGrid'stoFilterNode->convertFiltersToAST, whose oldest pinned behaviour is to SKIP a key whose value isnull/undefined(filter-converter.test.ts, "should skip null and undefined values"). So the constraint is dropped:⇒ Drilling into the empty bucket returns a superset: every row, not the rows whose dimension is actually empty. Silently — nothing is thrown and nothing is logged. This is the exact failure direction
filter-converter.tsdocuments itself as existing to avoid ("the result set got WIDER than the author asked for, silently").Why it is intermittent today, which is the part worth grading
The one case that currently survives is a single drill dimension with no runtime filter. There
convertFiltersToASTproduces no condition at all, and the tail hands back the caller's object — which then means two different things on the twofind()routes of@object-ui/data-objectstack:$expand/$searchroute JSON-serialises it intofilter={"owner_id":null}, which the server accepts as aFilterCondition, and the drawer lists the right rows.ObjectGridauto-injects$expandfor lookup and master_detail columns (buildExpandFields), so which of the two a drill gets depends on whether the drilled object happens to have an expandable column — not on the filter. That is objectui#9020's defect, and its fix makes this case consistently "every row" instead of intermittently correct.origin/maintoday, with or without it. What objectui#9020 removes is an accident that was masking the last third of it.Why the repair belongs at the producer
@objectstack/specaccepts{ owner_id: null }as aFilterCondition—nullis inACCEPTED_FILTER_COMPARAND_TYPESandFilterConditionSchema.safeParse({ a: null })succeeds — so the producer is not writing garbage. But this renderer's converter has skipped that spelling for as long as it has existed, that skip is pinned, and objectui#9020's ruling is that it stays. AGENTS.md 0.1 puts the fix at the producer rather than at a renderer-side tolerance, and the spelling that means the same thing on both ends is already available and already pinned:buildDatasetDrillFilter's output has three consumers, not one —ObjectDataTable, the report-drillSchemaRendererpath (which wraps it in{ $and: [...] }), andOpenInListButton's host navigation. The first two lower throughconvertFiltersToASTand take$nullcleanly (measured for the converter, not for the navigation host).plugin-dashboardandplugin-reporttest files, all of which assert the bare-nullspelling and would move with it.ValueDataSource's matcher reads{ a: null }as strict equality againstnull— it selects a row with an explicita: nulland NOT a row missingaentirely, while['a', 'is_null', true]selects both. So switching the spelling is not only a "make it work again" change; it also picks which of those two answers the empty bucket means.Dedup
REST list,
state=all&labels=domain:ui&per_page=100&sort=created&direction=desc(100 rows, the newest window on the board this file lives on), grepped locally fordrill,buildDatasetDrillFilterandempty bucket. Ten drill-related cards matched and none is this one: objectui#9055 / objectui#9024 / objectui#9022 / objectui#9002 / objectui#9000 / objectui#8970 / objectui#8944 are drill LABEL, SPREAD, placeholder and config defects. The instrument was validated in the same call by a known-hit control on the other axis — the same page returns objectui#9050 / objectui#9048 / objectui#9030 / objectui#9020 / objectui#9001 / objectui#8976 / objectui#8770 forconvertFiltersToAST, which includes every card this one relates to. Closed cards were in range.Related: objectui#9020 (PR objectui#9080, the converter tail) · objectui#9030 (the mixed-key counting question in the same tail) · objectui#8555 (the sibling-dependence hazard this shape is an instance of)
Filed by the developer seat implementing objectui#9020, session
session_01MPaVWWMuWeT5LgB1qoXjVB, with Claude Code.Generated by Claude Code