Skip to content

fix(core): lower the TRUE-identity combinators to "no constraint" instead of handing back the caller's object - #9019

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-8770-lower-true-identity-groups
Sep 10, 2026
Merged

fix(core): lower the TRUE-identity combinators to "no constraint" instead of handing back the caller's object#9019
os-steve merged 2 commits into
mainfrom
claude/issue-8770-lower-true-identity-groups

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #8770

convertFiltersToAST({ $and: [] }), { $or: [{}] } and { $and: [{}] } returned the input object unchanged. lowerLogicalGroup answers undefined for a group that reduces to the TRUE identity — deliberately, so no childless ['and'] is emitted — but when such a group was the only thing in the filter that undefined fell through to the general tail (if (conditions.length === 0) return filter), so the group reappeared one level up, in the $ dialect, in the slot the AST occupies. The same function already lowered the fourth identity correctly, which is what made this an internal inconsistency rather than an open question.

Direction per triage (comment 5619918295): the producer aligns to the consumer that objectui#8513 already settled.

1. Re-measurement of all four identity groups

Taken myself, against @objectstack/spec 17.4.0 (the card measured 17.3.0) and this branch's base 2596b1b85, before any edit:

input before isFilterAST after
{ $and: [] } {"$and":[]} — same object back false undefined
{ $or: [{}] } {"$or":[{}]} — same object back false undefined
{ $and: [{}] } {"$and":[{}]} — same object back false undefined
{ $or: [] } ["$or","=",[]] true unchanged (control)

The PM's "exactly three wrong, one right" assumption holds on 17.4.0. Two extra readings worth recording, because they show the defect is a family and the three shapes are representatives, not the whole set:

  • { $or: [{}, { s: 1 }] } and { $and: [{}, {}] } also come back unlowered — anything whose combinators all absorb or drop.
  • { $and: [], s: 1 } was already correct (["s","=",1]): the defect only appears when the identity group is the only producer of conditions. That sibling-dependence is the same hazard objectui#8555 called out on this file.

2. The p2 fence — what the refusal currently does

Answer: a HARD ERROR (400 UNSUPPORTED_QUERY_PARAM) on the default provider: 'object' list path. Not a silent empty. Nothing can be relying on it to scope data, so there is no caller to stop for.

How I established it — three links, each measured or pinned rather than assumed:

  1. The producer (measured, above): the object comes back unlowered.

  2. The client (measured, real @objectstack/client 17.4.0 with a stub fetch, observing the URL it builds): client.data.find() tests the value with isFilterAST, and its else branch spreads a plain object's entries as query parameters. So the request is

    • ?$and= for { $and: [] }
    • ?$or=[object Object] for { $or: [{}] }
    • ?$and=[object Object] for { $and: [{}] }

    with no filter parameter at all, while the control { $or: [] } correctly leaves as ?filter=["$or","=",[]].

  3. The server (read + pinned in its own repo): the shared normalizer rejects any unknown $-prefixed query parameter — Unsupported query parameter(s), status: 400, code: 'UNSUPPORTED_QUERY_PARAM' — and that is pinned by packages/objectql/src/protocol-data.test.ts ("rejects an unknown $-prefixed query param with 400 UNSUPPORTED_QUERY_PARAM"). $and / $or are not in the supported set ($top, $skip, $orderby, $select, $count, $search, $searchFields, $filter, $expand) and are not reserved names either.

⚠️ A correction to the card, which matters for the fence. The card says these shapes "do not pass isFilterAST, so the filter is refused". The outcome is right but the mechanism is not, and the difference is the whole fence: the object is never sent as a filter and judged. It is shredded into junk query parameters, and the 400 is about the junk. The list therefore fails loudly — an INVALID_FILTER-class "the filter is malformed" outcome, never an empty page that could be mistaken for "no records".

I also traced the other wire route (rawFindWithPopulate, taken when $expand or $search is present, plus the export route). There the same object travels verbatim as filter={"$and":[]}, and the server's normalizer accepts a plain object as a FilterCondition (options.where = parsedFilter), so that route already answers every row today. ⇒ the two find() routes disagreed about one filter; after this change they agree, and the widening direction the fence warns about is not new on either — one route was loudly broken, the other already wide.

No caller depends on the refusal. In-repo, the only pin that recorded the old behaviour is objectui#8513's conformance file, and it recorded it explicitly as a symptom ("Not asserting this is right — asserting it is what happens"); it is updated here.

