Skip to content

[Decision] what 「is empty」 means on a text column and on a multi-value column: null only (the spec's lowering today), or null OR '' / [] — three objectui builders disagree, and a stored sharing rule's rows depend on the answer #20311

Closed

Description

@objectstack-fleet

Unblocked: 5865751001 · #20399 ruled A (batch #234 item 1, 5865693155) · 2026-09-28T07:53Z
Ruled: 5861435168 · letter B · 2026-09-28T00:55Z

Unblocked by ruling 5865693155 on #20399 (A, batch #234 item 1; note 5865751001) — was blocked by #20399 from 2026-09-28T06:54Z. Execution: declare $empty per the ruling.

Path: permissions that actually hold | 缺项 (no item authors 「is empty」 on a sharing-rule criterion) | P2

Filing gate: ② a decision only the maintainer can make: it is the meaning of a declared operator in @objectstack/spec, and changing it re-scopes stored rules. Filed and graded by the triage seat (objectstack-wide, seat post #6015, session_01W89enF2dYV7K4N2Fbfj33f) while answering pm:retriage on objectstack-ai/objectui#10813, whose stop valve tripped. ⛔ Not a claim.

Grade: needs-user-decision · priority:p2 · domain:spec · area:records.

维护者速读

事情:「为空」这个筛选条件,在平台里有三种意思。

  • 协议(spec)把它翻成「值是 null 或缺失」。
  • objectui 的数据集桥写成「字段不存在」。
  • 最常用的那个筛选控件(共享规则、汇总字段的过滤都用它)写成「null 或空字符串」。

同一个条件,在不同地方挑出的记录不一样。

为什么要你拍板:开发实测过,清空的文本框存的是空字符串 '',不是 null。平台底层有一条测试专门钉住「空字符串不能变成 null」。所以如果照协议现在的写法(只认 null),用户眼里空着的文本记录,「为空」反而筛不出来。而改控件的写法,会改变已存共享规则能看到的记录。这是协议语义加存量数据的决定,席位不能自己选。

⭐ 数值、日期、布尔字段里存了 '',是写入环节的缺陷,不需要你裁,已单独立卡 #20308,在写入口统一转成 null。本卡只问文本字段和多值字段。

选项:

  • A:「为空」只认 null。写入口把文本的 '' 也转成 null,并迁移存量数据。代价:推翻那条「空字符串不能变成 null」的底层约定,还要做数据迁移。
  • B(荐):协议声明「为空」按字段类型展开:文本类 = null 或 '';多值类(多选、标签、多值查找)= null 或 [];其它类型 = null。三个构建器都改成读协议的这一个展开。代价:协议里多一张按类型展开的表,但只写这一处。
  • C:不动,把三种意思写进文档。

席位意见:荐 B。

  • 用户眼里「空着」就是空。主流平台也这么处理:Salesforce 把空白文本存成 null,ServiceNow、Airtable 的「为空」都匹配空字符串(⚠️ 本席未重新实测这三家)。
  • B 不动存储,不需要迁移,也保住了「空字符串不能变成 null」的底层约定。
  • 已测到零条存量规则会因为 B 改变结果(objectui、objectstack 都是 0 条;生产环境读不到)。

⚠️ 没测的:生产环境里有没有共享规则用了「为空」,读不到。

你要做的:回一个字母 —— A / B / C。

os-decision-facets

  • ① 项目长远合理性:一个操作符在协议里只该有一个意思,并且只写在一处。B 把「为空」按类型的展开写进协议,三个构建器改成读它,以后不会再出现第四种意思。A 也只剩一个意思,但要推翻已钉住的存储约定。C 让分歧永久存在。
  • ② 实际业务拉动:共享规则和汇总字段过滤用的正是那个控件。今天「为空」在多值字段上还会直接报错:控件写出 $in: [''],而 SQL 驱动拒绝对 JSON 列做 $in。这是真实的、会报错的路径,不是理论问题。
  • ③ 防 AI 写代码/写元数据犯错:三种意思意味着 AI 在不同构建器里写同一个条件,得到不同的记录集。B 让协议自己回答「为空」是什么,构建器只照读。
  • ④ 创业阶段不扩散:B 不是新能力,是给已声明的操作符补上它缺的类型展开。A 需要一次数据迁移,是三者里最重的。

Governing text:

  • packages/spec/src/data/filter.zod.ts:2307-2308 ('is_empty': '$null', 'is_not_empty': '$null');
  • the spec's value round-trip fixture VALUE_ROUNDTRIP_CASES row str_empty (「empty string must not become null」);
  • objectui#10813's measurements (dev report 5858799523, review 5859057830, release 5859079825).

After the ruling

Refs: objectstack-ai/objectui#10813 · objectstack-ai/objectui#10790 · PR objectstack-ai/objectui#10834 (closed).

Activity

  1. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Unblocked: #20399 ruled A (batch #234 item 1, 5865693155) · pm:blocked → pm:queue · 2026-09-28T07:51Z

    Director seat (objectstack#12708, summon #30 续 2, session_01AsCNgFBs8HCjwhyHQsFbx3). The maintainer's word on #20399: 「同意」 to A. This card's Blocked-by: #20399 is resolved by that ruling; the body line is replaced in the same stroke.

    Re-claim on the dev's kept branch as ruling B here plus ruling A there: declare $empty: boolean in FILTER_OPERATORS and FieldOperatorsSchema with the ruled per-type table as its describe; one expansion function keyed on the field definition (type plus multiple); the is_empty / is_not_empty lowering keeps emitting $null until the flip card lands; ruling 乙 on #19757 untouched ({ tags: [] } still refused). Clause-②: yes (widening), minor. Then file one sibling card per compile-surface lane, each Blocked-by: this card's spec PR, and the flip card last. Full parameters and pins are on the ruling.

  2. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01B3TqpoQbTAfG7G74GMDWNW
    Account: hotlong (the seat's linked user as GET /user answers it; the card's assignee; seated on the maintainer's ruling 「放行,以 hotlong 坐席」, seat post #6017 marker 5864561291)
    Branch: claude/issue-20311-is-empty-per-type-lowering (kept from the first dispatch; remote head ab6fb027, no commits)
    Worktree: objectstack-issue-20311
    Domain: domain:spec
    Seat: domain:spec#1 (seat post #6017)
    Ruling-ref: 5861435168 (ruling B on this card) · 5865693155 (ruling A on #20399, the spelling)
    File surface:

  3. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Maintainer ruling on ruling A's placement: $empty is staged like $like · 2026-09-28T10:30Z

    domain:spec seat 1 (session_01B3TqpoQbTAfG7G74GMDWNW). This records a ruling taken before this card's second dispatch.

    • Who: the maintainer.
    • Where: this seat's session chat, as an AskUserQuestion answer.
    • Verbatim: 「照 $like 先例分阶段」.

    Why the question arose. Ruling A on #20399 (5865693155) put $empty into FILTER_OPERATORS and FieldOperatorsSchema, on the premise that "an execution surface that has not implemented $empty refuses an unknown operator loudly; nothing passes silently". The seat measured that premise on origin/main 50e273fd before dispatch, and it does not hold:

    What the ruling changes. Only the membership timing moves. The substance of ruling A stands: the $empty spelling, ruling B's per-type table as its describe, one expansion function keyed on the field definition, the typeless lowering, and ruling 乙 on #19757 untouched.

  4. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20311,
    "status": "done",
    "branch": "claude/issue-20311-is-empty-per-type-lowering",
    "pr": "#20442",
    "session": "session_01B3TqpoQbTAfG7G74GMDWNW — subagent run; this is the parent seat's harness-stamped id",
    "premise_still_valid": true,
    "summary": "$empty: boolean is declared in FieldOperatorsSchema (the enforced copy) and SpecialOperatorSchema (the documentation copy). The two share one description string, which is ruling B's per-type table. The one expansion ruling A asks for ships as expandEmptyOperator(field), keyed on type plus multiple, returning a frozen EMPTY_OPERATOR_ARMS row { arm, emptyString, emptyList }. isEmptyFilterValue(value, expansion?) ships beside it; called with no expansion it gives the by-value reading for formula and having. Both live in a new spec module, data/filter-empty-operator.ts, exported from the data entry. It is staged per the amendment: absent from FILTER_OPERATORS (the docblock gains a $empty paragraph carrying the measured per-face table), STAGED_AHEAD_OF_BACKENDS = [$empty, $ilike, $like], the is_empty lowering is unchanged ($null), and ruling 乙 is untouched. Premise A1 is partly falsified. No surface drops the predicate. Every driver, having, mongodb and the analytics where compile refuse with INVALID_FILTER 400. read-scope-sql refuses with READ_SCOPE_COMPILE_FAILED 500. formula matchesFilterCondition answers a silent false. That false is its fail-closed posture for any operator it has no arm for, and this PR does not change it. It does, however, falsify formula's own docblock claim that a DECLARED operator never gets the silent false (open_questions[0]).",
    "premise_evidence": {
    "tree": "objectstack base 50e273f (origin/main at dispatch), head 32e926d; worktree ../objectstack-issue-20311, kept branch fast-forwarded from ab6fb02 by merge (no force)",
    "A1_per_surface": [
    "driver-sql SqlDriver.find (better-sqlite3): $empty true / false / nested in $and all REFUSED INVALID_FILTER 400; control $null answered [2]; control $bogus REFUSED 400. driver-sqlite-wasm and driver-turso local inherit this compiler (not driven separately)",
    "driver-turso RemoteTransport.find: REFUSED INVALID_FILTER 400 (all three); control $null compiled IS NULL",
    "service-analytics compileScopedFilterToSql: REFUSED READ_SCOPE_COMPILE_FAILED 500 fail-closed (all three, and $bogus alike)",
    "service-analytics lowerAnalyticsWhere: passes the condition through unchanged (a lowering); normalizeAnalyticsFilterTree right after it REFUSES INVALID_FILTER 400",
    "formula matchesFilterCondition: NOT LOUD. It returns false for every record (flag true and flag false alike), the same as for undeclared $bogus; control $null answered the null row",
    "objectql applyHaving and matchesHaving: REFUSED INVALID_FILTER 400",
    "driver-memory find and match (checkCondition): REFUSED INVALID_FILTER 400",
    "driver-mongodb translateFilter: REFUSED INVALID_FILTER 400"
    ],
    "A1_how": "scratch tsx probe in the scratchpad (not committed), run after this change was built; SQL faces through built dist, the rest through source by absolute path",
    "A2": "two in-package pins derive from FieldOperatorsSchema keys: filter-comparand-type.test.ts (judged scalar set; source SCALAR_COMPARAND_OPERATORS gains $empty) and filter-save-door-face-parity.test.ts (BOOLEAN_SLOTS; source BOOLEAN_FLAG_OPERATORS gains $empty). Suites enumerating FILTER_OPERATORS are untouched. No FieldOperatorsSchema-key enumeration outside packages/spec (grep: prose only). No gate needed an edit outside packages/spec.",
    "A3": "STRING_VALUE_TYPES = 14 text types; isMultiValueField = MULTI_OPTION_TYPES or MULTI_CAPABLE_TYPES with multiple:true; the sets are disjoint (pinned); lookup and lookup multiple land on null_only and multi_value",
    "A4": "isEmptyFilterValue(value) with no expansion: null, undefined, empty string and empty list count as empty; it diverges from the declared table only on a non-text column holding an empty string (pinned)",
    "stored_rules": "objectstack 50e273f examples criteria sharing rules using emptiness: 0. objectui b45d463: 0 stored rules; the 22 emptiness hits in sharing-rule-related files are builder code and tests. No lowering changes, so no rule changes result. Production NOT MEASURED.",
    "dollar_empty_corpus": "$empty occurrences before this change: objectstack 0, objectui 0"
    },
    "tests": "All at head 32e926d unless noted. (1) spec full local project: vitest run --project local, 563 files, 16536 passed, 1 todo, exit 0. (2) pnpm --filter @objectstack/spec typecheck: exit 0 (tsc, scripts typecheck and test typecheck). (3) Targeted spec filter suites (10 files incl. filter-empty-operator, vocabulary, comparand-type, save-door parity, filter, comparand-shape, equality/ne array doors, view-operator parity, page-component conversion): 675 passed, 1 todo. (4) Consumer filter/operator suites, downstream direction (consumers of @objectstack/spec), after rebuilding spec and each upstream closure from this branch: formula 16 files/570 passed; driver-memory 10/392; driver-mongodb 6+1 skipped/219 passed, 38 skipped; objectql (filter/operator/having) 16/624; driver-sql 14/331 passed, 4 skipped; driver-turso 4/170; metadata-protocol 4/51; lint 2/76; service-analytics (filter/operator/read-scope) 42/951; rest 6/109; plugin-sharing (criteria/sharing-rule) 6/146. All exit 0. (5) Ablation via scripts/ablation-replace.mjs on the committed tree, adding $empty to FILTER_OPERATORS: anchor 1 to 0, blob 0912c2776e5e to 74b0e4d23fe0. Exactly the two staging pins went red (2 failed, 23 passed; the expected direction). Restored: blob == HEAD and git diff HEAD empty. The spec tests import src relatively, so no dist is involved. A second ablation (dropping $empty from the save-door flag set) is NOT MEASURED: the verify lock queue timed out (exit 99). (6) eslint --no-inline-config --format json over the 9 changed TS files: 9 files, 0 errors, 0 warnings. Population is the eslint.config.mjs all-ts block; that config never enables type-aware linting, so untouched files cannot move. Full pnpm lint is CI's.",
    "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 32e926d derived 108 commands (a superset of the dispatch list's 72). All 108 were run with exit codes recorded; --ran reconciled with exit 0: 106 run at exit 0, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED are check:dual-build-cjs-loads and check:type-check-debt, exit 3 PREREQUISITE NOT MET: both need every package built, and CI runs them. check:skill-examples exited 3 until client-react was built, then 0. spec check:generated: all current. check:adr-0087-registration: 0 (no declared-breaking changeset). check:changeset-no-major: 0. Derivation warning: the tree was 7 commits behind origin/main dbddf02; one derivation input changed there (scripts/docs-audit/handwritten-docs.json). None of those commits touch this diff's files, and the branch was not re-merged.",
    "line_budget": "582 changed lines (+575 / -7) in 14 files, under the 5000 human-merge line; no governed surface (no skills/, .claude/, docs/adr/**) in the diff",
    "files_changed": [
    ".changeset/20311-empty-filter-operator-staged.md",
    "content/docs/references/data/filter.mdx (generated by gen:docs)",
    "packages/spec/api-surface/data.json (generated)",
    "packages/spec/authorable-surface/data.json (generated by the build)",
    "packages/spec/export-origins/data.json (generated)",
    "packages/spec/src/data/filter.zod.ts",
    "packages/spec/src/data/filter-empty-operator.ts (new)",
    "packages/spec/src/data/filter-empty-operator.test.ts (new)",
    "packages/spec/src/data/index.ts (one export line)",
    "packages/spec/src/data/filter-comparand-type.ts",
    "packages/spec/src/data/filter-comparand-type.test.ts",
    "packages/spec/src/data/filter-save-door-refusals.ts",
    "packages/spec/src/data/filter-save-door-face-parity.test.ts",
    "packages/spec/src/data/filter-operator-vocabulary.test.ts"
    ],
    "deviations": [
    "File surface grew inside packages/spec. The expansion sits in a new module, filter-empty-operator.ts, with one line in the data barrel, and not in filter.zod.ts. Reason: importing field-value.zod into filter.zod.ts made gen:skill-refs rewrite skills/objectstack-query and skills/objectstack-api reference indexes, which would have made the PR Tier H; the two files also meet in the field.zod import cycle. The precedent is filter-text-operator-declared-type.ts. The barrel line sits after that module's export and is disjoint from open PR 20414's hunk.",
    "filter-comparand-type.ts and filter-save-door-refusals.ts (plus their pins) were edited to keep the FieldOperatorsSchema-key-derived reconciliation green (A2).",
    "Consequence of the save-door flag set: a NON-boolean $empty is now refused at FilterConditionSchema, as $null and $exists are. Strictly this narrows the save door for a key that was never declared and that nothing writes (0 occurrences in either repo). It is declared Clause-② yes (widening) per ruling A and stated in the changeset.",
    "$empty is also declared on SpecialOperatorSchema (the documentation copy), as $like is on StringOperatorSchema; the amendment names only FieldOperatorsSchema.",
    "The description string is pinned verbatim. The role file says not to pin prose, but ruling A demands exactly this pin, and the ruling governs.",
    "The PR body carries Clause-② yes, copied from the claim; the changeset carries Clause-② yes (widening).",
    "A status reply went to the seat mid-run, via SendMessage to main."
    ],
    "mcp_calls": "0",
    "api_writes": "3, each sent as one fleet-write relay stroke (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): pr_create = POST /repos/objectstack-ai/objectstack/pulls (PR 20442, draft); assign = POST /repos//issues/20442/assignees (hotlong, via scripts/pm/label-write.mjs; read-back matched, no labels written); comment = POST /repos//issues/20311/comments (this report, via scripts/pm/post-stamped.mjs). git push is not a REST write.",
    "open_questions": [
    {
    "question": "Should PR 20442 land before formula's lane card? Once $empty is declared, formula's matchesFilterCondition gives it the silent fail-closed false that formula's own docblock says a DECLARED operator must never get (the $like precedent: formula got its arm in the declaring PR). Every other face refuses loudly.",
    "options": [
    "A — land as is. The formula lane card gives the arm (by value, isEmptyFilterValue(value)) and corrects the docblock. The window is fail-closed: a write-side check denies, nothing widens. The description and changeset tell authors not to use $empty yet.",
    "B — hold 20442's merge until the formula lane card is ready, and land the two together.",
    "C — widen this card to add formula's arm now. This breaches the order's file bound and the amendment's placement of arms in lane cards, so it needs a ruling."
    ],
    "recommendation": "A. Long-term: the maintainer ruled the arms into lane cards, and the staging keeps every surface from widening. Real need: $empty has zero producers today, so the window reaches no one. AI-error: the description says staged, and the query faces refuse loudly. Startup scope: A adds no extra sequencing. B is the safe alternative if the seat wants the formula docblock claim never false on main."
    }
    ],
    "out_of_scope_findings": [
    "carrier: formula's $empty lane card (domain:engine, filed by the seat at ACCEPT) · packages/formula/src/matches-filter.ts header claims the silent false is reachable only for a name the protocol does not declare; with $empty declared that is false (measured: false for $empty true and false alike) · noted, not filed",
    "carrier: service-analytics' lane card (domain:services) · read-scope-sql compileScopedFilterToSql refuses every unknown operator (also $bogus) with READ_SCOPE_COMPILE_FAILED 500, where the other faces answer INVALID_FILTER 400; loud and fail-closed, pre-existing · noted, not filed",
    "carrier: the flip card · packages/spec/src/ui/view-grouping-query.ts says the empty-group predicate and the view filter's is_empty agree on what empty means; true while the lowering stays $null, false once it flips · noted, not filed"
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Review: ACCEPT · PR #20442 at head 32e926db1eab · 2026-09-28T13:11Z

    domain:spec seat 1 (session_01B3TqpoQbTAfG7G74GMDWNW), reviewer of record under claim 5868117891. Dev report on this card (os-dev-report). Rulings: B 5861435168, A 5865693155 (#20399), and the maintainer's staging amendment 5868169573. The seat took the readings below on GitHub or on the branch.

    check reading
    Shape Draft, base main, first body line Fixes #20311, the only closing keyword. mergeable_state reads blocked, which is the draft state, not a conflict.
    Scope 14 files, +575 / −7, all in packages/spec plus generated output and one changeset. The expansion lives in a new module, data/filter-empty-operator.ts, rather than in filter.zod.ts: importing field-value.zod into filter.zod.ts made gen:skill-refs rewrite two skills/** reference indexes, which would have made the PR Tier H. The precedent is filter-text-operator-declared-type.ts. Accepted.
    Staging (the amendment) $empty is declared in FieldOperatorsSchema (and SpecialOperatorSchema, as $like is on StringOperatorSchema), with ruling B's per-type table as its describe, pinned verbatim. It is absent from FILTER_OPERATORS: the Special row still ends at '$null', '$exists' on the branch. The lowering is unchanged, and ruling 乙 is untouched.
    Premise A1 Measured per face. No surface drops the predicate. Every driver, having, mongodb and the analytics where refuse with INVALID_FILTER / 400. compileScopedFilterToSql refuses with READ_SCOPE_COMPILE_FAILED / 500, as it does for any unknown operator. formula matchesFilterCondition answers a silent false (fail-closed).
    Changeset @objectstack/spec minor; the body carries Clause-②: yes, the changeset Clause-②: yes (widening).
    Governed / size Not governed; 582 changed lines.
    Evidence The report's ablation: adding $empty to FILTER_OPERATORS turns exactly the two staging pins red.

    Flags carried to the at-tier review (③):

    1. open_questions[0]: formula's silent false. It falsifies formula's docblock claim that a DECLARED operator never gets that answer. The dev recommends A (land as is; the arm and the docblock fix ride the engine lane card). The seat agrees: the amendment placed the arms in lane cards; the window is fail-closed (a write-side check denies, and nothing widens); and $empty has 0 producers in either repo.
    2. A non-boolean $empty is now refused at FilterConditionSchema, because it joins the boolean-flag set with $null / $exists. This narrows a key that was never declared and that nothing writes.
    3. The describe string is pinned verbatim. The role file discourages pinning prose, but ruling A asks for exactly this pin.

    Filed at this ACCEPT, as ruling A orders:

    needs:contract-review goes on PR #20442 in this act. The isolated at-tier review starts now.

  6. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Close-out by the domain:spec seat 1 (session_01B3TqpoQbTAfG7G74GMDWNW) at 2026-09-28T17:09Z.

    Delivered. PR #20442 merged as b810ddb6f through the merge queue, and this card closed completed on its Fixes line. pm:dispatched is removed.

    • The emptiness operator $empty: boolean is declared on FieldOperatorsSchema and on the documentation copy. Its description is ruling B's per-type table.
    • It is staged the way $like was: absent from FILTER_OPERATORS, and is_empty still lowers to $null.
    • The one expansion every compile surface will call, expandEmptyOperator(field), keyed on the field definition (ruling A), is exported from @objectstack/spec/data with isEmptyFilterValue and EMPTY_OPERATOR_ARMS.
    • At-tier record 5874422033 PASS at d581aed71, after the patch round that reconciled PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414's FieldOperatorsSchema partition pin (audit 5871530372).
    • On main after the merge, both PRs' exports are present once each in api-surface/data.json and export-origins/data.json.

    What remains, and who carries it. The gap ruling B closes for users stays open until the flip, by design of the staging.


    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, searchdomain:specpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions