fix(core): gate the comparand in both filter-converter arms — lower a Date, refuse an array on a scalar view operator - #8566
Merged
os-justin merged 2 commits intoSep 8, 2026
Conversation
…ropping it
A `Date` passed both halves of the operator-object gate (`typeof value ===
'object' && !Array.isArray(value)`), so it entered the operator loop —
and `Object.entries(someDate)` is `[]`, so the loop body never ran and no
condition was pushed for the field at all. `{ status: 'a', created: someDate }`
lowered to `['status', '=', 'a']`: not refused, not lowered wrongly, ABSENT.
The result set got wider than the author asked for, silently. The defect also
depended on the field's siblings — a lone Date left `conditions` empty, so the
original object came back untouched and nothing looked wrong.
Lowered rather than refused, and the spec decides that — the opposite answer to
objectui#8514, which was a refusal precisely because the spec declined to rule.
Measured against @objectstack/spec 17.3.0: `ACCEPTED_FILTER_COMPARAND_TYPES` is
`['string','number','bigint','boolean','null','Date']`, and $gt/$gte/$lt/$lte/
$between declare `z.ZodDate` in comparand position.
The leaf carries the Date INSTANCE. `parseFilterAST(['created','=',d])` hands
back `{ created: d }` with the Date intact, and the operator arm already emits
`{ created: { $gte: d } }` as `['created','>=',d]`, so stringifying to ISO here
would give the shorthand and the operator form two different comparand types for
one author intent. The gate is the spec's own `isAcceptedFilterComparand`, not a
local `instanceof Date` — the same reason operators route through the spec's
`normalizeFilterOperator` instead of a second map.
Refs: objectui#8555
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
…leToNode
`viewFilterRuleToNode` never inspected `rule.value`, so a stored view rule
`{ field: 'tags', operator: 'equals', value: ['a'] }` lowered to
`['tags', 'equals', ['a']]`. Measured against @objectstack/spec 17.3.0 the
spec's doors accept that node unjudged — `isFilterAST` is true and
`parseFilterAST` returns `{ tags: ['a'] }` — so the refusal arrived two layers
away, as driver-sql's 400 INVALID_FILTER or as an empty list from an in-memory
matcher, attributable to nothing. It is the same array-in-a-scalar-slot shape
objectui#8530 refused in the object arm, which deliberately did not reach this
door: a hand-authored `{ tags: ['a'] }` failed fast naming `$in`, the same
mistake saved into a view stayed silent.
Keyed on the operator's ARITY, never on `Array.isArray(value)`. The two
array-valued classes are the spec's own exports — VIEW_FILTER_LIST_VALUE_OPERATORS
(`in`, `not_in`) and VIEW_FILTER_PAIR_VALUE_OPERATORS (`between`) — so `in` /
`not_in` / `between` rules keep their arrays, and the check runs after
normalization so `nin` is judged as `not_in`. Two classes are left alone on
measurement: an operator the spec does not know is still passed through verbatim
(the misspelling is already the loud failure), and the valueless operators are
not refused because the spec discards their value anyway
(`parseFilterAST(['tags','is_null',['a']])` is `{ tags: { $null: true } }`). A
pin holds the four classes to an exact partition of VIEW_FILTER_OPERATORS.
The refusal throws, so a saved view with one bad rule fails at render rather
than returning a narrower answer. Not a new blast radius: plugin-list's
`buildEffectiveFilter` and plugin-view's `ObjectView` both call this sink inside
their load `try` and have caught this error class since objectui#8530, and
`classifyLoadError` reads INVALID_FILTER / 400 — so the user sees the "filter is
malformed" panel. Dropping the rule would widen the result set; rewriting
`equals` into `in` would change what the view means.
Refs: objectui#8557
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
This was referenced Sep 8, 2026
Contributor
❌ Console Performance Budget
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. Which half objected:
📦 Bundle Size Report
Size Limits
|
os-justin
marked this pull request as ready for review
September 8, 2026 12:02
os-justin
enabled auto-merge
September 8, 2026 12:02
os-justin
deleted the
claude/issue-8555-filter-converter-comparand-gate
branch
September 8, 2026 12:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #8555
Fixes #8557
Two defects in the same per-field lowering, dispatched together because they edit
the same file. One commit each, each with its own reasoning and its own pin file.
Both were re-derived against the merged tree rather than the cards. Worked example
followed for both:
f391edee5(PR #8551), which names the field, prints thecomparand, says why the lowered node can never select anything, and prescribes the
spelling that works.
Commit 1 — objectui#8555: a
Datecomparand was silently DROPPEDtypeof value === 'object' && !Array.isArray(value)admits aDateinto theoperator loop, and
Object.entries(someDate)is[], so the loop body never ranand no condition was pushed for that field:
Not refused, not lowered wrongly — absent, so the result set got wider,
silently. It also depended on the field's siblings: a lone
Dateleftconditionsempty, so the original object came back untouched and nothing lookedwrong. That asymmetry is pinned closed.
The direction: LOWER — and the spec is what decided it
The card left this open and asked what wire form a
Datetakes. Measured against@objectstack/spec17.3.0, read from the implementation rather than any test name:ACCEPTED_FILTER_COMPARAND_TYPES['string','number','bigint','boolean','null','Date']isAcceptedFilterComparand(new Date())true(plain objectfalse, arrayfalse, RegExpfalse)normalizeFilterComparandTypes({created: d})INVALID_FILTER/ 400$gt$gte$lt$lte$betweenz.ZodDatein comparand positionparseFilterAST(['created','=',d]){ created: d }— the Date INSTANCE survivesThe spec rules on dates, and rules them in. So this is the opposite answer to
objectui#8514, exactly as the card said it would be if the spec ruled.
The wire form is not this adapter's question. The leaf carries the
Dateitself.
parseFilterASTkeeps it aDate, and the operator arm has always emitted{ created: { $gte: d } }as['created', '>=', d]— so converting to ISO or epochhere would give the shorthand and the operator form two different comparand types
for one author intent. Serialization is the transport's job, one layer down.
The gate is the spec's own
isAcceptedFilterComparand, not a localinstanceof Date— the same "vocabulary is the spec's, not a second list" reason this filealready routes operators through
normalizeFilterOperator. A pin holdsDateasits only object-typed member, so the arm reads as a Date arm and reddens if that
ever widens.
Legs — run, not predicted (16 pins)
['status','=','a'],node[2]isundefinedvalue.toISOString()'2026-01-01T00:00:00.000Z'vs a DateObject.keys(value).length === 0gate['created','=',{}], and a RegExp into a slot the spec refusesTwo corrections the legs forced on my own predictions, both now written into the
pin file:
instanceof/parity pins. Six assertions redden, because a string is not deep-equal to a Date.
The correction that matters runs the other way: the symmetry pin does NOT
discriminate it — the caricature is symmetric too. Symmetry never catches a
wrong comparand type; the
instanceof/toBe(D)pins do, and they sit in theirown
it()blocks so nothing reddens ahead of them (objectui#8506, objectui#8514).Object.keyscaricature is byte-identical to the shipped fix for everyDate input — 14 of 16 pins stay green. Only section 4 catches it. Reporting
that rather than letting it stand, as asked.
Commit 2 — objectui#8557: the stored-view arm passed an array through
viewFilterRuleToNodenever inspectedrule.value. Pinned pre-fix behaviour:isFilterAST(['tags','equals',['a']])istrueandparseFilterASTreturns{ tags: ['a'] }— the spec's doors accept it unjudged, so the refusal arrived twolayers away as a 400 nobody could attribute to their filter.
Keyed on ARITY, from the spec's own exports
VIEW_FILTER_LIST_VALUE_OPERATORSis['in','not_in']andVIEW_FILTER_PAIR_VALUE_OPERATORSis['between']. The spec exports them forexactly this question — its own docblock names a hard-coded
["in", "notIn"]elsewhere in this repo as the mistake they exist to prevent. The check runs after
normalizeFilterOperator, soninis judged asnot_inand keeps its array.Two classes are deliberately not refused, each on a measurement:
isFilterAST(['tags','bogus_op',['a']])is alreadyfalse; refusing here wouldreport "use
$in" for what is a typo.is_null,is_empty, ...). Measured:parseFilterAST(['tags','is_null',['a']])is{ tags: { $null: true } }— thespec discards the value, so a stray array cannot select wrong rows. Refusing would
turn a harmless input into a render-time throw and prescribe
infor an operatorthat takes no value. This is the one class the spec exports no set for, so it is
written out in source and held by a partition pin: the four arity classes must
cover
VIEW_FILTER_OPERATORSexactly, so a new spec operator reddens instead ofsilently inheriting a verdict.
Where the refusal belongs, and why
It throws from the lowering, so a saved view with one bad rule fails at render.
That was weighed, not assumed:
plugin-list'sbuildEffectiveFilter(
ListView.tsx:1770, inside the loadtryat :1767 that feedssetLoadError) andplugin-view'sObjectView(:878, inside thetryat :866 /catchat :1007)both already catch a
FilterOperatorErrorfrom this same file, because the objectarm has thrown since objectui#8530.
classifyLoadErrorreadsINVALID_FILTER/400, so the user sees the "filter is malformed" panel — not a network fault, not a
crashed page. Pinned in section 5 as an envelope-parity assertion against the
object arm, with the messages required to stay distinguishable.
one direction this file exists to avoid, and a stored view's whole purpose can be
to hide rows. Rewriting
equalsintoinchanges what the view means.two layers away is attributable to nothing.
Legs — run, not predicted (16 pins)
captureRefusalfails on its own first line printing the node that travelled throughArray.isArray(value)alone (the card's named one)in/not_in/betweenwith itis_nullpin, the single assertion written for itRefusal pins assert the envelope and the message's first sentence on a captured
error, because objectui#8530 measured an envelope-only pin going green for the wrong
reason in this very file.
Correction to the dispatch brief
The brief attributed this file's recent history to PR #8512 and PR #8529. Measured
on the merged tree: neither touched
filter-converter.ts— since 2026-09-06 theonly commits to it are
f391edee5(PR #8551) and617707a48(PR #8456, the$and/$orgroup-node lowering, which the brief did not mention).refuseFilterNodelives inpackages/core/src/adapters/ValueDataSource.tsand isnot an idiom available here; this file's idiom is a thrown
FilterOperatorError,which is what both commits use. The
$regexrefusal is fromad0183a0a(2026-07-31),not from today's work. Nothing shipped here was affected — the merged file was read
first — and a pin was added for the
#8456interaction: an AST group node is anarray,
isViewFilterRulerequires an object, so a group never reaches the arity gate.Verification
pnpm exec vitest run packages/core/ packages/data-objectstack/src/filter-entry-translation.test.ts packages/plugin-view/src/__tests__/ObjectView.filterSources.test.tsx packages/plugin-grid/src/__tests__/gridDefaultFiltersLowering.test.tsx packages/plugin-detail/src/__tests__/RelatedList.listFilter.test.tsx-> 135 files / 2866 tests passed, quoted fromdd0fa8eda(the final commit).pnpm --filter @object-ui/core run type-check-> exit 0, both programs. Coverage proved rather than assumed:tsc --noEmitexcludes*.test.ts(0 hits with--listFiles),tsconfig.test.jsonincludes both new pin files.check:spec-symbols,check:control-bytes,check-changeset-presence,check-changeset-no-major-> all exit 0.check-governed-queue-guard --teston the 5 changed paths ->NOT GOVERNED.eslinton the three changed source files -> 0 errors (7 pre-existingno-explicit-anywarnings, none on added lines).EXIT=143, SIGTERM, by my own recorded PID) because it had produced no file result and a sibling agent shares this container. That run is NOT MEASURED, not green. The checkable reason the narrowed set suffices: a multiline-aware scan ofpackages/,apps/andexamples/finds zero pre-existing fixtures carrying a scalar-operator view rule with an array value (lit control: 94 hits for array-valued operators), so no consumer test can newly throw; and theDatearm only adds conditions where none were emitted. CI runs the full farm.Two changesets (
patch, nomajor). Staying in draft as dispatched — not flipped to ready, no auto-merge armed.Implemented by the
domain:uidev seat; session referencesession_01YBWFb5YgMU5dw8p2VKj16S.🤖 Generated with Claude Code
https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Generated by Claude Code