3. Which layer, and why

⛔ Not picked in passing. Three candidates, and the tail is the one that is actually wrong:

  • toFilterNode — rejected. It does not serve the consumer this card is about: @object-ui/data-objectstack calls convertFiltersToAST directly on both of its routes, so a fold there would leave the wire exactly as broken as it is.
  • A distinguishable return value (a sentinel separating "no constraint" from "unlowerable") — rejected on a measurement rather than on taste. Nothing unlowerable reaches that tail. Every arm that cannot lower its input throws ($not, $regex, a bare array, an exotic comparand, an unknown or retired operator). What reaches conditions.length === 0 is only: a TRUE-identity combinator, a null/undefined-valued key, and an empty operator map — all of them "no constraint". There is no second meaning to distinguish, so a new sentinel would be vocabulary without a referent.
  • convertFiltersToAST's tail — chosen. undefined is not a new vocabulary either: it is already what toFilterNode, mergeFilterNodes and data-objectstack's translateFilterToAST mean by "no filter, skip the slot", and all four call sites already act on it correctlylowerLogicalGroup tests Array.isArray, the other three test for undefined or falsiness. The declared return type gains | undefined (a published-API change, called out in the changeset with migration text).

And it is deliberately scoped to a filter whose EVERY key is such a group — that is why the implementation counts the collapsed groups and compares the count with the key count, instead of using a flag. The tail also serves inputs that are not combinators at all, and folding those in would have been a real widening on a path nobody ruled about: { $and: [], a: null } travels the $expand route as filter={"$and":[],"a":null}, which the server reads as a genuine a IS NULL predicate. Those keep the object they have always returned, and section 6 of the new test file pins that boundary.

4. Tests — one assertion per group, with the already-correct one as control

New: packages/core/src/utils/__tests__/filter-true-identity-8770.test.ts (24 cases), driven by the published FILTER_LOGIC_ROWS fixture the identity ruling is held to.

  • the three groups lower to "no constraint"; control { $or: [] } keeps its ['$or','=',[]] leaf and still answers zero rows;
  • the invariant that actually broke, asserted through the spec's own door: every returned value is either absent or isFilterAST-readable;
  • composition — mergeFilterNodes(identity, ['a','=','x']) is now the plain node, where it used to be ['and', { $and: [] }, ['a','=','x']], an object in AST child position;
  • nesting — { $and: [{ $and: [] }] };
  • the untouched boundary — all-null filter, empty operator map, { $and: [], a: null }, { $and: [], a: 'x' }.

⚠️ One section is labelled as NOT witnessing this fix, in the file itself: the row-set section stays green on both ablation legs, because objectui#8513 already made the matcher answer the object dialect correctly, so the unlowered filter and the absent one select the same four rows. It pins the producer/consumer alignment direction, not this change — said out loud rather than left to look like coverage.

5. Ablation (run, not reasoned about)

Fix committed first, then the fold removed on disk, then restored. No dist/ is involved: both test files import the module by relative source path, so vitest compiles the mutated source directly.

  • on-disk proof: anchor count 1 -> 0, injected marker 0 -> 1, blob hash f6b0632 -> ae00617 (a hash change was asserted, and an empty hash treated as failure);
  • restored: blob hash back to f6b0632 = the HEAD blob, and git diff HEAD on the path is empty;
  • the script carries trap restore EXIT INT TERM with absolute paths, and restores with git checkout HEAD -- path (never bare git checkout --, which would take the mutation back out of the index).
leg result
fold present (HEAD) 61 passed, 0 failed
fold removed 12 failed, 49 passed
restored byte-identical to HEAD

The 12 are exactly the predicted ones: the 3 shape cases, the 3 isFilterAST-invariant cases, the 3 merge cases, TRUE-and-FALSE, the nested case, and objectui#8513's updated chain pin. Predicted 13 before running; the single difference was my own miscount (two assertions living in one it), not a case that failed to witness.

6. Gates

gate exit note
turbo run type-check (repo-wide, 81 packages) 0 81/81 successful — the published return type changed, so the whole tree is the honest scope
vitest run packages/core/ packages/data-objectstack/ 0 203 files, 3869 tests passed
turbo run lint (@object-ui/core, @object-ui/data-objectstack) 0 0 errors; the 534 warnings are pre-existing no-explicit-any
eslint --format json on the 4 changed source files 0 4 files, 0 errors, 139 warnings, all pre-existing rule classes
check-control-bytes.mjs 0 plus a direct scan of the changed files for the non-NUL control range
check-changeset-presence.mjs 0 "3 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
check-changeset-no-major.mjs 0 changeset is minor
check-governed-queue-guard.mjs --test (5 paths) 0 "NOT GOVERNED — 5 path(s) checked against 5 governed surface(s); none matched"
check-new-cross-file-line-citations.mjs 0
check:doc-types 0
check:doc-snippets 2 -> 0 exit 2 was PRECONDITION NOT MET on an unbuilt tree; re-run after the scoped build it names — 638/638 blocks judged
check:doc-examples 2 -> 0 same; 124 blocks, the 89 failures all declared in the ledger

Repo-wide pnpm lint and the full-farm suites are left to CI. Every exit code above was captured to a file before being read, never through a pipe. The two heavy runs went through the shared verify lock.

7. Assumptions I was asked to falsify

🤖 Generated with Claude Code

https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB


Generated by Claude Code

…tead of the caller's object

`convertFiltersToAST({ $and: [] })`, `{ $or: [{}] }` and `{ $and: [{}] }` returned the
input object unchanged. `lowerLogicalGroup` answers `undefined` for a group that
reduces to the TRUE identity — deliberately, so no childless `['and']` is emitted —
but when such a group was the only thing in the filter that `undefined` fell through
to the general tail (`if (conditions.length === 0) return filter`), so the group
reappeared one level up in the `$` dialect, in the slot the AST occupies. The same
function already lowered the fourth identity, `{ $or: [] }`, correctly.

Measured against @objectstack/spec 17.4.0 and @objectstack/client 17.4.0: the returned
object is not sent as a filter and refused — `client.data.find()` spreads a plain
object's entries as query parameters, so `{ $and: [] }` left as `?$and=` with no
`filter` parameter, and the server answered 400 UNSUPPORTED_QUERY_PARAM. A filter whose
ruled answer is every row was a failed list, so nothing depended on the refusal to
scope data.

Scoped to a filter whose every key is such a group: the same tail also serves `{}`, an
all-null filter and an empty operator map, and those keep the object they returned — a
null-valued key is this converter's own tolerance rather than a ruled identity, and it
reaches the server as a real `a IS NULL` predicate on the `$expand` route.

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

Both ablation legs leave it green — objectui#8513 already made the matcher
answer the object dialect correctly, so the unlowered filter and the absent one
select the same rows. It pins the producer/consumer alignment direction, not
this card's change, and now says so.

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

Copy link
Copy Markdown
Collaborator Author

One more gate result, arrived after the body was written — the three consumers the card names on the lowering chain (ObjectGrid's schemaFilter, plugin-list's buildEffectiveFilter, plugin-view's ObjectView):

gate exit note
vitest run packages/plugin-list/ packages/plugin-view/ packages/plugin-grid/ 0 232 files, 2283 tests passed

Also re-derived after the fact: main moved to da7c3d96d while this was in flight (docs only, PR #9005), and it touches neither packages/core nor packages/data-objectstack, so mergeable_state: behind here is staleness and not an overlap. No rebase — the queue rebuilds.

Out of scope and filed separately rather than repaired here: #9020, a null-valued filter key getting two different row sets on the two find() routes. PR #9019's fold is key-count-scoped precisely so that it does not decide that question.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3490.2 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-CEx6e5vT.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) 7.95KB 3.19KB
create-plugin (index.js) 28.21KB 9.54KB
data-objectstack (index.js) 207.56KB 57.44KB
fields (index.js) 247.01KB 62.29KB
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.63KB 19.99KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 130.98KB 34.54KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 253.71KB 65.78KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 210.87KB 57.30KB
plugin-kanban (index.js) 46.03KB 14.30KB
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.55KB 3.32KB
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) 2.32KB 1.24KB
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 Author

In-seat review of record — Clause-② (contract delta), domain:ui @ objectui seat

Verdict: land. One observation below is a follow-up card, not a change to this PR.

Clause-② is engaged, and I want to say why explicitly rather than let it be inferred: convertFiltersToAST is a published exportpackages/core/src/index.ts:23 is export * from './utils/filter-converter.js' — and its declared return type gains a member. That is a contract delta on @object-ui/core's public surface. Per objectstack#17285 this lane's in-seat default-tier review plus the gates is the review of record for that; the contract-review tier is not this lane's. So this comment is the review, and it is answerable.

The consumer set, enumerated by me rather than taken on the report's word

⚠️ My first enumeration was not a reading: git grep -- 'packages/*/src' is a glob matched against whole paths, not a directory prefix, and it returned zero. The control token toFilterNode returned zero under the same pathspec, which is what exposed it — the same instrument failure the #8920 dev caught last round. Corrected pathspec, control lit, and then:

convertFiltersToAST has two call sites in this repository outside its own module, both in packages/data-objectstack/src/index.ts:

site absorbs undefined? how
:627, inside translateFilterToAST already that function's own declared return type is unknown | undefined and its docblock already reads "Returns undefined when the input is empty/unrecognized so callers can skip emitting ?filter= entirely." The new answer is inside a type and a meaning it already had.
:4613:4622 this PR const lowered = …; if (lowered !== undefined) options.filters = lowered;

And translateFilterToAST has two callers, not the one the report names — :4459 (list GET) and :4541 (exportRecords). I checked both: each is const translated = …; if (translated !== undefined) { queryParams.set('filter', …) }. So the widening is absorbed at every sink, and the report undercounted in the safe direction.

The four direct toFilterNode sinks outside coreRelatedList.tsx:477, LineItemsPanel.tsx:151, ObjectGrid.tsx:1479 and :1853 — are unaffected at the type level, because toFilterNode's signature already carried | undefined and is unchanged here. At runtime they now receive undefined where they received an object; that direction cannot regress, since the object they used to receive is the one that left as ?$and= and drew 400 UNSUPPORTED_QUERY_PARAM.

The fence, and why the scoping is a count and not a flag

trueIdentityGroups > 0 && trueIdentityGroups === Object.keys(filter).length is the right shape. The return filter tail also serves {}, an all-null filter and an empty operator map, and those are not the ruled family: an all-null filter travels the $expand route as filter={"a":null} and the server reads a real a IS NULL predicate, so folding it would return more rows on a path objectstack#5322 never ruled on. The PR keeps that boundary and pins it in section 6. { $or: [] } stays FALSE with its ['$or','=',[]] leaf, carried as a live control through every section — a change that flattened the identities into one arm takes the control with it. That is the correct control design for this defect class.

On the evidence, including the part that is honestly labelled as not evidence

Section 3 is declared in its own docblock to not witness this fix — both ablation legs leave it green, because objectui#8513 already taught ValueDataSource's matcher to answer the object dialect. Saying so in the file, rather than letting the next reader count it, is the standard this lane wants. The witnesses are sections 1, 2, 4, 5 and 6.

The edit to #8513's existing pin is legitimate and I checked it specifically, because editing another card's pin is where a ruling quietly dies. It does not weaken #8513: that card's ruling is what this adapter answers for the object dialect, and the assertion still exercises exactly that — moved from selectedIds(toFilterNode({ $and: [] })) to selectedIds({ $and: [] }), i.e. from the literals rather than through a producer that no longer hands them down. A pin was added (toFilterNode({ $and: [] }) is now asserted toBeUndefined), not removed, so a silent revert of this PR reddens there too.

The changeset is minor with the break and a Migration section spelled out, which is the correct handling given this repo forbids major; Changeset Bump Policy is green. The new JSDoc @example compiles under Doc Snippet Type Check (green on this head) with no ledger row added, so it is a real example and not a declared failure.

Observation — the fence is one shape wider than its own rationale. Follow-up card, not a change here.

Object.keys(filter).length counts keys that the loop itself deliberately skipped: the first statement in the loop body is if (value === null || value === undefined) continue;. For null the PR has a stated and correct reason to keep the old answer. For an undefined-valued key that reason does not hold — JSON.stringify drops the key entirely, so { $and: [], b: undefined } reaches the $expand route as filter={"$and":[]}, which is already the ruled TRUE answer, and reaches the raw-GET route as the same ?$and= failure this card exists to end. So { $and: [], b: undefined } is in the ruled family and is not folded.

Explicitly not a blocker and not a regression: the behaviour is byte-identical to before this PR, so nothing that worked stops working. It is a missed member, not a broken one, and widening a PR that is already green under review to chase it is the wrong trade. I will file it as its own card against this tail once this lands, so the ruled family closes on the record rather than in a review comment.

Gates

34-check roster read on this head; the five that were still running at review time are the test shards and Type Check. Landing on every-check-green, not on this comment.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants