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

    Ruling: batch #229 item 1 · letter B · maintainer 「同意」 2026-09-28T00:52Z

    Director seat, summon #30 续 2, session_01AsCNgFBs8HCjwhyHQsFbx3. Batch #229 was presented in the seat chat with the full analysis of each item (background, options with real costs, mainstream platforms, four axes, recommendation, confidence gaps, execution; the triage seat's analysis in this card's body re-read, four axes not flipped); the maintainer answered 「同意」 (verbatim, recorded here per the charter).

    Ruled: B — @objectstack/spec declares what 「is empty」 means once, per field type: text-like = null or ''; multi-value (multi-select, tags, multi-value lookup) = null or []; every other type = null only. The three objectui builders read that one lowering; none keeps a meaning of its own.

    Readings on origin/main ab820016b3 (objectstack) and fab627ff (objectui): packages/spec/src/data/filter.zod.ts:2395-2396 lowers is_empty / is_not_empty to $null; the value round-trip fixture value-roundtrip-conformance.ts:244 (str_empty, 「empty string must not become null」) pins that a cleared text box stores ''; objectui filter-builder.tsx:161 says in its own comment that is_empty / is_not_empty 「also treat '' as empty」, and the dataset bridge spells the operator a third way (the card's measurement, objectui#10813). So the operator the protocol declares has three meanings on the product, and the one the protocol writes today cannot find the records a user sees as empty. On a multi-value field the builder emits $in: [''], which the SQL driver refuses on a JSON column: a live error path, not a theoretical one. Numeric, date and boolean columns storing '' are a write-door defect and are #20308's, not this card's. Prior-ruling sweep over AGENTS.md, docs/adr, packages/spec/src: no ADR gives the operator a per-type meaning.

    Mainstream reading the ruling rests on: Salesforce stores a blank text field as null, so its 「is empty」 covers the blank case by construction; ServiceNow's and Airtable's 「is empty」 match the empty string; no mainstream product asks a user to tell null from ''. B reaches the same user-facing meaning without moving storage, and keeps the round-trip fixture true. A reaches one meaning by overturning that fixture and migrating data, the heaviest of the three; C makes the disagreement permanent.

    Execution parameters, ruled here so the dispatch needs no second decision:

    1. Spec (domain:spec): the lowering of is_empty / is_not_empty becomes a per-type table in filter.zod.ts, declared once, with the three arms above and a describe that says which types take which arm; is_not_empty is its exact complement. A pin per arm and per type family; the round-trip fixture is untouched.
    2. objectui#10813 then converges FilterConditionField and datasetFilterCondition.ts on the spec's lowering, including the multi-value arm that emits $in: [''] today; no builder keeps a private meaning.
    3. Stored sharing rules: re-measured at landing on both repositories (0 rows change result today, per the card); production rules are NOT MEASURED and the changeset says so.
    4. Changeset @objectstack/spec minor (a declared operator gains a per-type meaning: a widening of what a filter finds on text and multi-value columns), Clause-②: yes; ADR-0087 registration if the migration gate asks for a semantic entry.
    5. Carrier: this card becomes the spec-lane work card (domain:spec · p2 kept); it closes by Fixes with the spec PR, and objectui#10813 unblocks on that landing.

    Confidence gap carried: production sharing rules that use 「is empty」 are unreadable from the fleet.

    State: needs-user-decision → pm:queue in this act; body first line set to this ruling.


    Generated by Claude Code

  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
    Worktree: objectstack-issue-20311
    Domain: domain:spec
    Seat: domain:spec#1 (seat post #6017)
    Ruling-ref: 5861435168 (batch #229 item 1, letter B)
    File surface:

  3. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20311,
    "status": "needs_decision",
    "branch": "claude/issue-20311-is-empty-per-type-lowering",
    "pr": null,
    "session": "session_01B3TqpoQbTAfG7G74GMDWNW — subagent run; this is the parent seat's harness-stamped id",
    "premise_still_valid": false,
    "summary": "Stopped before code: ruling B's multi-value arm (null or []) has no spelling in the lowered FilterCondition. An empty list in the equality slot is refused at the shared comparand-shape face and at the schema door by ruling 乙 on #19757 (and $ne: [] by #19886), and no declared operator tests for an empty stored list, so a per-type lowering in filter.zod.ts can carry the text arm and the null arm but not the third. Separately, the order's premise A2 is false at three runtime doors: protocol.ts:10862, engine.ts:1056 and filter-normalizer.ts:2184 lower is_empty without the field types they hold or can reach, and protocol.ts receives is_empty from named production producers. The choice between a type-aware lowering (the dispatch's route, which must reopen #19757 for []) and a declared emptiness operator resolved at the compile surfaces (which leaves #19757 standing and needs no type at any lowering door) decides what the spec half even is, so writing either now would be speculative; open_questions[0] carries the options on the four axes, with a recommendation.",
    "premise_evidence": {
    "tree": "objectstack origin/main ab6fb02 (worktree ../objectstack-issue-20311, BASE ab6fb02); objectui origin/main 9f0c84a",
    "A1_lowering_sites": "confirmed, none sees a field type: AST_OPERATOR_MAP rows is_empty/is_not_empty/isempty/isnotempty lower to $null (filter.zod.ts:2460-2463); canonicalAstOperator folds is_empty onto is_null and is_not_empty onto is_not_null (:2514-2521); convertComparison emits { f: { $null: true|false } } (:2643-2650)",
    "A2_sites": [
    "protocol.ts:10862 parseFilterAST(parsedFilter) — schema reachable, not passed: the same normalizer reads field declarations through this.resolveQueryFields(object) (assertSortFieldsExist, :9844, gate.fields[head].type). Production input WITH is_empty: objectui packages/core/src/utils/filter-converter.ts viewFilterRuleToNode (:1156; VALUELESS_VIEW_OPERATORS :1146) lowers a stored view rule { operator: 'is_empty' } to ['f','is_empty'] onto the list request; objectstack packages/rest/src/view-filter-rule-lowering.ts lowerViewFilterRules (:87) does the same for a public form picker's static filter. Verdict: needs a change outside packages/spec.",
    "engine.ts:1056 parseFilterAST(where, ctx) — lowerWhereFilterArray(object, operation, bag, schema) holds schema and hands it to the three sibling doors on the same seam (assertFilterIsMaterializable, assertTextOperatorTargetsAreStringCapable, assertTemporalComparandsInterpretable), not to the lowering. In-repo authored corpus with is_empty: 0. Verdict: needs a change outside packages/spec.",
    "service-analytics filter-normalizer.ts:2184 lowerAnalyticsWhere(query) — takes no schema; its callers hold the cube (objectql-strategy.ts:200/219/255). In-repo corpus: 0; objectui's dataset builder writes the object form ({ $exists: false }, datasetFilterCondition.ts:190), not the sugar. Verdict: needs a change outside packages/spec.",
    "driver-memory memory-driver.ts:1348 canonicalAstOperator — node form { type: 'comparison' }; producers of that node outside driver-memory and its tests: 0 (grep over packages/). Verdict: no production input; its case arms for is_empty/isempty/is_not_empty/isnotempty (:1415/:1418) are unreachable after the fold.",
    "lint validate-preset-comparands.ts:676 canonicalAstOperator — a classifier for date-preset findings; is_empty folds to is_null, which no arm reads; lowers nothing. Verdict: unaffected.",
    "spec data-engine.zod.ts:1058 parseFilterAST(bag.where) — a transport-schema transform with no object schema by construction, reached through FindDataRequestSchema.query, which no runtime package parses (0 non-test hits outside packages/spec; plugin-rest-api.zod.ts:963 names it as a string). spec conversions/registry.ts:10811 never sees is_empty (it folds to a view-rule operator first, :10806)."
    ],
    "A2_corpus_control": "is_empty spellings in examples/: 0; control is_null|is_not_null|not_equals|contains in examples/: 37 hits in 12 files. Whole-tree is_empty hits: spec, drivers, formula (CEL isEmpty, a different thing), rest docblock, docs, changesets; no platform-objects, template or example authors it.",
    "A3_type_sets": "text-like = STRING_VALUE_TYPES (field-value.zod.ts:57, 14 types); multi-value = isMultiValueField(def) (:355) = MULTI_OPTION_TYPES (multiselect, checkboxes, tags) or a MULTI_CAPABLE_TYPES member (select, radio, lookup, user, file, image) with multiple: true; so a multi-value lookup is lookup or user with multiple: true. Consequence: the table keys on the field DEFINITION (type plus multiple), so the zone-3 signature (fieldType, negated) cannot tell a multi-value lookup from a single one.",
    "multi_value_arm_probe": "tsx probe over packages/spec/src at ab6fb02, EXIT=0. Text arm { $or: [ { name: { $null: true } }, { name: { $eq: '' } } ] }: shape face passed, FilterConditionSchema true; complement { name: { $null: false, $ne: '' } }: passed, true. Multi-value: parseFilterAST(['tags','=',[]]), { tags: [] }, { tags: { $eq: [] } }, { tags: { $ne: [] } }, { tags: { $in: [null] } } and the $or arm with $eq: [] all THROW code=INVALID_FILTER status=400; FilterConditionSchema and FieldOperatorsSchema refuse $eq: [] at parse. Control: { tags: { $in: [] } } passes the face, FieldOperatorsSchema { $in: [] } true. Governing text: filter-comparand-shape.ts:237-262 (ruling 乙 on #19757, record 5793368540: 'An EMPTY array is still an array: { tags: [] } is refused too'); pre-face readings in migrations entry 18.filter-equality-array-comparand-refused.ts (SQL family 400 top level / 500 nested, driver-memory 400, formula no row, driver-mongodb answered) — quoted, not re-measured. [] is a real stored state: record-validator.ts:263-279 (#9447 ruling: the empty set reads back as [], never null).",
    "A4_compile_surfaces": "not reached — the lowered shape is the open decision. What each option costs per surface: the text arm uses only declared operators ($or, $null, scalar $eq/$ne), per-surface execution NOT MEASURED this run; the [] arm is refused at the shared face before any surface runs (probe above). Declared-type access for option A, by grep of each compile file for NON_TEXT_STORED_VALUE_TYPES / isMultiValueField / STRING_VALUE_TYPES / field-def reads: driver-sql sql-driver.ts 10 and driver-turso remote-transport.ts 3 read declared types in the compile file; service-analytics read-scope-sql.ts takes a caller-supplied classifier (nonTextColumn, :653); objectql having-filter.ts 1 (a comment; it filters aggregated rows with no declarations); 0 in formula matches-filter.ts, driver-memory memory-matcher.ts, driver-mongodb mongodb-filter.ts, service-analytics filter-normalizer.ts.",
    "A5_stored_rules": "objectstack ab6fb02: criteria sharing rules in examples/ = 2 (app-showcase src/security/sharing-rules.ts:76 record.status == 'new', :110 record.stage == 'qualified' && record.company == 'Northwind'); using emptiness = 0; app-crm declares none (objectstack.config.ts:102). objectui 9f0c84a: criteria rule fixtures = 1 (app-shell clientValidation.optOuts.test.ts:52, region == "west"); using emptiness = 0. The widget's stored shape ($in: [''] beside $null) in objectstack outside spec tests and one changeset: 0. Production sharing rules: NOT MEASURED (unreadable from the fleet)."
    },
    "tests": "No code, so no test run is owed. Readings taken: (1) tsx scratchpad probe against packages/spec/src at ab6fb02, EXIT=0, 16 result lines quoted in premise_evidence.multi_value_arm_probe (refusals print code=INVALID_FILTER status=400; controls pass). (2) node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: EXIT=2, 'this branch changes nothing against origin/main (merge base ab6fb02) — nothing to derive'. (3) Corpus greps with positive controls as listed in premise_evidence (A2_corpus_control, A5_stored_rules). No build, no ablation: nothing was changed.",
    "gates": "not run: files_changed is empty (branch head = origin/main ab6fb02); the derivation above has nothing to derive.",
    "files_changed": [],
    "line_budget": "n/a — no diff",
    "deviations": [
    "The order's A2 fallback ('implement the spec half … write Part of #20311') was not taken: what the spec half IS depends on open_questions[0] (a type-aware lowering entry under B, a declared operator plus the table under A), so writing it now is speculative code. The role file's stop rule (a public-contract shape with two readings that lead to two architectures) and the order disagree here; the role file wins by its own precedence clause, and this line names the conflict rather than choosing silently.",
    "The zone-3 signature (fieldType, negated) is falsified by A3: the multi-value arm needs the field definition (type plus multiple).",
    "Writes: git push of the empty branch (the route probe) and this one comment; no pr_create, no label-write, PR assignee not set (no PR).",
    "Worktree ../objectstack-issue-20311 was clean with zero commits ahead and is removed (node_modules first, then git worktree remove without --force); the remote branch stays at ab6fb02 for the re-dispatch after the ruling."
    ],
    "mcp_calls": "0",
    "api_writes": "1 — POST /repos//issues/20311/comments (this report), through scripts/pm/post-stamped.mjs on the fleet-write relay. git push of claude/issue-20311-is-empty-per-type-lowering is not a REST write.",
    "open_questions": [
    {
    "question": "How does the lowered filter spell ruling B's multi-value arm (null or []), and so where is the per-type table applied: at the lowering, or at the compile surfaces?",
    "options": [
    "A — a declared emptiness operator. Add $empty: boolean to the FilterCondition vocabulary (FILTER_OPERATORS, FieldOperatorsSchema); its describe IS the ruled table (text-like = STRING_VALUE_TYPES: null or ''; multi-value = isMultiValueField: null or []; every other type: null); is_empty / is_not_empty lower TYPELESSLY to { f: { $empty: true|false } }, and each compile surface expands it by the field's declared type through one spec function. Cost: one new operator; all 8 compile surfaces of A4 implement one arm, in a staged landing (surfaces answer first, the lowering flips last, or every stored is_empty is refused as an unknown operator in between); the faces with no declarations (formula matcher, having) read by value, a named divergence only on a non-text column holding '' (the write-door class #20308 carries). Gain: no lowering door needs a field type (A2's failure dissolves), #19757 stands untouched, a stored filter keeps its meaning if its field's type changes, and an author of the canonical object form gets one spelling for 「is empty」.",
    "B — type at lowering time (the dispatch's suggested route). parseFilterAST takes a field-definition resolver and emits per-type fragments. Cost: the multi-value fragment needs [] as an equality comparand, so ruling 乙 on #19757 (and its save-door twin on #19889) must be reopened for the empty list, and the SQL family, driver-memory and the formula matcher must learn to execute it on a JSON column (each refuses or answers no row today); the three runtime doors of A2 must pass resolvers; the transport-schema door (data-engine.zod.ts:1058) has no object schema at all and needs its own answer (open_questions[1]); and a lowered filter stored in sharing-rule criteria freezes the field's type at authoring time.",
    "C — B for the text and null arms now, the multi-value arm deferred to its own ruling. Cost: ruling B's third arm undelivered and the table ships two of three arms; if the deferred arm later lands as A, the B half is rework."
    ],
    "recommendation": "A, on the four axes. Real need: the measured production path for 「is empty」 is a stored view rule that objectui lowers to ['f','is_empty'] and protocol.ts:10862 lowers again without types; A serves it with no caller change, B needs three doors changed first. Long-term: one predicate and one table, applied where the declared type is known — the pattern NON_TEXT_STORED_VALUE_TYPES already uses for the text operators; B bakes a type-dependent fragment into stored criteria. AI-error: the canonical object form gets a spelling; under B an AI writing FilterCondition directly must hand-build the per-type $or, which is how '' reached a number column (#20336). Startup scope: A adds one operator but no capability (it is the canonical spelling of an operator already declared); B reopens a five-day-old ruling. The trade-off to weigh: A touches all 8 compile surfaces in one staged landing; B touches fewer compile surfaces but more doors, plus a reopened ruling."
    },
    {
    "question": "Only under B or C: what does a lowering door that holds no field types do with is_empty?",
    "options": [
    "A — refuse with INVALID_FILTER / 400 naming the missing type. Loud, but every such door must be converted before the lowering flips, or saved views with 「is empty」 answer 400.",
    "B — keep $null at such a door. This is the typeless path keeping $null as a meaning of its own, which the order forbids."
    ],
    "recommendation": "A under B or C; the question does not arise under option A of open_questions[0]."
    }
    ],
    "out_of_scope_findings": [
    "carrier: sibling card, filed by the seat (needed under options B and C only) — metadata-protocol protocol.ts:10862 lowers is_empty without the field definitions it already reads through resolveQueryFields (:9844); reached in production by objectui filter-converter.ts viewFilterRuleToNode and objectstack rest lowerViewFilterRules · dedupe words: is_empty typeless lowering protocol, parseFilterAST field type resolver, view filter is_empty null only",
    "carrier: sibling card, filed by the seat (options B and C only) — objectql engine.ts:1056 holds schema on the lowering seam and does not pass it to parseFilterAST · dedupe words: lowerWhereFilterArray schema parseFilterAST, is_empty engine lowering type",
    "carrier: sibling card, filed by the seat (options B and C only) — service-analytics filter-normalizer.ts:2184 lowerAnalyticsWhere takes no schema; its callers hold the cube · dedupe words: lowerAnalyticsWhere field type, analytics is_empty lowering",
    "carrier: sibling card(s), filed by the seat (every option) — the 8 compile surfaces of A4: under A each implements $empty from the spec table; under B the SQL family, driver-memory, the formula matcher and having must execute an empty-list equality on a JSON column · dedupe words: is_empty compile surface, empty list equality JSON column, $empty operator",
    "carrier: the PR that implements the ruling (every option) — packages/spec/src/ui/view-grouping-query.ts:539-546 says the empty-group predicate ($null) and the view filter's is_empty agree on what 「empty」 means; under ruling B that stops being true on text and multi-value columns · noted, not filed",
    "carrier: the driver-memory compile-surface card above — memory-driver.ts:1415/:1418 case arms for is_empty/isempty/is_not_empty/isnotempty are unreachable because canonicalAstOperator (:1348) folds them first · dead code, noted, not filed"
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Release: session_01B3TqpoQbTAfG7G74GMDWNW · cause: premise falsified (ruling B's multi-value arm has no spelling in the lowered filter) · destination: pm:blocked, Blocked-by: #20399 · domain:spec seat 1 · 2026-09-28T06:53Z

    Seat review of the dev report 5864899591: ACCEPT as premise evidence, no PR

    The dev stopped before code with premise_still_valid: false. The seat checked the three load-bearing readings on origin/main itself; it did not take them from the report.

    The order's fallback was not taken, and the seat agrees. The order said: if a site outside packages/spec must change, implement the spec half and write Part of. The dev read that fallback as unexecutable, because what the spec half IS depends on the open choice. That is correct: either spec half would be speculative code.

    Premise A2 was falsified as well (report premise_evidence.A2_sites). protocol.ts:10862, engine.ts:1056 and service-analytics filter-normalizer.ts:2184 lower is_empty without the field types they hold or can reach. protocol.ts receives is_empty from a named production producer: objectui viewFilterRuleToNode, re-read by the seat on objectui 9f0c84a. Under option A of #20399 that stops mattering; under B it becomes three sibling cards.

    Disposition. Ruling B stands and is not re-presented. The new question is on #20399: needs-user-decision, with options A ($empty), B (reopen #19757 for []) and C (two arms now). The seat recommends A, with the full four-axis analysis there. This card goes to pm:blocked behind it, and domain:spec · p2 · area:records stay. The next claimant re-claims under the full protocol once #20399 is ruled. objectui#10813 stays blocked on this card.

    Leftovers: the branch claude/issue-20311-is-empty-per-type-lowering sits at ab6fb027 (no commits, the dev's route probe), and the worktree is removed. The branch is not a claim; the next dispatch may reuse or replace it.

    Report hygiene: mcp_calls 0; api_writes 1 (this report). No write tool is named.

  5. 5 remaining items

  6. 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:

  7. 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.

  8. 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 b45d463a9: 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

  9. 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.

  10. 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