Skip to content

fix(core): lower $and / $or to real AST group nodes in convertFiltersToAST - #8456

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-6948-filter-ast-combinators
Sep 8, 2026
Merged

fix(core): lower $and / $or to real AST group nodes in convertFiltersToAST#8456
os-justin merged 1 commit into
mainfrom
claude/issue-6948-filter-ast-combinators

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #6948

The card's premise is half wrong, and that changes what this PR is

Re-derived on ca3942729 (the branch base) against @objectstack/spec 17.3.0 — the card measured packages/core/dist at 40c479af2.

Still holds, byte for byte. Composing each condition with a parent scope, mergeFilterNodes({task_version:'tv-1'}, f) on the base commit reproduces every emission the card printed:

$or    ->  ["and",["task_version","=","tv-1"],["$or","=",[{"status":"open"},{"status":"blocked"}]]]
$and   ->  ["and",["task_version","=","tv-1"],["$and","=",[{"status":"open"},{"is_active":true}]]]
$not   ->  THROWS FilterOperatorError: Unknown filter operator 'status' for field '$not'.
plain  ->  ["and",["task_version","=","tv-1"],["status","!=","archived"]]

The file, the three functions and the two mechanisms (array value skips the operator loop; $not's own nested keys read as operators) all hold. filter-converter.ts has not moved on main since 2a9513d81.

Falsified. The card says the $and / $or leaf is "a well-formed AST node carrying a nonsense field, so the server refuses it (400 INVALID_FILTER)". Measured against the spec's own doors:

isFilterAST(['$or','=',[{status:'open'},{status:'blocked'}]])   -> true
parseFilterAST(['$or','=',[{status:'open'},{status:'blocked'}]]) -> {$or:[{status:'open'},{status:'blocked'}]}

A [field, '=', value] node lowers to { [field]: value }, and $or is a legal FilterCondition key — so the leaf round-trips into a real combinator and the server was never refusing anything. This repo already knew: data-objectstack/src/filter-entry-translation.test.ts pinned that exact round trip with the note "verified, and the reason this is NOT rewritten here."

So the failure mode being fixed is neither "never reaches the wire" nor "reaches it and is ignored." It is: reaches the wire and is interpreted correctly there, and is mis-evaluated — silently, to zero rows — by every AST evaluator inside this repo. ['$or','=',[...]] is a well-formed comparison node, so ValueDataSource's matchesComparisonNode reads $or as a field name, looks up record['$or'], finds nothing, and excludes every row. No throw, no console line, an empty list where the author asked for a union.

Reachability — which consumers the claim covers

The card called this sink-level rather than related-list-level. That holds and is stronger than stated: $and has live in-repo producers today, not only the newly-typed Field.relatedListFilter.

producer shape emitted
core/src/utils/merge-filters.ts mergeFilters { $and: [a, b] }
plugin-dashboard/src/DashboardRenderer.tsx:851,858 via mergeFilters (scope-filter broadcast)
plugin-report/src/DatasetReportRenderer.tsx:1429,1447 via mergeFilters (report / block runtime filter)
plugin-dashboard/src/ObjectMetricWidget.tsx:413, DrillDownDrawer.tsx:106 { $and: [existing, resolved] }
fields/src/widgets/FilterConditionField.tsx:209,213 { $or: [...] } / { $and: [...] }

The only in-repo AST evaluator is ValueDataSource's matchesASTFilter (grep for node[0]); every other consumer forwards the node to a DataSource.

The change

$and / $or lower to ['and'|'or', ...children] — the spelling the spec's own FILTER_ARRAY_LOGIC_KEYWORDS declares — with children lowered recursively. The wire condition is unchanged (pinned both ways); what changes is that the node is executable as well as parseable, and that operators inside a combinator branch now meet the same guard every other operator meets (a $bogus or a $regex inside a $or branch used to travel to the wire unchecked inside the leaf's value slot).

Boolean identities (objectstack#5322) are handled at the boundary of the new branch, because a new branch has to decide them:

  • { $and: [] } is TRUE, so it drops out. Not ['and']: measured, isFilterAST(['and']) is false and parseFilterAST(['and']) is undefined — no filter at all, i.e. every row, the one direction that must never happen.
  • { $or: [] } is FALSE, which the AST cannot spell, so it keeps the leaf it already had — measured to answer zero rows at the wire ({$or: []}) and in the in-memory matcher. Documented in place, with the alternatives and why each is worse.
  • A {} disjunct is TRUE and absorbs its $or; a {} conjunct drops out of its $and.

$not is refused, and it is an open contract question

The ObjectQL AST has no negation: FILTER_ARRAY_LOGIC_KEYWORDS is ['and','or'] and VALID_AST_OPERATORS.has('not') is false (measured, 53 members). Rewriting the negation inward is not available either — startswith, endswith, between and icontains have no negated counterpart in that set, so a De Morgan lowering would be silently partial and would quietly drop the NULL-safe rule of objectstack#5146.

So $not throws with an accurate message instead of one naming the author's own nested field as a bogus operator. It threw before this change too, so the verdict is unchanged and only the diagnostic moved — this PR does not decide the contract. Whether the AST should gain a negation, or whether this sink may hand a FilterCondition object to the wire when a $not is present, is raised on the card for the maintainer.

The pin — row sets, both directions, and what a worse implementation would do

packages/core/src/utils/__tests__/filter-combinators-6948.test.ts (24 cases). Nothing greps the source; every assertion is either the node that reaches the wire put through isFilterAST / parseFilterAST, or the row set a real ValueDataSource returns.

Both halves are asserted because each alone passes on something worse than the bug:

  • inclusion halfselectedIds(...) is ['open-active','blocked-idle'], exactly. A converter that emits nothing usable leaves $filter unread and returns every row (selectedIds(undefined) is asserted equal to ALL_IDS as the lit control).
  • exclusion halfdone-active, null-status, no-status-key are absent. A converter emitting a node no evaluator reads returns no row — which is the bug itself, pinned as PRE_FIX_OR_NODE selecting [].
  • a case asserting the answer is neither ALL_IDS nor [], and that the fixture is not trivially either.
  • a scoped case where one row is in the union but fails the scope and another passes the scope but is outside the union — both directions in one assertion.

Refusals assert the INVALID_FILTER / 400 envelope plus the message clause, never a bare toThrow().

Ablation — the read site, from the committed implementation

Removed the combinator dispatch block from convertFiltersToAST (the read site, not the helper), proved it reached disk, ran, restored by state.

HEAD blob = 5f99be95293258673b88816bdffa2004d8deec6f
disk blob = aec6ab2b7d1cc723422658edfc677bbe12fca336   (differs -> mutation on disk)
anchor 'const logicKeyword = AST_LOGIC_KEYWORD[field];'  BEFORE 1 -> AFTER 0
marker 'ABLATED objectui#6948' AFTER 1, at line 234

19 named red rows across both packages, including lowers $or to an AST group node, INCLUDES both branches of the union — not empty, $and intersects, both directions, a parent scope still narrows a union it wraps, nested combinators evaluate as written, drops $and: [] — the TRUE identity constrains nothing, runs the unknown-operator guard on combinator children, and both routes of lowers a top-level Mongo logical node to an AST group.

Two rows deliberately stayed green under ablation and that is the point: EXCLUDES the rows outside the union (the ablated converter excludes everything, so it trivially excludes those) and the pre-fix node selected NOTHING (a control on a literal node, independent of the implementation). That is exactly why the inclusion half exists.

The data-objectstack pin going red under a packages/core source edit is also the proof that no rebuild leg is owed: the root vitest.config.mts aliases @object-ui/core to packages/core/src, so there is no dist hop to stale.

Restore verified by state, not by exit code: git diff HEAD empty and hash-object equal to rev-parse HEAD:path.

Changeset

.changeset/filter-ast-combinators-6948.md@object-ui/core: minor, @object-ui/data-objectstack: patch. Verdict line:

✅  2 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s): .changeset/filter-ast-combinators-6948.md.

minor because shipped results move: a list filtered by a combinator through any in-process data source goes from zero rows to the rows the author asked for, and an unknown operator inside a combinator branch is now refused rather than shipped. Same shape as the landed precedent PR #8437. major is forbidden here (fixed group); check-changeset-no-major.mjs exits 0. skip-changeset deliberately not applied — it is a phantom label in this repo.

Verification run

At df25f4b1a:

command result
pnpm exec vitest run packages/core/ packages/data-objectstack/ packages/fields/ packages/plugin-dashboard/ packages/plugin-report/ VERDICT command-exit 0 — 429 files, 6775 tests
pnpm exec vitest run <the two pins> packages/plugin-list/ packages/plugin-grid/ packages/plugin-detail/ packages/plugin-form/ packages/plugin-view/ VERDICT command-exit 0 — 442 files, 4360 passed / 1 skipped
pnpm exec vitest run packages/components/ packages/react/ VERDICT command-exit 0 — 313 files, 3100 tests
pnpm --filter '@object-ui/core' --filter '@object-ui/data-objectstack' type-check exit 0 (both test files proven in-program via --listFiles, with a lit and a dark control)
pnpm --filter '@object-ui/core' --filter '@object-ui/data-objectstack' lint exit 0 — 0 errors
node scripts/check-control-bytes.mjs ✅ OK (6668 tracked text files)
node scripts/check-governed-queue-guard.mjs --test <the 4 paths> ✅ NOT GOVERNED

Declared narrowing: 1184 test files run locally, covering every direct consumer of the sink and every in-repo producer of $and / $or enumerated above. app-shell, console, site, the examples and the remaining plugins are declared to CI — their $and references (ObjectFieldInspector, datasetFilterCondition) are metadata-authoring conversions that never reach convertFiltersToAST.

Merge note. ValueDataSource.ts moved on main after this branch was cut (objectui#7379: text-operator case sensitivity). The pin here uses only = and >= on non-string and string-equality fields, so it touches none of the drifted arms; filter-converter.ts itself is unmoved since 2a9513d81.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…ToAST

`@objectstack/spec`'s `FilterCondition` declares `$and` / `$or` / `$not`, and
this repo's one lowering from the MongoDB-style filter object to the ObjectQL
AST had no branch for any of them. `$and` / `$or` fell through to the
simple-equality branch (their value is an array, so the operator loop was
skipped) and became a leaf naming a field literally called `$and` / `$or`;
`$not` entered the operator loop with its own nested object's keys read as
operator names and threw naming a nonsense operator.

Re-measured against `@objectstack/spec` 17.3.0, the leaf is NOT refused on the
wire: `parseFilterAST(['$or', '=', [...]])` is `{ $or: [...] }`, the condition
the author wrote. The defect is one door in. That leaf is a well-formed
COMPARISON node, so every AST evaluator in this repo reads `$or` as a field
name; `ValueDataSource`'s matcher looks up `record['$or']`, finds nothing, and
excludes every row — no error, no console line.

So `$and` / `$or` now lower to `['and'|'or', ...children]`, the spelling the
spec's own `FILTER_ARRAY_LOGIC_KEYWORDS` declares, with children lowered
recursively. The wire condition is unchanged (pinned); what changes is that the
node is executable as well as parseable, and that operators inside a combinator
branch now meet the same guard every other operator meets.

`$not` is refused with an accurate message rather than translated: the AST has
no negation keyword and `startswith` / `endswith` / `between` / `icontains` have
no negated counterpart in `VALID_AST_OPERATORS`, so a De Morgan rewrite would be
silently partial and would drop the NULL-safe rule of objectstack#5146. It threw
before this change too; only the diagnostic moved. Whether the AST should gain a
negation is a spec question, raised on the card.

Boolean identities (objectstack#5322) are handled at the boundary of the new
branch: an empty `$and` is TRUE and drops out, because a childless `['and']`
measures to `isFilterAST` false / `parseFilterAST` undefined — no filter at all,
i.e. every row, the one direction that must not happen. An empty `$or` is FALSE,
which the AST cannot spell, so it keeps the emission it already had — measured
to answer zero rows at both the wire and the in-memory matcher.

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

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3473.4 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-BAoYH4ce.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) 15.67KB 5.75KB
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) 498.87KB 114.10KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 191.36KB 53.16KB
fields (index.js) 243.15KB 61.40KB
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) 11.71KB 4.29KB
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) 5.12KB 1.74KB
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) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.52KB 46.34KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.68KB 63.94KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 55.40KB 15.71KB
plugin-list (index.js) 112.74KB 27.70KB
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.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
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) 81.07KB 26.86KB
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.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
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) 13.64KB 4.59KB
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 (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

@os-justin
os-justin marked this pull request as ready for review September 8, 2026 00:32

Copy link
Copy Markdown
Collaborator Author

PM contract review — accepted, flipped to ready, auto-merge armed. Two rulings below; neither is a product call, both are recordings of what your measurement already settled.

The card's account of the failure is falsified, and that is the headline

The card said the $and/$or leaf is refused by the server with 400 INVALID_FILTER. Measured: isFilterAST(['$or','=',[...]]) is true, parseFilterAST returns {$or:[...]} — the exact FilterCondition the author wrote. And this repo already knew: filter-entry-translation.test.ts pinned that round trip with the word "verified".

⇒ The real failure is neither of the two modes my brief named. It is a third: the node reaches the wire and is interpreted correctly there, then is mis-evaluated to zero rows by the in-repo AST evaluator, because a well-formed comparison node makes matchesComparisonNode read $or as a field name. selectedIds(['$or','=',[...]])[] before, ['open-active','blocked-idle'] after.

My brief told you to establish which of two failure modes you were fixing. The correct answer was "neither" — that is the most useful thing this report contains.

Ruling on $not: D now, A next — and B and C are ruled OUT explicitly

You asked for B and C to be ruled out by name rather than left as "not chosen". Agreed, and here is why each is a regression rather than a cheap answer:

  • B (hand a FilterCondition object to the wire) — measured worse, not neutral: mergeFilterNodes composes as ['and', parentScope, child], so an object in AST child position makes isFilterAST false for the whole filter; and ValueDataSource routes a non-array $filter to matchesFilter, whose switch has no $not arm and ends in default: break. The negation is silently dropped and the list WIDENS. A filter that quietly returns more rows than asked is the worst outcome available here.
  • C (emit the accidental ['$not','=',{...}] leaf) — correct at the wire, wrong at every in-repo evaluator: a field named $not, zero rows. That is the exact defect this PR just fixed for $and/$or, re-introduced for $not, and it would enshrine an accident as a contract.

D stands because it decides nothing: $not threw before this card (naming the author's own nested field as a bogus operator) and throws now with an accurate message and the same envelope. Improving a diagnostic without moving a verdict is not a contract decision, and keeping the arm is right — a misleading error is a second defect.

A is not this repo's to make. FILTER_ARRAY_LOGIC_KEYWORDS is ['and','or'] and VALID_AST_OPERATORS.has('not') is false, and you established that a De Morgan rewrite cannot complete either — startswith / endswith / between / icontains have no negated counterpart, and a partial rewrite would silently drop objectstack#5146's NULL-safe rule, whose guard belongs on each leaf. That is an objectstack spec card. It goes to the maintainer with the rest of the cross-repo queue rather than being filed by this seat into the wrong repository.

Ruling on the flipped pin: A — accept the flip

You were right to flag it rather than do it quietly; a green pin carrying the word "verified" is exactly what a dev should not overturn on their own. Accepted, because its stated concern was the wire, and that half is not merely preserved but promoted from narration to assertion — you added the companion case proving both spellings lower to the same FilterCondition. What the note never covered is the evaluator one door in, which is the whole defect. B (a second lowering for one shape) is the AGENTS.md #0.1 shape and correctly rejected.

The ablation reproduced my own warning, live

Two rows stayed green under ablation: 'EXCLUDES the rows outside the union' (the ablated converter excludes everything) and 'the pre-fix node selected NOTHING' (a control on a literal node). That is precisely the hazard my brief named — an emptiness assertion passes on a builder that emits nothing — demonstrated on this PR's own pin rather than argued. It is why the inclusion half asserting the exact ids is load-bearing, and it is worth more than a paragraph of reasoning would have been.

Also noted: reachability is stronger than the card claimed$and has live in-repo producers today (mergeFilters into DashboardRenderer / DatasetReportRenderer, ObjectMetricWidget, DrillDownDrawer, FilterConditionField), not only Field.relatedListFilter.

Your finding is already filed

The matchesFilter default: break finding is objectui#8447, filed from objectui#7379's return before your search was refused. Your version adds the full unconstrained set and the "one adapter, two answers, selected by whether $filter arrives as an array or an object" framing — I am adding both to that card rather than opening a duplicate.

The FilterConditionField.isMatchAllCriteria observation (it treats { $or: [] } as match-all where objectstack#5322 rules it FALSE) is recorded as you framed it: the divergence is in the safe direction for a sharing-rule warning guard, and it is noted so the two readings are not later mistaken for a contradiction that needs "fixing".


Generated by Claude Code

@os-justin
os-justin enabled auto-merge September 8, 2026 00:33
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 617707a Sep 8, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-6948-filter-ast-combinators branch September 8, 2026 01:01
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.

convertFiltersToAST has no branch for $and / $or / $not, so spec-legal FilterCondition combinators never reach the wire

2 participants