Skip to content

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 into
mainfrom
claude/issue-8555-filter-converter-comparand-gate
Sep 8, 2026
Merged

fix(core): gate the comparand in both filter-converter arms — lower a Date, refuse an array on a scalar view operator#8566
os-justin merged 2 commits into
mainfrom
claude/issue-8555-filter-converter-comparand-gate

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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 the
comparand, says why the lowered node can never select anything, and prescribes the
spelling that works.


Commit 1 — objectui#8555: a Date comparand was silently DROPPED

typeof value === 'object' && !Array.isArray(value) admits a Date into the
operator loop, and Object.entries(someDate) is [], so the loop body never ran
and no condition was pushed for that field:

{ status: 'a', created: new Date('2026-01-01') }   ->   ['status', '=', 'a']

Not refused, not lowered wrongly — absent, so the result set got wider,
silently. It also depended on the field's siblings: a lone Date left
conditions empty, so the original object came back untouched and nothing looked
wrong. 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 Date takes. Measured against
@objectstack/spec 17.3.0, read from the implementation rather than any test name:

instrument reading
ACCEPTED_FILTER_COMPARAND_TYPES ['string','number','bigint','boolean','null','Date']
isAcceptedFilterComparand(new Date()) true (plain object false, array false, RegExp false)
normalizeFilterComparandTypes({created: d}) passes, Date preserved; a RegExp is refused INVALID_FILTER / 400
$gt $gte $lt $lte $between declare z.ZodDate in comparand position
parseFilterAST(['created','=',d]) { created: d } — the Date INSTANCE survives

The 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 Date
itself. parseFilterAST keeps it a Date, and the operator arm has always emitted
{ created: { $gte: d } } as ['created', '>=', d] — so converting to ISO or epoch
here 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 local instanceof Date — the same "vocabulary is the spec's, not a second list" reason this file
already routes operators through normalizeFilterOperator. A pin holds Date as
its only object-typed member, so the arm reads as a Date arm and reddens if that
ever widens.

Legs — run, not predicted (16 pins)

leg red mode
Ablation (arm removed) 7/16 MISSING condition — ['status','=','a'], node[2] is undefined
Caricature: value.toISOString() 6/16 right node, WRONG comparand type — '2026-01-01T00:00:00.000Z' vs a Date
Caricature: Object.keys(value).length === 0 gate 2/16 over-promotion — ['created','=',{}], and a RegExp into a slot the spec refuses

Two corrections the legs forced on my own predictions, both now written into the
pin file:

  • I predicted the ISO caricature would be caught only by the 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 their
    own it() blocks so nothing reddens ahead of them (objectui#8506, objectui#8514).
  • The Object.keys caricature is byte-identical to the shipped fix for every
    Date 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

viewFilterRuleToNode never inspected rule.value. Pinned pre-fix behaviour:
isFilterAST(['tags','equals',['a']]) is true and parseFilterAST returns
{ tags: ['a'] } — the spec's doors accept it unjudged, so the refusal arrived two
layers away as a 400 nobody could attribute to their filter.

Keyed on ARITY, from the spec's own exports

VIEW_FILTER_LIST_VALUE_OPERATORS is ['in','not_in'] and
VIEW_FILTER_PAIR_VALUE_OPERATORS is ['between']. The spec exports them for
exactly 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, so nin is judged as not_in and keeps its array.

Two classes are deliberately not refused, each on a measurement:

  • An operator the spec does not know is still passed through verbatim.
    isFilterAST(['tags','bogus_op',['a']]) is already false; refusing here would
    report "use $in" for what is a typo.
  • The valueless operators (is_null, is_empty, ...). Measured:
    parseFilterAST(['tags','is_null',['a']]) is { tags: { $null: true } } — the
    spec discards the value, so a stray array cannot select wrong rows. Refusing would
    turn a harmless input into a render-time throw and prescribe in for an operator
    that 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_OPERATORS exactly, so a new spec operator reddens instead of
    silently 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:

  • Not a new blast radius. plugin-list's buildEffectiveFilter
    (ListView.tsx:1770, inside the load try at :1767 that feeds setLoadError) and
    plugin-view's ObjectView (:878, inside the try at :866 / catch at :1007)
    both already catch a FilterOperatorError from this same file, because the object
    arm has thrown since objectui#8530. classifyLoadError reads INVALID_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.
  • Both alternatives are silent. Dropping the rule widens the result set — the
    one direction this file exists to avoid, and a stored view's whole purpose can be
    to hide rows. Rewriting equals into in changes what the view means.
  • The author is not present, which argues for the louder answer: a silent 400
    two layers away is attributable to nothing.

Legs — run, not predicted (16 pins)

leg red mode
Ablation (gate removed) 7/16 no throw at all; captureRefusal fails on its own first line printing the node that travelled through
Caricature: Array.isArray(value) alone (the card's named one) 6/16 all in section 3, section 2 entirely green — the refusal "works" and eats in / not_in / between with it
Caricature: valueless class not carved out 1/16 exactly the is_null pin, the single assertion written for it

Refusal 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 the
only commits to it are f391edee5 (PR #8551) and 617707a48 (PR #8456, the
$and / $or group-node lowering, which the brief did not mention).
refuseFilterNode lives in packages/core/src/adapters/ValueDataSource.ts and is
not an idiom available here; this file's idiom is a thrown FilterOperatorError,
which is what both commits use. The $regex refusal is from ad0183a0a (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 #8456 interaction: an AST group node is an
array, isViewFilterRule requires 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 from dd0fa8eda (the final commit).
  • pnpm --filter @object-ui/core run type-check -> exit 0, both programs. Coverage proved rather than assumed: tsc --noEmit excludes *.test.ts (0 hits with --listFiles), tsconfig.test.json includes both new pin files.
  • check:spec-symbols, check:control-bytes, check-changeset-presence, check-changeset-no-major -> all exit 0. check-governed-queue-guard --test on the 5 changed paths -> NOT GOVERNED.
  • eslint on the three changed source files -> 0 errors (7 pre-existing no-explicit-any warnings, none on added lines).
  • Narrowing, declared: the full 9-package consumer run was killed at 13.5 min (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 of packages/, apps/ and examples/ 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 the Date arm only adds conditions where none were emitted. CI runs the full farm.

Two changesets (patch, no major). Staying in draft as dispatched — not flipped to ready, no auto-merge armed.

Implemented by the domain:ui dev seat; session reference session_01YBWFb5YgMU5dw8p2VKj16S.


🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…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
@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) 3475.6 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-BGvYibYY.js
Status FAIL

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:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ❌ over its ceiling
Ceiling sensitivity (headroom) ✅ pass
Ceiling freshness (checkout vs. base branch) ✅ pass

📦 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.93KB 114.12KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 192.72KB 53.55KB
fields (index.js) 243.24KB 61.42KB
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.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.46KB 63.90KB
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.30KB 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 12:02
@os-justin
os-justin enabled auto-merge September 8, 2026 12:02
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit f5cfbbd Sep 8, 2026
34 of 35 checks passed
@os-justin
os-justin deleted the claude/issue-8555-filter-converter-comparand-gate branch September 8, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants