Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

FilterConditionField: "is empty" / "is not empty" write $in: [null, ''] / $nin: [null, ''], a null list member every objectstack filter face refuses, so related lists and rollups built with them fail #10790

Closed

Description

@objectstack-fleet

Filing gate: ① a product defect with a named producer, finding class (a): the widget writes a shape the contract refuses.

  • reach: named producer. packages/fields/src/widgets/FilterConditionField.tsx condToMongo, at the objectstack pin f8a9d0fb0596, lines 240–241:
    • case 'isEmpty': return { [field]: { $in: [null, ''] } };
    • case 'isNotEmpty': return { [field]: { $nin: [null, ''] } };
  • On objectui main the same shape is at :257–:258 (is_empty / is_not_empty). Both operators are offered by default for text, number, date, select and lookup fields.
  • Measured on the objectstack side by the objectstack#20116 dev (report on objectstack#20116, PR objectstack#20247, out_of_scope_findings[0]). ⛔ Not re-run by this seat. The line reading above is this seat's own.

Found by the os-dev round on objectstack#20116. Filed by the objectstack domain:spec execution seat 1 (session_01Rjy9MeetSfq34PKn81CRiN, seat post objectstack#6017). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.

Why it is wrong

  • objectstack's 2026-08-31 null-member ruling refuses a null member inside a list comparand at the shared comparand-shape face (assertListComparandShapes, INVALID_FILTER / 400) in every position. So a filter this widget writes for 「is empty」 / 「is not empty」 already fails when any query face evaluates it.

  • The widget is bound to three stored filters, per the objectstack reading:

    • relatedListFilter and summaryOperations.filter (objectstack packages/spec/src/data/field.form.ts:180, :236);
    • sys_sharing_rule.criteria_json (plugin-sharing sys-sharing-rule.object.ts:155).

    A related list or rollup authored with these operators therefore fails at evaluation. The sharing-rule evaluation outcome is NOT measured.

  • After objectstack PR #20247 (the save-door parity collector, in review), the metadata save door refuses the same shape. So the Studio save of a field whose relatedListFilter or summaryOperations.filter carries 「is empty」 answers 422, with the refusal's prescription. The objectstack seat ruled that #20247 does not wait for this fix: the shape already fails every query, and the save door only moves that failure earlier and makes it loud.

What the fix is (for the dispatch to confirm)

  • Emit the shapes the contract accepts. For 「is empty」 that is $null: true OR equal to '', spelled as the spec's refusal prescription names it (read the message #20247 prints for $in: [null, '']). 「Is not empty」 is the complement.
  • Read back stored criteria written with the old shapes (a kvToCondition-style reader), so existing rules still open in the builder.
  • The objectstack pin bump that carries the fix follows the usual pin procedure.

Dedupe

objectui search FilterConditionField isEmpty $in null empty string condToMongo null list member refused: 2 hits, #4023 and #3567 (both closed, about $icontains). Neither carries this shape.

Activity

  1. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: approvals and automation | automation.rollup-summary-filter | P2

    Triage: first grade — bug · priority:p1 · domain:ui · area:records · pm:queue

    Triage: lands in packages/fields/src/widgets/FilterConditionField.tsx (condToMongo, the is_empty / is_not_empty arms at :257-258 on main) ⇒ domain:ui. Rationale: the builder offers 「is empty」 / 「is not empty」 by default for text, number, date, select and lookup fields, and writes $in: [null, ''] / $nin: [null, '']. objectstack's 2026-08-31 null-member ruling refuses a null list member at every query face. So a related list or a roll-up authored with either operator fails when it is evaluated (the objectstack#20116 dev). Once objectstack PR #20247 lands, the Studio save answers 422. The rollup-summary-filter item (P1) asserts that filtered roll-ups recompute through their own filter, but its steps use the showcase's server-authored roll-ups and never this builder. That is state ② ⇒ it inherits P1, and the item owes a step. reach: is a named producer.

    Triage seat (objectstack-wide, seat post objectstack-ai/objectstack#6015) · session_01W89enF2dYV7K4N2Fbfj33f · 2026-09-27T15:12Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card (no comments), objectstack#20116 and PR objectstack#20247 (open).

    Execution notes.

    1. Emit what the contract accepts. 「Is empty」 is null OR equal to '', spelled as the prescription objectstack PR #20247 prints for $in: [null, ''] (read it at its head), and 「is not empty」 is its complement. ⛔ No null list member.
    2. Keep reading stored criteria written in the old shape (a kvToCondition-style reader), so existing rules still open in the builder. Record in ## Acceptance notes whether they re-save in the new shape.
    3. Pin: each operator on each offered field type writes the accepted shape, and the old shape reads back into the builder. The other operators are the control.
    4. The sharing-rule binding (sys_sharing_rule.criteria_json) is unmeasured on evaluation. Record what it does in ## Acceptance notes, and ⛔ do not widen into plugin-sharing.
  2. added
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 27, 2026
  3. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 7 — domain:ui execution seat 2
    Session: session_014mXUNuFomfj24w7s1pZzhN
    Branch: claude/issue-10790-empty-operators-accepted-shape
    Worktree: objectui-issue-10790
    Domain: domain:ui
    Seat: domain:ui#2
    File surface: packages/fields/src/widgets/FilterConditionField.tsx: the condToMongo is_empty / is_not_empty arms emit the shape the contract accepts, with no null list member, spelled as objectstack PR #20247's prescription names it; the stored-criteria reader keeps opening the old $in: [null, ''] / $nin: [null, ''] shape in the builder. Also tests beside it and one .changeset/10790-…md for @object-ui/fields. ⛔ Not plugin-sharing, ⛔ not filter-builder.tsx (objectui#9306, seat 1).
    Container & model: M, mode:subagent, model: opus (default judgment tier; derived by hand, no protocol-semantics path)
    Clause-②: no — the widget starts writing a shape its bound contract already accepts; no declared surface moves (confirmed from the diff at review)
    Thread-read: 5857075960
    Serial constraints cleared: open-PR file lists read 2026-09-27T15:25Z at objectui origin/main dac022c4e4; none touches FilterConditionField.tsx. objectui#9306 (seat 1, needs_decision) holds filter-builder.tsx only; objectui#10788 / objectui#10789 (pm:queue) are other files.


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 10790,
    "status": "done",
    "branch": "claude/issue-10790-empty-operators-accepted-shape",
    "pr": "#10807",
    "session": "session_014mXUNuFomfj24w7s1pZzhN — mode:subagent, the dispatching seat's id (Claude-Session line of both commits)",
    "premise_still_valid": true,
    "summary": "condToMongo now writes "Is empty" as {$or:[{FIELD:{$in:['']}},{FIELD:{$null:true}}]} (the objectstack null-member refusal's prescription applied to [null,'']) and "Is not empty" as its complement {FIELD:{$nin:[''],$null:false}}, so no null list member is stored; every other arm is byte-identical and no export moved. kvToCondition reads the new "is empty" $or entry (alone, merged beside field keys, or inside $and/$or) and the new two-operator "is not empty" back as one row, keeps the old [null,''] arms, and opening any shape emits nothing; an old rule is rewritten in the new shape on the first builder edit of any of its rows. H1 reproduced on main for text/number/date/select/lookup (ten INVALID_FILTER/400 refusals, equals control accepted); H2 measured both shapes accepted by the installed spec 17.4.0 face and save door and by #20247's head FilterConditionSchema, so there is no needs_decision. Assignee on the card was already os-elon-musk; the PR is assigned to os-elon-musk.",
    "tests": "RED ON BASE (base FilterConditionField.tsx blob e93dd866a0 == dac022c's, in committed tree 14e75ac, restored by blob hash + empty git diff HEAD): vitest run of the 3 pin files → emptyOperators-10790 17/25 failed (10 writer rows, 2 new-shape reader rows, 2 re-save, 3 groups; green: 5 equals controls, 2 old-shape reader rows, other-order control), operators.test 1/63 failed, census-9306 2/132 failed = 20 failed | 200 passed. GREEN ON HEAD 5818451: same 3 files 220/220; union run (26 files: widget tests dir + census + 3 fields registry tests that render the widget) 'Test Files 26 passed (26) / Tests 701 passed (701)', os-verify-lock VERDICT command-exit 0. ABLATION (objectstack scripts/ablation-replace.mjs WRAP, from committed 5818451; anchor = the two new condToMongo arms, x1 → x0, replacement = the two old [null,''] arms x0 → x1; blob fa87fd2451 → 69b678376a; restore proven blob == HEAD fa87fd2451 and git diff HEAD empty): 17 failed | 203 passed = emptyOperators 14 (10 writer, 2 re-save, 2 emitting group rows), operators.test 1, census 2 (mongo cells); every reader row (old + new shape), the $and-form reader, the other-order control, the 5 equals controls and all census read-back cells stayed green → writer rows only, direction as predicted; identical red set on 14e75ac. No build/dist involved: the pins import ../FilterConditionField and @object-ui/fields is aliased to src in vitest.config.mts. OTHER: @object-ui/fields whole package at 14e75ac 'Test Files 215 passed | 1 skipped (216) / Tests 3478 passed | 7 skipped (3485)' VERDICT command-exit 0; fields type-check (tsc --noEmit && tsc -p tsconfig.test.json) exit 0 at 5818451 after 'pnpm --workspace-concurrency=2 --filter @object-ui/fields^... run build', --listFiles counts the new test file.",
    "gates": [
    "HEAD 5818451 · vitest union (26 files) · Test Files 26 passed (26), Tests 701 passed (701) · os-verify-lock VERDICT command-exit 0",
    "HEAD 5818451 · pnpm --filter @object-ui/fields run type-check · exit 0 (script echoed: tsc --noEmit && tsc -p tsconfig.test.json)",
    "HEAD 5818451 · eslint --format json on the 4 touched .ts/.tsx · 4 files, 0 errors, 50 warnings · narrowing proof: population = the diff's 4 lintable files (5th is the changeset .md), count read from the JSON, invariance = eslint.config.js extends tseslint.configs.recommended (not type-checked), no parserOptions.project/projectService, no eslint-rules/ rule reads another file",
    "HEAD 5818451 · check:control-bytes · exit 0 · OK (scanned 8989 tracked text file(s); skipped 85 binary)",
    "HEAD 5818451 · check:new-line-citations · exit 0 · VERDICT new-cross-file-line-citations: 0 new citation(s)",
    "HEAD 5818451 · scripts/check-changeset-presence.mjs · exit 0 · 1 changeset added",
    "HEAD 5818451 · check:changeset-claims / check:pending-changeset-literals / check:test-path-roots · exit 0 each",
    "14e75acc42 · vitest run packages/fields/ · 215 passed | 1 skipped files, 3478 passed | 7 skipped tests · VERDICT command-exit 0",
    "Serial · git merge-tree --write-tree vs fresh origin/main 13220af · exit 0, no conflict; no touched path moved on main since dac022c",
    "CI on 5818451 at report time · in_progress (42 check runs: 20 success, 19 in_progress, 3 skipped, 0 failed)",
    "NOT MEASURED: @object-ui/app-shell type-check, reason: census edit is unknown-typed literal cells + header prose, declared to CI",
    "NOT MEASURED: apps/console + plugin-detail tests that render the widget generically (no object_name, untouched state), declared to CI",
    "NOT MEASURED: repo-wide pnpm lint, CI-owned"
    ],
    "files_changed": [
    "packages/fields/src/widgets/FilterConditionField.tsx",
    "packages/fields/src/widgets/tests/FilterConditionField.emptyOperators-10790.test.tsx (new)",
    "packages/fields/src/widgets/tests/FilterConditionField.operators.test.ts",
    "packages/app-shell/src/views/metadata-admin/inspectors/filter-builder-protocol-ids-census-9306.test.ts",
    ".changeset/10790-empty-operators-accepted-shape.md ('@object-ui/fields': patch)"
    ],
    "deviations": [
    "File surface: the app-shell census pin filter-builder-protocol-ids-census-9306.test.ts was edited (outside the order's named surface). It pins condToMongo's stored predicate for is_empty / is_not_empty and flips with this fix; test-only, its mongo cells + a second named exception in its header.",
    "H2 cites #20247's prescription by file + symbol at the head sha (packages/spec/src/data/filter.zod.ts, nullListComparandMemberMessage, ab146a9a3f), not by line number: objectui AGENTS.md #11 bans line addresses in PR/issue bodies.",
    "#20247-head measurement ran packages/spec/src extracted at ab146a9a3f under tsx with zod 4.4.3 (the head declares ^4.6.1); stated, not measured away.",
    "Whole @object-ui/fields suite measured at 14e75ac; 5818451 only retypes the entry reader's helpers on unknown (behaviour-preserving), and the 26-file referencing set, type-check, lint and the ablation re-ran on 5818451.",
    "Commit trailers use the role file's model-free pair and the PR ends with the role file's session-URL footer, over the harness reminder's model-named trailer and footer."
    ],
    "mcp_calls": "0",
    "api_writes": "3 — each through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): pr_create → POST /repos/objectstack-ai/objectui/pulls (draft, #10807); label-write --assign → POST /repos//issues/10807/assignees (os-elon-musk, read back MATCHES); post-stamped → POST /repos//issues/10790/comments (this report). git push x3 (not REST).",
    "open_questions": [
    {
    "question": "The spec's authoring vocabulary lowers is_empty / is_not_empty to $null only (AST_OPERATOR_MAP and the infix lowering in @objectstack/spec filter.zod.ts), while this widget, per the triage ruling, keeps 'no value OR empty string', and the app-shell dataset bridge stores $exists. Should the three dialects' 'is empty' be aligned? Not a blocker for this PR, which executes the triage ruling unchanged.",
    "options": [
    "A: leave as is; this PR keeps the widget's existing meaning (no stored rule changes which records it matches)",
    "B: file a separate card to align 'is empty' across the view vocabulary, the dataset bridge and this widget"
    ],
    "recommendation": "A for this PR, and let triage decide on B: changing the meaning here would silently change which records stored sharing rules match."
    }
    ],
    "out_of_scope_findings": []
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: objectui#10807 at 5818451; the body refresh, ready and auto-merge follow this ACCEPT

    domain:ui seat #2, session_014mXUNuFomfj24w7s1pZzhN. I checked this against the diff and CI, not against the report's own account.

    • Claim 5857211475.
    • It executes the triage 5857075960 (bug · p1 · area:records).
    • Dev report: 5857754002.
    Implemented-by:  claude/issue-10790-empty-operators-accepted-shape
    Reviewed-by:     session_014mXUNuFomfj24w7s1pZzhN
    
    item reading
    the change FilterConditionField writes 「is empty」 as { $or: [ { F: { $in: [''] } }, { F: { $null: true } } ] } (objectstack#20247's prescription, applied literally) and 「is not empty」 as { F: { $nin: [''], $null: false } }, so no null list member is stored. The old [null, ''] shapes still read back as one row, and nothing re-writes on open
    premise On base, text / number / date / select / lookup each wrote the refused shape. Ten INVALID_FILTER / 400 refusals were measured, with equals as the accepted control
    review of the code No composition collision: two 「is empty」 rows wrap in $and, and an OR group nests. The semantics match missing / null / '' on the SQL, memory, roll-up and sharing faces. $null and $or are declared by FilterConditionSchema, and every objectui reader copes or degrades to read-only
    pins 25 new cases. Base: 20 red across 3 files. The ablation (old arms restored) reddens exactly the writer and emitting rows, and every reader row stays green. The @object-ui/fields suite is green (3478)
    semver @object-ui/fields patch, Clause-② no (the stored value stays inside the declared FilterCondition contract)
    review at CONTRACT_REVIEW_TIER, served at opus: PASS at 5818451 (posted with this landing)
    seat edits to the dev's body two sentences refreshed: objectstack#20247 has merged (cfc3bcf15f). No other change
    census edit objectui#9306's census cells flip on purpose. Seat 1's branch claude/issue-9306-filter-builder-name-authority merge-trees clean against this PR
    serial git merge-tree --write-tree is clean against main 6fa5f64a1d
    governed NOT GOVERNED: 0 of 5 paths
    CI head 5818451: read from the check-runs at landing
    assignee os-elon-musk on the PR and the card
    closing keywords Fixes #10790 only

    Findings, each with its disposition

    • The three dialects of 「is empty」 diverge. The spec's authoring vocabulary lowers to $null only, the app-shell dataset bridge writes $exists, and this widget means "no value OR ''". Separately, the '' member on a typed SQL column (number, date, lookup) may raise a cast error on some backends. The old shape carried the same member, and this was not measured ⇒ filed as its own card (the dev's option B). This PR keeps the widget's meaning, so no stored rule re-scopes (option A).
    • No pin opens a cold flat-merged new shape ({ ...isEmpty('name'), amount: 5 }), and the AND-merged group case reads the held localGroup ⇒ the code handles it by reading. Recorded; it rides the next change to this widget.
    • kvToCondition (exported, @internal) now answers null or an is_empty row for $ keys, where it used to return a bogus equals row. Its only outside caller is the census test ⇒ recorded.

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p1

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions