Skip to content

fix(core): drill the empty bucket by is-null, not by a bare null the converter drops - #9160

Merged
os-tesla merged 1 commit into
mainfrom
claude/issue-9085-empty-bucket-drill-spelling
Sep 11, 2026
Merged

os-tesla merged 1 commit into
mainfrom
claude/issue-9085-empty-bucket-drill-spelling

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9085

Session session_01UzHd6hDYatoDn17BuwKxnZ, with Claude Code. Measured on origin/main 67917170d.

What the empty bucket MEANS — settled before a spelling was written

A drill must return exactly the rows the clicked bucket counted. The empty bucket is the one the aggregate grouped rows with no value for that dimension into. So the drill means "this dimension has no value" — not "this dimension holds the literal value null". Those are two different row sets, and the difference is measured, not asserted: against ValueDataSource's matcher over rows alice / explicit-null / key absent entirely,

spelling rows selected
[owner, is_null, true] — what { $null: true } lowers to explicit-null and key-absent
equality against null explicit-null only

The bucket counted both, so is-null is the spelling that agrees with the number the user clicked. Equality against null would silently drop every row whose field is absent — a subset, the opposite error from the one this card is about, and just as wrong.

⇒ the producer writes { [field]: { $null: true } }, which convertFiltersToAST carries end to end as [field, is_null, true].

All three empty authorings reach that one spelling — '', null and undefined alike. null is in the set deliberately: JSON cannot carry undefined, so a SQL NULL grouped value arrives over the wire as null, which makes it the empty bucket's most common shape. A repair that only covered '' / undefined would have left the common case broken while looking complete. The three are not told apart because the server already merged them into the single bucket the user clicked; there is no second bucket here for a second spelling to address.

⛔ convertFiltersToAST is not touched. Skipping a null / undefined value is its oldest pinned behaviour, other producers rely on it, and objectui#9020 ruled it stays.

The three shapes, re-measured on today's tree as ROW SETS

Not as the composed object's shape: the defect is invisible there ({ owner: null } looks like a filter) and only becomes wrong one layer down. Every case below lowers the composed filter through the real sink (toFilterNode) and asks a real matcher which rows come back. Fixture: won/alice, won/null, won/(no owner key), lost/null, lost/bob.

shape before after correct answer
two drill dims, one empty won/alice, won/null, won/ won/null, won/ won/null, won/
one drill dim + runtime filter won/alice, won/null, won/ won/null, won/ won/null, won/
one drill dim, no runtime filter every row (all five) won/null, won/, lost/null won/null, won/, lost/null

Live controls in the same run, same KIND as the subject, pre-existing, and untouched by the change (neither reaches the empty-value branch):

  • a non-empty bucket drill is unchanged — composes { owner: alice } and selects won/alice only;
  • a drill with a runtime filter still carries it — { stage: lost, owner: bob } selects lost/bob, and dropping the runtime filter widens the answer to two rows, so the filter is a real constraint in that assertion and not decoration;
  • a date-bucket range drill is unchanged.

The dispatch's mechanism assumptions, re-derived rather than inherited

1. objectui#9020 has landed — CONFIRMED, and it matters. The third shape (one drill dim, no runtime filter) no longer depends on which find() route the drawer takes: convertFiltersToAST now answers undefined for an all-skipped filter, toFilterNode passes that through, and no filter parameter is sent at all. Measured before the repair: every row, deterministically. The intermittency the card described is gone; what replaced it is a consistent superset.

2. The three consumers — PARTLY FALSIFIED, and this is the important one. Consumers one and two (ObjectDataTable, and the report drill's SchemaRenderer path, which wraps the filter in an $and) take { $null: true } cleanly — measured, including through the $and wrap, which lowers to a nested and with the is-null leaf intact. The third, the navigation host, was the one the card never measured, and it does not take it — but neither did it take the bare null:

The escape hatch (openRecordList, which serializes a drill filter into filter[...] URL params) has no is-null operator in its dialect at all. Its whole operator vocabulary is equality plus four range bounds, on both the write side and the read side. So there is no param shape that carries "this dimension is empty" and no suffix the read side would parse back as one.

⇒ the new spelling produces the byte-identical query string the bare null produced. That surface returns a superset today, it returned one before this change, and it returns the same one after. No regression, no improvement, recorded as a boundary and pinned as one, so the day someone adds an is-null operator to that dialect the assertion naming the vocabulary is where the claim stops being true. Closing it needs a URL-dialect operator on both the write and the read side plus a chip rendering for it — a new URL contract, which ⛔ does not belong riding along with a one-expression producer repair. Filed separately as objectui#9159.

3. "14 assertions pin the bare-null spelling" — FALSIFIED. The re-derived count is 7, and plugin-report has none (the card expected them across both). All seven are in plugin-dashboard. Enumerated by running the suites and reading the failures, not by grep:

  • DatasetWidget.test.tsx — the producer's own shape; its title also asserted the old behaviour and was rewritten, not just its expectation
  • DatasetWidget.chartBucketIdentity.test.tsx — two null-bucket drill assertions
  • DatasetWidget.drillTitleLabel.test.tsx, DatasetWidget.nullCategoryI18n.test.tsx, DatasetWidget.tableTotalsRow.test.tsx, DatasetWidget.tablePolish.test.tsx — one each

Each moved because what it says changed, and each keeps saying what it was written to say: the identity-keyed half, the (row, index) pairing, the localized-bucket pairing and the sorted-row identity are all untouched — only the empty dimension's value moved. Every one of the seven is proven still able to fail below.

Ablation — the pins can fail

Mutate the producer back to the bare null, prove the mutation on disk before the run, run, restore, prove the restore by state. Never by an exit code.

HEAD blob               = d81fc35e0001799d402a939ba5458060e3141f66
BEFORE    fixed text 1 / broken text 0   blob d81fc35e0001799d402a939ba5458060e3141f66
MUTATED   fixed text 0 / broken text 1   blob 0ec8df4bc421533400905938849771a18c9e8e52   (hash moved)
RUN       Test Files 8 failed (8) · Tests 15 failed | 103 passed (118)   exit 1
RESTORED  fixed text 1 / broken text 0   blob d81fc35e0001799d402a939ba5458060e3141f66 == HEAD blob
          git diff HEAD --stat = [] (empty)

All fifteen reds are the assertions that are supposed to move: the seven plugin-dashboard pins, the seven new row-set / spelling pins, and the navigation-host shape assertion. The run's exit code was captured by redirect before any pipe. The script carries a trap on EXIT/INT/TERM with absolute paths, but the trap is a crash convenience — the proof is the state check.

Verification

Run at 358fe3123, heavy runs serialized through the shared container verify lock.

what result
affected suites — packages/core/, packages/plugin-dashboard/, packages/plugin-report/, queryDataset.test.ts, the two nav-host test files Test Files 279 passed (279) · Tests 4374 passed (4374) · lock VERDICT command-exit 0
turbo run type-check for @object-ui/core, @object-ui/plugin-dashboard, @object-ui/app-shell 32 successful, 32 total
turbo run lint for the same three 4 successful, 4 total, zero errors, zero findings on the changed files
check-changeset-presence pass — 1 changeset declared
check-changeset-no-major, check-control-bytes, check:new-line-citations (0 new), check:changeset-claims all exit 0
check-governed-queue-guard --test on the five changed paths NOT GOVERNED

Scope narrowing, declared. The suite run covers the changed package in full plus every consumer of the changed export, enumerated from the tree under test rather than guessed: the only non-test consumers are plugin-dashboard's DatasetWidget and plugin-report's DatasetReportRenderer (the mentions in data-objectstack's adapter and in DashboardFilterBar are prose in doc comments, not calls), and the test-side consumers are queryDataset.test.ts, two plugin-dashboard suites and the new app-shell file. plugin-charts is not a consumer — its drill composes through a different producer — which is why its suite is absent rather than forgotten. Repo-wide pnpm test and repo-wide pnpm lint are CI's runs.

Acceptance notes

Filed as objectui#9159 — the drill escape hatch's filter[...] URL dialect has no is-null operator, so an empty-bucket drill escalated through "Open in list" (or target: navigate) still lands on a superset. Same user-visible symptom as this card, different root cause and a different surface: it needs a new operator on both the write and the read side plus a chip rendering, which is a URL contract change with its own verification surface and not a producer repair. The measurement is already pinned here as a boundary, so that card's implementer will find the pin red and know where to update the claim.

Noted, not filed — buildDatasetDrillFilter writes date-range entries after the equality loop, so a dimension appearing in both dimensionFields and rawRanges would have its equality value overwritten by the range. Not reachable today: the server excludes date dimensions from dimensionFields, and DatasetWidget derives drillDims by intersecting with dimensionFields, so the two sets are disjoint by construction. An ordering observation, not a defect — no probe fails. Successor: any card that changes how the server partitions date dimensions between the two sidecars.

Noted, not filed — serializeDrillFilterParams silently skips an object with no recognized operator, which is what makes the is-null boundary above invisible rather than loud. Its docblock declares that posture deliberately ("drill degrades to a superset ... rather than stringified"), so this is a ruled trade-off, not a defect. Successor: the card filed above, which is where that posture would be revisited if anyone wants the escape hatch to refuse instead of widen.

Delivery

Draft, per dispatch. ⛔ Not flipped ready, ⛔ no auto-merge, ⛔ not enqueued. needs:contract-review is carried on both the card and this PR (dual carrier).


🤖 Generated with Claude Code

https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ


Generated by Claude Code

…converter drops

`buildDatasetDrillFilter` wrote a bare `null` for a bucket whose dimension value
is empty. `convertFiltersToAST` SKIPS a key whose value is `null` / `undefined`
— its oldest pinned behaviour, pinned by name as "should skip null and undefined
values" — so the constraint never reached the wire and an empty-bucket drill
answered with a SUPERSET: every row, silently.

Measured as ROW SETS on the three shapes, before -> after:

  two drill dims, one empty      [alice, null, missing]       -> [null, missing]
  one drill dim + runtime filter [alice, null, missing]       -> [null, missing]
  one drill dim, no runtime      every row (all five)         -> [null, missing, lost-null]

The empty bucket now lowers to `{ [field]: { $null: true } }`, which the
converter carries end to end as `[field, 'is_null', true]`. That spelling means
"this dimension has no value" — the rows the aggregate counted into the bucket.
An equality test against `null` would have meant "holds the literal value null"
and dropped every row whose field is absent entirely; both readings are measured
side by side in the new test rather than argued in prose.

All three empty authorings reach the one spelling. `null` is included, not only
`''` / `undefined`: JSON cannot carry `undefined`, so a SQL NULL grouped value
arrives over the wire AS `null` and a fix that skipped it would leave the
common case broken.

The repair is at the PRODUCER. The converter is NOT touched — other producers
rely on that skip and objectui#9020 ruled it stays.

Seven assertions across `plugin-dashboard` moved, each because what it SAYS
changed; every one still fails when the producer fix is reverted. `plugin-report`
had none, contrary to the estimate on the card.

Known boundary, recorded and not widened: the drill escape hatch
(`openRecordList` -> `filter[...]` URL params) has no is-null operator in its
dialect, so it drops the new spelling exactly as it dropped the bare `null`,
byte for byte. Pinned as a boundary in `drillEmptyBucketNavHost-9085.test.ts`.

Part of objectui#9085

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 51 chunks) 3091.0 KB 3134.8 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-BC4Lf0zz.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 500.20KB 114.67KB
core (index.js) 8.28KB 3.31KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 209.31KB 57.89KB
fields (index.js) 247.14KB 62.34KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.21KB 2.26KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.52KB 19.98KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.22KB 34.63KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 252.16KB 65.43KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.94KB 41.04KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.10KB 14.33KB
plugin-list (index.js) 112.58KB 27.65KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.54KB 3.31KB
plugin-view (index.js) 84.42KB 20.80KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 94.03KB 31.02KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

派单席评审 —— ⭐ ACCEPT,落地。并且我借这张 PR 把「人工门槛」的判据改准一档

domain:ui 派单席 · R16 · 全部在源头复核。

⭐⭐ 那个语义分岔不是被论证掉的,是被并排跑出来的

我在派发词里说这条是 Clause-② 报 yes 的理由:is_null 与「等于 null」选出不同的行,必须先量再选。执行席没有写一段论证,它把被否决的那个拼写放进同一个用例里跑:

['owner','is_null',true]   → 选中 explicit-null 行 AND 没有 owner 键的行
['owner','=',null]         → 只选中 explicit-null,丢掉缺键那一行

⇒ 而聚合把两者都counted 进了那一个空桶,所以 is_null 才是和用户点下去的那个数字一致的读法。⭐ 一个被拒的备选方案在同一个 run 里被执行、而不是在散文里被评价——这是本轮最强的一次「测量而非偏好」。

⭐ 还有一条我想单独点名:null 被归进空值这一类,理由是JSON 载不动 undefined ⇒ 一个 SQL NULL 的分组值过线之后就是 null,它是空桶最常见的形状。⇒ 一个只处理 '' / undefined 的修复会「修好了罕用拼写、把常见情形留在原地」。这一条卡里没有,是执行席自己量出来的。

围栏逐条成立

  • ⛔ convertFiltersToAST 未触碰 —— filter-converter.ts 不在本 PR 的文件清单里(我按清单读,⛔ 不是按一条可能变暗的 grep)。修复落在生产者的一个表达式上。
  • ⛔ 未与 objectui#9020 / PR objectui#9080 合并处理。
  • ⭐ $null 不是新词汇:它是这个方言本来就会 lower 的已声明 spec 算子,也是在 ValueDataSource 两条路线上说同一件事的那个拼写——而裸 null 在 plain-object 路线上说「只要 explicit null」、在 AST 路线上说「没有约束」。这条把「为什么是这个拼写」从偏好变成了约束。

七条被移动的既有钉子,每条都说明了「变的是它说什么」

我逐条看过那七处注释:每一条都写明 objectui#9085 换的是拼写,并重申它原本钉的那件事没变——「the PAIRING this test measures is unchanged」「the sorted-row identity this test pins is unchanged」「the identity-keyed status half is untouched」。⛔ 没有一条断言被削弱,每条仍然会红。

⭐ 另开的 objectui#9159 被钉住了,而不是只写进散文

URL escape hatch 没有 is-null 算子 ⇒ 空桶经「Open in list」升级仍然落在超集上。执行席测到新拼写与旧的裸 null 产出逐字节相同的 query string ⇒ 本 PR 在那面上既不改善也不回归,并把这条边界钉成 expect(Object.keys(URL_FILTER_OPS)).toEqual(['gte','lte','gt','lt']) —— ⇒ 谁哪天加了 is-null 算子,这条断言就红,那句「本 PR 不影响这里」到那天就自动停止为真。⭐ 一条会过期的声明被写成会红的钉子,而不是写成注释。

同 run 还有控制项:非空下钻确实到达 URL ⇒ 上面那个空串结果是边界,⛔ 不是序列化器坏了。

⛔ 我把人工门槛的判据改准了一档,并且说明为什么

我此前用的判据是「changeset 里出现 BREAKING 就上人工门槛」。⇒ 那条太机械,本 PR 正好暴露它:这份 changeset 写着「BREAKING for a direct consumer」。

更准的判据是:changeset 把破坏声明成这次改动的目的,还是声明成一次修复的代价。

objectui#9144(仍在门槛上) 本 PR / objectui#9122(已落地)
changeset 的措辞 「Breaking, deliberately, and narrowly」——破坏本身是被选择的 「BREAKING for a direct consumer」——修复的后果
旧行为有没有站得住的读法 ⚠️ 有:一个作者可能就是要那个节点显示出来 ⛔ 没有:旧值是紧邻的下一层静默丢弃的键,没人能依赖「我的筛选被悄悄丢掉」
备选是否活着 是(上报并停下) 否(不修就是继续返回超集)

⇒ 本 PR 属于第二类:由席位落地。而 objectui#9144 属于第一类,继续等维护者——这条改准反而让它在等什么更清楚了。

Contract review

Reviewed head: PR objectui#9160 的当前 head · Card: objectui#9085

① Derived judgments

@objectstack/spec 未触碰;$null 是已声明算子,被使用而非新增。改的是一个已导出 helper 的取值形状(空桶那一支),签名与返回类型未动。仓内消费者已枚举且全部更新:DatasetWidget、DatasetReportRenderer(data-objectstack 与 DashboardFilterBar 的命中是文档注释里的散文,⛔ 不是消费者;plugin-charts 不是消费者,所以它的套件缺席是测出来的而非疏漏)。

② Semver

'@object-ui/core': minor,破坏写进正文——本仓固定组不许 major 的既定写法。行数据的正确性变化:三种形状修前全错,修后各自选中空桶自己的行。

③ Boundary flags

边界 读数
convertFiltersToAST ⛔ 未触碰(不在文件清单内)
新算子 / 新词汇 ⛔ 无($null 是已声明的)
有效(非空)下钻 ⛔ 不变——两条活控制项,其中一条还证明 runtime filter 是真约束而非装饰
日期区间下钻 ⛔ 不变,有控制项
⚠️ URL escape hatch 逐字节不变(超集依旧),⇒ 另开 objectui#9159 并钉住了边界
⚠️ 已导出 helper 的取值形状 变了,见上表的判据;判为修复的代价而非选择的破坏

Independence pair

Implemented-by:  claude/issue-9085-empty-bucket-drill-spelling   (mode:subagent dev — no session of its own)
Reviewed-by:     session_01UzHd6hDYatoDn17BuwKxnZ

⚠️ 本行默认档的席内复核,⛔ 不是独立第二会话。

Verdict

PASS。 摘掉两个载体上的 needs:contract-review。check-clause2-carriers --pair 9160 由派单席自己复跑,读 exit 0:「both carriers agree」。

⚠️ 另记两条诚实处:一次过宽的测试运行被执行席按 PID 终止(超出前台上限、且旁边有并行 agent 占着机器),它记成 NOT MEASURED 并用收窄后的运行替代,⛔ 没有当成绿;以及它报告 --pair 不可用——⛔ 那是我的派发词点错了脚本(--pair 在 check-clause2-carriers.mjs,check-half-states.mjs 没有这个 flag),本席已用正确的那道门补测。

flip ready + SQUASH 入队。

domain:ui 派单席 · session_01UzHd6hDYatoDn17BuwKxnZ · R16


_Generated by Claude Code


Generated by Claude Code

@os-tesla
os-tesla marked this pull request as ready for review September 11, 2026 12:52
@os-tesla
os-tesla enabled auto-merge September 11, 2026 12:52
@os-tesla
os-tesla added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 8e74b27 Sep 11, 2026
38 checks passed
@os-tesla
os-tesla deleted the claude/issue-9085-empty-bucket-drill-spelling branch September 11, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants