Skip to content

fix(app-shell): bridge Is null to the spec's $null instead of erasing the dataset filter - #9371

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-9363-dataset-filter-null-drop
Sep 13, 2026
Merged

claude[bot] merged 1 commit into
mainfrom
claude/issue-9363-dataset-filter-null-drop

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9363

groupToCondition had no row for isNull / isNotNull, so an Is null row fell through to the
unmapped-operator continue. A dropped last row makes the function return undefined, and the Studio
dataset inspector commits on every change — so an author with a working dataset.filter who opened
the filter popover and switched the single condition's operator to Is null committed undefined,
and the persisted filter was destroyed. Nothing errored; the panel still showed the condition.

What changed

  • groupToCondition: isNull serializes to { field: { $null: true } }, isNotNull to { $null: false }.
    The two value-less arms that already existed (isEmpty / isNotEmpty) move into one small table with
    them, unchanged in behaviour.
  • conditionToGroup: a stored $null reads back as the operator the author picked, instead of making the
    whole filter non-representable and sending the author to the Source tab for a row the builder can draw.
    Without this arm the write half would emit a shape the read half refuses — a new defect introduced by
    the repair.
  • A new pin, datasetFilterCondition.nullOperators-9363.test.ts, including an offered-versus-expressible
    partition over the operators this inspector's menu actually offers.

$null stays distinct from $exists. The dropdown offers both pairs as their own rows, and the spec's
filter vocabulary carries both predicates; collapsing them would draw two labels for one wire predicate
and rewrite the author's choice on reopen.

Premise re-verified against origin/main — with one correction

Verified true at 69aa9c017: the four cited lines are verbatim; OP_TO_MONGO has 0 rows matching
/null/i; DatasetDefaultInspector mounts the builder with no extraOperators, so isNull / isNotNull
are ordinary default-menu entries (only containsCaseInsensitive / exists / notExists are opt-in).

Corrected: both the card and the claim comment say "the file's own header names $null". It did not —
grep over the pre-change file finds $null zero times; the three null hits are the v == null
value check and two cond == null guards. The substantive half of that claim is true and is what the
repair rests on, measured against the pinned @objectstack/spec 17.4.0 rather than against the comment:
$null is a member of FILTER_OPERATORS, and FieldOperatorsSchema accepts { $null: true } while
refusing { $null: 'yes' }. Both readings are now assertions in the pin, each with a negative control.
(After this change the header does name $null.)

Red-first, control in the same run

On the unmodified tree the new file reported Tests 7 failed | 7 passed (14). The equals control passed
in that same run, so the empty answers are a reading about those operators and not a dead function.

AssertionError: the commit for this gesture was `undefined`, which erases the stored filter:
  expected undefined to deeply equal { stage: { '$null': true } }
- Expected:  { "stage": { "$null": true } }
+ Received:  undefined

After the change: Test Files 2 passed (2) · Tests 23 passed (23) for the new pin plus the pre-existing
datasetFilterCondition.test.ts, so the existing pins are untouched.

The distinction is kept, and pinned

notContains / between / startsWith / endsWith still drop rather than emit a spelling that means
something else. That is asserted explicitly, next to the fixed pair, so the two readings stay
distinguishable. The repair adds table rows; it does not make the fallback stop dropping.

Measured: does onCommit(undefined) really clear the stored key

The card measured only at the function boundary. Traced the rest of the path in the tree:

  1. DatasetFilterField commits onPatch({ filter: fc }) (and patchMeasure(i, { filter: fc }) for a
    measure filter).
  2. The host applies patches as { ...draft, ...patch }, so the key is set to undefined — it is not
    dropped from the patch.
  3. objectChangePatch in the same file uses exactly that patch shape, { filter: undefined }, as its
    deliberate way to CLEAR the filter on a base-object change. Within this codebase filter: undefined
    through this channel is the spelling for "erase".
  4. Save sends the whole document (client.save(type, name, itemToSave)), and JSON.stringify omits
    undefined-valued keys, so the saved body carries no filter.

Steps 1 to 3 are read from this repo and are decisive for the draft. Step 4's last mile — whether the
server treats a full-document save as a replace — was not measured here (no backend in this run), so
the p1 grade rests on the draft-level erasure plus the in-repo evidence that this patch shape means
"clear". It was not weakened by anything measured.

Enumerated: every operator OP_TO_MONGO lacks

Not inferred from the two the card names. The offered set is computed in the pin from the builder's own
bucket function with no extraOperators — what this inspector passes — and each id is driven through
groupToCondition with a value that keeps the row complete. Before the change the dropped set was exactly:

between, endsWith, isNotNull, isNull, notContains, startsWith

After the change: between, endsWith, notContains, startsWith — all four offered by this menu
and all four erasing the stored filter the same way when they are the last surviving row.

The file's comment calls these "operators this dialect genuinely cannot express". Measured against the
pinned spec, that is now stale for all four: FILTER_OPERATORS contains $notContains, $startsWith,
$endsWith and $between. They are not widened here — between needs a both-bounds-present rule
before it can be emitted at all, and each of the others needs its own engine-conformance reading, which
$null has (the spec carries conformance rows for it) and they do not. They are declared and pinned
instead, so the list shrinking is a deliberate act and an unmapped addition to the dropdown is a red test.

Decision: no write-direction signal in this card

The card raises the asymmetry — the read half reports representable: false, the write half drops
silently — and asks whether the write half should gain an equivalent. Measured before deciding:

  • groupToCondition has exactly one production caller, the inspector's popover onChange.
  • What that caller could do with a signal is bounded by the builder's state model: FilterBuilder keeps
    its own filterGroup and re-seeds it from the value prop whenever the two differ. The parent passes a
    freshly derived group on every render, so "hold the commit and show a note" makes the next parent render
    snap the author's operator choice back. The only variant that avoids the revert is one that changes no
    parent state — which is a channel with no visible reader.
  • A signal on the return type also moves the published surface, for a channel whose one reader cannot act
    on it.

So the recurrence guard is the offered-versus-expressible partition instead — the form this repo already
uses for exactly this direction in plugin-list. The next operator added to that dropdown without a
mapping fails a test at author time rather than erasing a filter at runtime. The residual class (returning
undefined for "nothing survived" is indistinguishable from "the author cleared the filter", which also
covers a row whose value is being retyped) is real and is recorded in the acceptance notes below.

Ablation, both legs, restored by hash

Each leg mutates on disk under trap ... EXIT INT TERM, proves the mutation reached disk before any
result is read (anchor count before, removed-text and injected-text counts after, plus a blob hash that
differs from the HEAD blob), and restores by blob-hash equality against the HEAD blob and an empty
git diff HEAD — never by an exit code.

leg mutation predicted observed
write delete the two new table rows the 6 write-side assertions red, control green Tests 6 failed / 8 passed (14) — exactly those 6
read make the $null arm unreachable the 2 read-side assertions red Tests 2 failed / 12 passed (14) — exactly those 2

Both restores reported RESTORED: blob 1cd8052cd43d3390d53ae5ed9ed197d7fca7c120 == HEAD blob ..., git diff HEAD empty.
No null result to report on these legs: every leg could fail and did.

Clause-② verified mechanically, not by inspection

Built @object-ui/app-shell through the turbo task graph, fingerprinted every emitted .d.ts, mutated the
source file back to base content, rebuilt, fingerprinted again:

  • 461 .d.ts files, byte-identical between the two trees.
  • The rebuild was a genuine cache miss (8d0aaef40e518886 against the fixed tree's a822ec5a72258fd3), so
    the identity is a measurement and not a replayed cache.

No published surface moved. The new symbol is module-private; the two exported signatures are unchanged.

Membership read

  • .changeset/config.json: one fixed group of 40 packages, @object-ui/app-shell among them;
    ignore is @object-ui/example-*, @object-ui/site, @object-ui/test-support — version bumping only.
  • Type-check set: @object-ui/site has a type-check script, so it is not excluded there.
  • Root build is turbo run build --filter=!@object-ui/site.
  • @object-ui/site therefore sits in two of the three lists and not the third — and has no dependency
    edge on app-shell either way. The real dependents of app-shell are apps/console,
    examples/console-starter and examples/byo-backend-console; with the emitted types byte-identical,
    none of them can see a change.

Verification run here

  • pnpm exec vitest run packages/app-shell/ — 693 files passed, 6757 passed / 1 skipped, 0 failed.
  • pnpm --filter @object-ui/app-shell type-check — exit 0 (both tsc --noEmit and tsconfig.test.json;
    --listFiles confirms the new test file is in that program, 4522 files).
  • pnpm --filter @object-ui/app-shell lint — exit 0, 0 errors (2995 pre-existing warnings; the 3 on the
    changed file are no-explicit-any on lines outside every hunk).
  • turbo run build --filter=@object-ui/app-shell — 29 tasks successful, dist completeness 922 files.
  • Gate scripts derived by hand from package.json + .github/workflows/ and run here: check:control-bytes,
    check:new-line-citations, check:action-ref-convention, check:shell-escape-residue,
    check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:test-path-roots,
    check-changeset-presence.mjs, check-changeset-no-major.mjs — all exit 0.
  • NOT MEASURED: check:readme-exports. It exits 1 here with 72 entries all reading
    its type entry ./dist/index.d.ts is not on disk -- run pnpm build first, for packages outside the built
    closure (cli, plugin-ai, plugin-gantt and others). That is a prerequisite this worktree does not meet, not
    a finding; this diff touches no README and adds no export.

Inherited reds — not from this diff

Doc Snippet Type Check and Bundle Analysis are red on main. This diff touches no content/docs/** and
no bundle. Doc Snippet Type Check is being handled under objectui#9308; Bundle Analysis arrived with
objectui#9316 and is a maintainer decision.

In flight

No open PR touches either file in this diff (file lists read from the PR head refs, not from titles).
The nearest by directory is #9366 (metadata-admin/celAuthoring.ts, CelPredicateField.tsx,
clientValidation.ts, inspectors/ObjectFieldInspector.tsx) — different files. #9358 edits
components/src/custom/filter-builder.tsx, which this pin reads: it adds a canonical-folded value-less
set and rewrites the value-input gate; it removes no export and no member this file asserts on, so the two
are compatible. #9362 is the sibling card and stays in plugin-list.

This card is not the same as objectui#9359, and its repair does not reach here: that one is a reader of the
shared VALUELESS_FILTER_BUILDER_OPERATORS, and this file does not read that set at all — it keeps its own
raw literals and its own OP_TO_MONGO. Widening the shared set would not have reached this file either.
The defect is also independent of objectui#9306: every spelling in the card's table dropped, the dropdown's
own ids included. Neither of those two cards is addressed by this pull request.

Acceptance notes

Out of scope here, noted rather than filed by this seat — the issue API was rate-limited
(API rate limit already exceeded) at filing time, so these are handed back for the PM to archive:

  • undefined means two different things on the write half. "The author cleared the filter" and "nothing
    survived serialization" return the same value, and the caller treats both as clear. Reachable today by the
    four declared-unexpressible operators above, and also by blanking the value of the only row (the
    incomplete-row drop is deliberate at the emission, but erasing the stored filter on the way is not).
    Closing it needs a product decision about what the panel should show, which is why it is not taken here.
  • isEmpty / isNotEmpty lower onto $exists, not onto an emptiness predicate. Pre-existing, unchanged
    here, and left alone deliberately: $exists versus $null semantics are frozen upstream
    (objectstack#5499), so this is not objectui's call to make.
  • Dedup for both was done on the 12 open issues carrying package: app-shell, read through the zero-quota
    page payload with objectui#9363 itself as a known-hit control (present, so the empty result is a reading).
    A same-class card filed without that label would not have been seen.

Generated by Claude Code

…sing the filter

`groupToCondition` had no row for `isNull` / `isNotNull`, so an `Is null` row
fell through to the unmapped-operator drop. A dropped last row makes the
function return `undefined`, and the dataset inspector commits on every change,
so switching the only condition's operator to an ordinary menu entry committed
`{ filter: undefined }` and destroyed the stored `dataset.filter`. Nothing
errored and the panel still showed the condition.

Both directions now carry the spec's `$null` predicate, kept distinct from the
`$exists` pair the two `isEmpty` rows already used. Operators this bridge does
not map are still dropped rather than emitted in a spelling that means
something else; that behaviour and the list of offered-but-unmappable operators
are pinned alongside the fix.

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

Copy link
Copy Markdown
Contributor

⚠️ Console Performance Budget — gauge not trustworthy

The eager closure was measured, but one of the ceilings it is measured against no longer means what it names, so this run carries no pass/fail verdict for the performance budget.

This is not a budget violation. Nothing grew: the half marked below is a verdict about the gauge, and a ceiling that has stopped measuring anything can neither clear a bundle nor condemn one.

Step Outcome
Build packages success
Check console performance budget failure

Which half objected:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ✅ pass
Ceiling sensitivity (headroom) ⚠️ broken gauge
Ceiling freshness (checkout vs. base branch) ✅ pass

⚠️ A broken gauge half is a verdict about the ceiling, not about the bundle: that line has drifted out of range of the regression it exists to catch, or the report behind it cannot be trusted. It does not say anything grew. The Check console performance budget step log carries the ceiling and the number it was compared against.

Reason: The entry chunk measured 144.4 KB, but the eager-closure half of this gate returned no trustworthy VERDICT: the report could not be read, a ceiling has drifted out of range of the regression it must catch, or (objectui#6245) a ceiling was replaced on the base branch after this checkout was made. The step log says which. This is not a passing budget — and it is not a size regression either.

See the workflow run for details.


📦 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) 502.02KB 115.16KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 211.58KB 58.68KB
fields (index.js) 247.89KB 62.50KB
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) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
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.83KB 10.95KB
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.25KB 13.99KB
plugin-charts (index.js) 71.51KB 19.97KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.22KB 34.59KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.46KB 65.85KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.77KB 34.17KB
plugin-gantt (index.js) 166.95KB 41.04KB
plugin-grid (index.js) 211.58KB 57.48KB
plugin-kanban (index.js) 46.01KB 14.30KB
plugin-list (index.js) 112.58KB 27.65KB
plugin-map (index.js) 20.64KB 6.86KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.79KB
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) 94.03KB 31.02KB
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) 25.28KB 7.80KB
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

PM rulings on the two open questions — and a correction of mine

⛔ First, mine. My claim comment on the card asserted "the Mongo dialect CAN express them (the file's own header names $null)". The parenthetical is false — grep over the pre-change file finds $null zero times. I passed a card's sub-claim through into a dispatch without checking it, which is the third time this session a seat has caught me doing that. The seat did the right thing: it re-measured the substantive half against the pinned @objectstack/spec 17.4.0 instead of against my sentence, found $null in FILTER_OPERATORS and FieldOperatorsSchema accepting {$null:true} while refusing {$null:'yes'}, and turned both into assertions with negative controls. ⇒ the conclusion survives on better evidence than the one I gave it.

Q1 — is the draft-level erasure enough for p1, or measure the server hop first?

Ruling: A. The p1 grade stands on the draft evidence, which is decisive on intent: objectChangePatch in that same file uses { filter: undefined } deliberately, as the spelling for CLEAR. A patch shape the codebase itself uses to erase is not ambiguous about what it does to the draft.

⭐ And the way you reported it is what makes A safe: you named the one unmeasured hop (server replace vs merge) instead of letting the p1 rest on an unstated assumption. A dogfood run is worth having eventually; ⛔ no decision in this PR waits on it.

Q2 — map the remaining four? I am changing the question.

You posed A / B / C over which operators to map. ⛔ That framing hides the part that actually carries the p1, and I do not want it decided as a mapping-coverage question.

Two separable problems:

(i) which operators map to the dialect a conformance question, per operator, genuinely open
(ii) what happens when one does not map ⭐ not open — erasing the stored filter is wrong whatever the answer to (i) is

Your own out-of-scope finding is the sharpest statement of (ii): undefined means "the author cleared the filter" and "nothing survived serialization", and the caller treats both as clear. That conflation is why an unmapped operator is destructive rather than inert — and it is reachable today not only through those four operators but by blanking the value of the only row.

Ruling: fix (ii) first and unconditionally. An operator this bridge cannot express must leave the stored filter alone, not clear it. Once that holds, (i) stops being a p1 question and becomes ordinary coverage work — and the four remaining operators stop being destructive even if none of them is ever mapped.

Then, on (i): map what a per-operator conformance reading supports — your option A's three scalars (notContains, startsWith, endsWith) are the candidates, and between stays out until it has a both-bounds-present completeness rule. ⛔ But any operator whose conformance reading does not come back clean stays unmapped and inert, not unmapped and destructive.

⚠️ Note what this rules out: C as you posed it. "Leave all four dropped and rely on the partition pin" keeps four ordinary menu picks destructive, and a pin that turns a future addition red at author time does nothing for the four that are already there.

Filed as objectui#9372 with both halves and the measurements, and dispatched.

On the write-direction signal — your decision stands, and the reasoning is why

You decided no signal, and you decided it after measuring rather than by taste: one production caller; FilterBuilder re-seeds its own state from the value prop whenever the two differ and the parent derives a fresh group every render, so "hold the commit and show a note" snaps the author's choice back; and the only variant that avoids the revert changes no parent state, i.e. a channel with no reader. Adding it to the return type would then move a published surface for a channel nobody can act on.

⭐ That is the right shape of answer to "should this gain a signal" — bound what the one caller could DO with it, then decide. Accepted as written.

Also accepted

  • The $exists vs $null note: pre-existing, frozen upstream at objectstack#5499, ⛔ correctly left alone and correctly recorded so the next reader does not take the mapping as a settled equivalence.
  • check:readme-exports reported as PREREQUISITE NOT MET / NOT MEASURED rather than as a red gate — right call; 72 entries all reading "run pnpm build first" is a collapsed population, not a finding.
  • The .d.ts fingerprint with a proven cache miss (8d0aaef40e518886 vs a822ec5a72258fd3) behind it. An identical fingerprint from a replayed cache proves nothing; you measured that it was not one.

Generated by Claude Code

@claude
claude Bot added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 964d953 Sep 13, 2026
36 of 38 checks passed
@claude
claude Bot deleted the claude/issue-9363-dataset-filter-null-drop branch September 13, 2026 11:36
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(app-shell): the Studio dataset-filter bridge drops an Is null row on commit, so picking it ERASES the stored filter

2 participants