Skip to content

fix(core): an all-skipped filter says "no constraint" instead of returning the input - #9080

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-9020-all-keys-skipped-tail
Sep 11, 2026
Merged

os-steve merged 1 commit into
mainfrom
claude/issue-9020-all-keys-skipped-tail

Conversation

@claude

@claude claude Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #9020

convertFiltersToAST skips a key whose value is null / undefined. That is
long-standing, pinned, and unchanged here — { a: null, s: 1 } still lowers to
['s', '=', 1]. What changed is the answer when the skip leaves nothing behind: the
general tail returned the caller's original object, and that object meant two
different things on the two find() routes of @object-ui/data-objectstack.

The disagreement, re-driven on today's origin/main (ZONE 2 A)

Measured on 2a79e847b, with the branch point recorded before the first edit. Both
routes driven through the real adapter with a recording fetch, both wire values then
fed to ValueDataSource's matcher over one four-row fixture:

route URL rows
plain (convertQueryParams then client.data.find) /api/v1/data/account? 1,2,3,4
expand (rawFindWithPopulate) /api/v1/data/account?populate=owner&filter={"a":null} 2

The plain route's non-AST branch spreads a plain object's entries as query parameters
and skips the null ones, so nothing at all was appended and no filter parameter
was sent. The expand route JSON-serialises the same object into filter=, and
{ a: null } is a well-formed FilterCondition — null is in the spec's
ACCEPTED_FILTER_COMPARAND_TYPES, and FilterConditionSchema.safeParse({ a: null })
succeeds — so it arrived as a real predicate.

The disagreement is live. None of the three same-day landings closed it:
objectui#9019's fold needs a TRUE-identity combinator (trueIdentityGroups is 0 here),
objectui#8996's $icontains identity never runs (the key is skipped before any operator
machinery), and objectui#9049's refuseTextComparand throws rather than skipping, so it
cannot reach this tail at all.

objectui#8770's fold does NOT already cover it (ZONE 2 B)

Measured, same run: convertFiltersToAST({ $and: [] }) is undefined while
convertFiltersToAST({ a: null }) returned the input and was reference-identical to
it
. Two distinct arms of one tail, reached by two different inputs.

Which "constrains nothing" answer this takes, and why (ZONE 1 3b)

The same ANSWER as objectui#8770 — undefined — reached through a SEPARATE count and
a SEPARATE guard.

There is no second spelling to choose. This dialect expresses "no constraint" as the
absence of the slot: lowerLogicalGroup says so for the TRUE identity, and ['and']
— the "obvious" empty group — is isFilterAST FALSE. The two candidate repairs were
(a) make a null-valued key mean a predicate, which breaks the skip pin and moves every
caller's row set, and (b) carry the skip's own answer to the wire. The ruling forecloses
(a), and (b) is what the loop already says: the key contributes no condition, so a filter
made only of such keys contributes none either. An author who meant the predicate has
always been able to spell it { a: { $null: true } } -> ['a', 'is_null', true], and
that is pinned on both routes.

They coincide on the answer; they are not merged as a state. The tail now carries two
counts and two guards, each keyed on "EVERY key was of MY kind":

  • trueIdentityGroups — objectstack#5322's ruled identity, decided by lowerLogicalGroup;
  • skippedNullKeys — this file's own tolerance, made self-consistent.

The evidence that they were told apart rather than merged is the case that satisfies
neither: { $and: [], a: null } and { $and: [], b: undefined } still return the
object, even though each of their keys alone now folds. A single merged counter would
swallow both. Whether they should fold is objectui#9030's open question, and this PR does
not answer it.

The oracle is the disagreement, not a shape on either route (ZONE 1 2)

packages/data-objectstack/src/filter-all-skipped-two-routes-9020.test.ts drives every
case down both routes and compares the two readings with each other before either
is compared with a literal, so a repair that left them disagreeing differently fails even
if it satisfies every shape assertion. Both halves are present because either alone
passes on something worse: agreement alone passes on two routes agreeing on the wrong
answer, and a row set alone cannot tell "honoured" from "dropped" for a filter whose
correct answer is every row — so section 0 proves the fixture distinguishes every-row
(1,2,3,4) from the a = null subset (2) before any of it is read that way.

Pins UPDATED, not routed around (ZONE 2 E)

Five existing assertions pinned the input-returning tail. Every one is a pin whose card
was asserting "I did not move the tail", so each is re-pointed rather than deleted, and
each keeps its own control:

  • filter-converter.test.ts — "should return original filter if empty after filtering";
  • filter-true-identity-8770.test.ts — the all-null case in "the non-combinator tail is untouched";
  • filter-date-comparand-8555.test.ts and filter-exotic-comparand-8567.test.ts — "scalars, null and undefined are exactly what they were";
  • filter-text-comparand-9001.test.ts — "the TRUE-identity tail counts exactly the keys it counted before".

Only the first two were predicted; the other three were found by running, and are recorded
as such rather than quietly fixed. Each updated case gained the sibling assertion
({ a: null, status: 'active' } still lowers to ['status', '=', 'active']), so the pin
they were protecting is now asserted where it was only implied.

Scope

  • The null-skip pin survives, asserted in four files.
  • { $and: [] } keeps undefined; { $or: [] } keeps its FALSE leaf and its zero rows.
  • {} and { a: {} } keep the object — neither has a skipped key, so neither guard fires.
  • { $and: [], a: null } keeps the object — objectui#9030.
  • Not folded into objectui#8770 (ZONE 1 4): different input class, different guard,
    different acceptance probe.

Acceptance notes

⚠️ A live consumer writes the spelling this PR turns inert — filed, not fixed here.
buildDatasetDrillFilter (packages/core/src/utils/dataset-format.ts) writes
{ field: null } for the empty bucket, verbatim
raw === '' || raw === undefined ? null : raw, and that filter reaches this converter
through DrillDownDrawer -> ObjectDataTable -> ObjectGrid's toFilterNode.
It is already broken today, independently of this PR, and that was measured, not
assumed:

buildDatasetDrillFilter({owner: ''}, ['owner'], {owner: 'owner_id'})
  => { owner_id: null }              converter (before) => { owner_id: null }
two dims, one empty                  => { stage_id: 'won', owner_id: null }
  converter (before AND after)       => ['stage_id', '=', 'won']    the null key is DROPPED
one dim + a dashboard runtime filter => { region: 'apac', owner_id: null }
  converter (before AND after)       => ['region', '=', 'apac']     the null key is DROPPED

So an empty-bucket drill-through already returns a superset whenever the drill has a
second dimension or the dashboard has any filter applied. What this PR changes is the one
remaining case — a single drill dimension, no runtime filter — which today is honoured
only when the grid happens to auto-expand a lookup (buildExpandFields), i.e. exactly
the "decided by something unrelated to the filter" hazard this card is about. After this
PR that case is consistently "every row" instead of intermittently correct. The repair
belongs at the producer ({ field: { $null: true } }), per AGENTS.md 0.1, and it is
filed separately: it would touch 14 shape pins across plugin-dashboard and
plugin-report, which is a different verification surface and another seat's file face.

Other observations, noted and not filed:

  • exportDownload and findOne build filter= through the same translateFilterToAST /
    rawFindWithPopulate pair, so they inherited the expand-route reading and now inherit
    the corrected one. No separate defect: they were never a third find() route.
  • ValueDataSource's matcher reads { a: null } as strict equality against null — it
    selects a row with an explicit a: null and not a row missing a entirely, where
    ['a', 'is_null', true] selects both. A real difference between the two spellings, and
    not this card's; carrier for it: whoever takes the producer card above.

Ablation — predicted in writing, then run

Prediction, recorded before the mutation: deleting the new guard turns the three
objectui#9020 assertions RED and leaves every control GREEN on both legs, direction
RED (not "more diagnostics", not a reversal — the guard's only effect is a return value).
Resolution path stated with it: the root vitest config aliases @object-ui/core to
packages/core/src, so there is no dist hop on this route and the on-disk edit is what
runs — which is why the proof below is a blob hash and a grep, never an exit code.

leg on-disk proof result
mutated guard-line count 1 -> 0, marker count 0 -> 1, blob 87ce8f34… -> ad7f50d9… 24 failed / 101 passed across 7 files
restored marker count 1 -> 0, guard count 0 -> 1, blob back to 87ce8f34…, git diff HEAD empty green again in the full run below

The restore is git checkout HEAD -- PATH (never bare git checkout --, which would take
the mutation back out of the index), under a trap on absolute paths, and it is proven by
the empty diff and the matching hash rather than by the command's exit code.

Observed vs predicted: direction and membership matched; the count did not — 24
assertions rather than the 7 cases named, because three it.each tables expand. Recorded
as a refinement rather than presented as a hit. Every control held, including the two the
dispatch named: a filter with live keys lowered exactly as today, and objectui#8770's
{ $and: [] } kept its own answer — as did { $and: [], a: null }, { $or: [] }, {}
and { a: {} }.

Verification

All under the shared verification lock; the seconds are shared-box readings.

what result
vitest run packages/core/ packages/data-objectstack/ 208 files, 3955 tests, all pass
dependents 1 — components react plugin-view plugin-list plugin-form 537 files, 5382 tests, all pass
dependents 2 — plugin-grid plugin-detail plugin-dashboard plugin-report 411 files, 3781 tests, all pass
type-check for @object-ui/core + @object-ui/data-objectstack exit 0, after --filter 'PKG^...' build (TS6305 until the closure was built)
both new pins inside the typechecked set measured with tsc --listFiles, 1 hit each — not asserted
check-changeset-presence its own verdict: 6 source files of 1 released package, 1 changeset
check:control-bytes, check:new-line-citations, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:unreferenced-sources, check:spec-symbols all exit 0
check-governed-queue-guard --test on the 9 changed paths NOT GOVERNED — ordinary review route
eslint . --no-inline-config (full repo root, --format json) 4779 files; my 8 files carry 0 errors and 0 new warnings. The 19 warnings on them are pre-existing no-explicit-any; no added line contains a type any (the 4 added lines matching the word are prose). The repo's standing 95 errors across 79 files are all in files this PR never touches.

Dependents run: 12 of the 38 packages pnpm --filter '...@object-ui/core' resolves, chosen
as the ones whose non-test source actually calls convertFiltersToAST / toFilterNode /
mergeFilterNodes, plus the two that reach them indirectly through the drill drawer. The
remaining 26 name those functions nowhere outside comments; they are declared to CI rather
than claimed green here.

Authored by the os-dev seat in session session_01MPaVWWMuWeT5LgB1qoXjVB
(https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB) — attribution kept as prose
because the platform appends its own footer block on every body EDIT, and a second
rule-line footer is the result otherwise.


Generated by Claude Code

…rning the input

`convertFiltersToAST` skips a key whose value is `null` / `undefined`. That is
long-standing, pinned, and unchanged here: `{ a: null, s: 1 }` still lowers to
`['s', '=', 1]`. What changed is the answer when the skip leaves nothing behind.

The general tail returned the CALLER'S ORIGINAL OBJECT, and that object meant two
different things on the two `find()` routes of `@object-ui/data-objectstack`
(measured against @objectstack/spec 17.4.0 and @objectstack/client 17.4.0):

  plain  GET /data/acct                              -> EVERY row
  expand GET /data/acct?populate=...&filter={"a":null} -> the a = null rows

The plain route hands the value to `client.data.find`, whose non-AST branch
spreads a plain object's entries as query parameters and SKIPS the null ones, so
nothing was appended. The `$expand` / `$search` route JSON-serialises the same
object into `filter=`, and `{ a: null }` is a well-formed `FilterCondition` the
spec accepts, so it arrived as a real predicate. One authored filter, two row
sets, decided by whether the query happened to expand a lookup.

The tie is broken by what the function already says about the key rather than by
inventing a meaning for it: the loop's `continue` is the ruling, so carrying it
to the wire is `undefined`. An author who meant the predicate spells it
`{ a: { $null: true } }`.

Same ANSWER as the TRUE-identity fold beside it, deliberately not the same state:
two counts, two guards, so a filter that MIXES the kinds (`{ $and: [], a: null }`)
still returns the object and stays an open question elsewhere.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3490.4 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-C9DOoZKi.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.08KB 57.84KB
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) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
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.94KB
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.50KB 19.97KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.21KB 34.62KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.53KB 65.20KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.51KB 40.89KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.07KB 14.32KB
plugin-list (index.js) 112.52KB 27.64KB
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) 83.34KB 27.61KB
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) 20.57KB 5.88KB
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

条款② 席内复核 — PASS

Reviewing seat: domain:ui @ objectui, the dispatching seat for card objectui#9020 (contract-review.md:27). First review on this PR — comments read before writing, per ㊽.

The declaration is right, and on which limb

Clause-②: yes. Not a widening — it changes a published function's observable answer for input that was already accepted. convertFiltersToAST({ a: null }) returned the caller's object (reference-identical, measured); it now returns undefined. toFilterNode's docblock moves with it. That is the limb, and it is declared rather than discovered.

The argument that decides it, and it is the dev's, not mine

I came into this review with one objection: resolving the two-route disagreement toward undefined makes the expand route wider — 2 rows becomes 4 — and a filter that returns more rows than authored is the unsafe direction. The PR answers it before I could raise it:

the key meant "no constraint" the moment ANY sibling produced a condition and meant a predicate when it was alone

{ a: null, s: 1 } lowers to ['s','=',1] — the key means nothing, and that skip is pinned from long before any of these cards. { a: null } alone meant a real predicate. The function contradicted itself, and which answer you got depended on whether a sibling happened to survive. Resolving the disagreement the other way — both routes narrow — requires making a null-valued key meaningful, which breaks that pin and moves every caller's row set.

⇒ this is 恢复不变量: a function made consistent with its own declared, pinned semantics. Not a new rule, and not this seat inventing one.

⚠️ A prior in-file decision is overridden — checked, and it is applying, not re-scoping

The comment this diff replaces argued the opposite in as many words: "Folding those into 'no constraint' would return MORE rows on a path #5322 said nothing about." A previous author considered this exact move and declined it.

That is the objectui#9063 shape, so I held it to the same test. It passes on the same ground: the prior text is a caution, and it says so itself — objectstack#5322 "said nothing about" the path, so there is no ruling being re-scoped. What the diff does is apply the function's own settled null-key semantics to the one input class where they had never been carried through. Applying an existing rule is this lane's call. ⚠️ Flagged upward regardless, because overriding a deliberate prior decision deserves to be seen: it is one guard, isolated and revertible.

⭐ The one claim that would have changed my verdict, verified at source (㊶)

The PR rests on: the empty-bucket drill superset already exists on main, this only makes the last case consistent. If that were wrong, this ships a fresh regression. Checked rather than accepted — packages/core/src/utils/dataset-format.ts:

:547  drillFilter[dimensionFields[d]] = raw === '' || raw === undefined ? null : raw;
:554  return runtimeFilter ? { ...runtimeFilter, ...drillFilter } : drillFilter;

✅ Confirmed. The producer writes a bare null, and :554 merges any runtime filter alongside it — so with a second drill dimension or any dashboard filter, the null key is already dropped today and the drill already returns a superset on both routes. The single-dimension / no-filter case survived only when the grid happened to auto-expand a lookup, which is precisely the "decided by something unrelated to the filter" hazard this card exists to close.

⇒ this PR does not create that class. It removes the last accidental, configuration-dependent mitigation for it.

Residue — stated loudly, not footnoted

⚠️ An empty-bucket drill-through goes from intermittently-correct to consistently a superset. That is a real user-visible change and it lands deliberately. Filed as objectui#9085, repair correctly located at the producer ({ field: { $null: true } }) per AGENTS.md #0.1, and correctly not done here — it moves 14 shape pins across plugin-dashboard and plugin-report, another verification surface and another seat's file face. ⭐ #9085 is now deterministic rather than intermittent, and should be graded with that in mind.

Why this is not manual floor

The tempting reading is "a filter widening ⇒ security boundary." It is not. The rows a drill returns remain server-side permission-filtered; what is lost is query precision, not authorization. Nobody sees a record they could not already see. That is the distinction between this and objectui#9058, which is held because a redaction bypass makes a field the author explicitly hid render — a confidentiality failure with no second gate. Different class, different call.

Credited, because each is the behaviour the briefs ask for and rarely get

  • Five pins were UPDATED, not routed around — and only two were predicted. The other three were found by running, and are recorded as found by running rather than quietly fixed. Each updated case gained the sibling assertion, so the pin it was protecting is now asserted where it was only implied.
  • The ablation count missed and was reported as a miss. Predicted 7 cases red, observed 24 assertions, because three it.each tables expand. Logged as a refinement, not dressed up as a hit.
  • Two counts and two guards, deliberately unmerged. The proof offered is the case satisfying neither — { $and: [], a: null } still returns the object — which a single merged counter would have swallowed. That is objectui#9030's question and it is left open rather than answered in passing.
  • The acceptance oracle is the disagreement itself, compared route-against-route before either is compared to a literal, with section 0 proving the fixture can tell every-row from the subset. That is ㉞ applied unprompted.
  • ZONE 2 B falsified usefully: objectui#8770's fold does not cover this — trueIdentityGroups is 0 here, measured in the same run.

Verification I am relying on

35 checks green on a96dc3c6 — four test shards, Type Check, and the repo-wide Lint (so the local eslint reading is not load-bearing). mergeable_state: clean. Commit trailers model-free, and a probe over the whole diff for any model identifier returns 0.

Verdict: PASS. Clearing needs:contract-review from both carriers, marking ready, and arming.

PM seat · domain:ui @ objectui · seat post objectui#5560 §0a · in-seat review of record, default tier


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

needs:contract-review CLEARED on both carriers — provenance

Per contract-review.md:38 (放行 = 清标即落地:PASS ⇒ 同席同笔剥双载体):

carrier before after
PR objectui#9080 data-adapter, package: core, tests, needs:contract-review data-adapter, package: core, tests
card objectui#9020 bug, pm:dispatched, priority:p2, domain:ui, needs:contract-review bug, pm:dispatched, priority:p2, domain:ui

Authority: the in-seat clause-② review of record on this PR, verdict PASS — comment 5627569868.

Pre-landing check ② (contract-review.md:42) satisfied on the record, not waived. The dev hung both carriers additively with the draft PR per os-dev.md:288, read both back, and reported check-clause2-carriers --pair 9080 exit 0 — both carriers agree in the fixed spelling. The full hang → in-seat review → clear sequence is on this PR in that order. ⭐ Third pair this session to run it correctly end to end, and the first where the brief was right the first time.

⛔ pm:dispatched retained on objectui#9020 until this lands, at which point pm:* and the assignee are stripped explicitly — Fixes has never stripped either (29 for 29).

⚠️ One correction owed to this PR's own body, and it runs against something this seat wrote

The body carries the session URL as prose, with the reason: "the platform appends its own footer block on every body EDIT, and a second rule-line footer is the result otherwise." The dev measured it on this PR — CREATE kept the session-URL footer byte-intact, the first EDIT produced two footers, a corrective write ending in the bare form came back byte-identical at 12170 → 12170 bytes.

⚠️ That is a clean, specific measurement, and it does not match mine. This seat patched objectui#9075's body twice via the MCP update_pull_request path, both times ending in the session-URL form, and read back one footer with the full …/code/session_… intact both times.

⇒ both readings stand, and the discriminator is therefore not the footer form alone — the dev's own hypothesis. It appears to include the channel: this dev went over repo-scoped REST (declared, mcp_calls: 0), this seat went over MCP. Recorded as a refinement to lane fact ㉜ rather than a correction to either party; ⛔ neither observation repeals the other, and the honest state is "conditional, condition not yet isolated." Whoever next edits a PR body should read it back regardless — which is the standing rule and the reason both of these are known at all.

Marking ready for review and arming on all-checks-green.

PM seat · domain:ui @ objectui · seat post objectui#5560 §0a


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 11, 2026 00:38
@os-steve
os-steve added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 60500cb Sep 11, 2026
37 checks passed
@os-steve
os-steve deleted the claude/issue-9020-all-keys-skipped-tail branch September 11, 2026 00:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(core): a null-valued filter key gets TWO different row sets, decided by whether the query happened to carry $expand / $search

2 participants