You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[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
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.
B:domain:spec declares the per-type lowering once (filter.zod.ts). objectui#10813 then converges FilterConditionField and datasetFilterCondition.ts on it, including the multi-value arm that today writes $in: [''] onto JSON columns.
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/mainab820016b3 (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:
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.
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.
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.
Changeset @objectstack/specminor (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.
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.
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:
packages/spec/src/data/filter.zod.ts: the is_empty / is_not_empty lowering (the operator map rows, canonicalAstOperator's null/empty fold, and the array-sugar lowering's null/empty branch) becomes the per-type table the ruling names, declared once, with its describe.
Its tests in packages/spec/src/data/. ⛔ The value round-trip fixture (value-roundtrip-conformance.ts) is untouched.
An ADR-0087 semantic entry under packages/spec/src/migrations/entries/semantic/ plus the step-18 list in packages/spec/src/migrations/registry.ts, only if the migration gate asks for one.
The spec artefacts the repo's generators regenerate, produced by the generators, never by hand.
.changeset/.
⛔ Not objectui (objectui#10813 converges its builders after this lands). Any caller outside packages/spec that must change to honour the table is reported first, not edited silently.
(stop on breach; explain in the report)
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier --repo objectstack-ai/objectstack packages/spec/src/data/filter.zod.ts: no path-derived mandate; clause-② suspect surface). Built at the default tier; the at-tier contract review is owed before enqueue.
Clause-②: yes
Thread-read: 5861435168
Serial constraints cleared: read at 2026-09-28T06:28Z.
No open PR touches filter.zod.ts (open-PR file lists read at this claim).
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"
]
}
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.
No operator tests for an empty list.FILTER_OPERATORS in packages/spec/src/data/filter.zod.ts ends at '$null', '$exists'; there is no emptiness or size member.
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.
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:
packages/spec/src/data/filter.zod.ts: $empty: boolean added to FILTER_OPERATORS and FieldOperatorsSchema, its describe carrying ruling B's per-type table; one expansion function keyed on the field definition (type plus multiple), exported for the compile surfaces. ⛔ The is_empty / is_not_empty lowering keeps emitting $null (the flip is a later card).
packages/spec/src/data/field-value.zod.ts only if the table needs a set it lacks, by reference to the existing sets.
The generator-regenerated artefacts, never by hand. .changeset/.
⛔ No compile surface edited (drivers, formula, objectql, service-analytics): each lane's card is filed by this seat at ACCEPT, Blocked-by: #20311, naming the landed function, and the flip card last.
(stop on breach; explain in the report)
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; clause-② suspect surface). Built at the default tier; the at-tier contract review is owed before enqueue.
Clause-②: yes
Thread-read: 5865751001
Serial constraints cleared: read at 2026-09-28T10:27Z.
No open PR touches filter.zod.ts or field-value.zod.ts (open-PR file lists read at this claim).
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/main50e273fd before dispatch, and it does not hold:
driver-memory's accepted set is built from FILTER_OPERATORS (filter-refusal.ts:363, SUPPORTED_FIELD_OPERATORS = new Set([...FILTER_OPERATORS, '$like', '$ilike'])).
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.
The compile-surface lane cards (filed by this seat at ACCEPT, Blocked-by: #20311): each gives its surface a $empty arm, the way driver-memory widens its own set for $like.
The flip card (last): it adds $empty to FILTER_OPERATORS once every surface has its arm, and flips the lowering.
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"
]
}
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/specminor; 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 (③):
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.
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.
The describe string is pinned verbatim. The role file discourages pinning prose, but ruling A asks for exactly this pin.
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.
Activity
objectstack-fleet commentedon Sep 28, 2026
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/specdeclares 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/mainab820016b3(objectstack) andfab627ff(objectui):packages/spec/src/data/filter.zod.ts:2395-2396lowersis_empty/is_not_emptyto$null; the value round-trip fixturevalue-roundtrip-conformance.ts:244(str_empty, 「empty string must not become null」) pins that a cleared text box stores''; objectuifilter-builder.tsx:161says in its own comment thatis_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 overAGENTS.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:
domain:spec): the lowering ofis_empty/is_not_emptybecomes a per-type table infilter.zod.ts, declared once, with the three arms above and a describe that says which types take which arm;is_not_emptyis its exact complement. A pin per arm and per type family; the round-trip fixture is untouched.FilterConditionFieldanddatasetFilterCondition.tson the spec's lowering, including the multi-value arm that emits$in: ['']today; no builder keeps a private meaning.@objectstack/specminor(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.domain:spec· p2 kept); it closes byFixeswith 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:queuein this act; body first line set to this ruling.Generated by Claude Code
objectstack-fleet commentedon Sep 28, 2026
Claim: PM loop round 1
Session:
session_01B3TqpoQbTAfG7G74GMDWNWAccount:
hotlong(the seat's linked user asGET /useranswers it; the card's assignee; seated on the maintainer's ruling 「放行,以 hotlong 坐席」, seat post #6017 marker5864561291)Branch:
claude/issue-20311-is-empty-per-type-loweringWorktree:
objectstack-issue-20311Domain:
domain:specSeat:
domain:spec#1(seat post #6017)Ruling-ref:
5861435168(batch #229 item 1, letter B)File surface:
packages/spec/src/data/filter.zod.ts: theis_empty/is_not_emptylowering (the operator map rows,canonicalAstOperator's null/empty fold, and the array-sugar lowering's null/empty branch) becomes the per-type table the ruling names, declared once, with its describe.packages/spec/src/data/. ⛔ The value round-trip fixture (value-roundtrip-conformance.ts) is untouched.packages/spec/src/migrations/entries/semantic/plus the step-18 list inpackages/spec/src/migrations/registry.ts, only if the migration gate asks for one..changeset/.packages/specthat must change to honour the table is reported first, not edited silently.(stop on breach; explain in the report)
Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier --repo objectstack-ai/objectstack packages/spec/src/data/filter.zod.ts: no path-derived mandate; clause-② suspect surface). Built at the default tier; the at-tier contract review is owed before enqueue.Clause-②: yes
Thread-read: 5861435168
Serial constraints cleared: read at 2026-09-28T06:28Z.
filter.zod.ts(open-PR file lists read at this claim).packages/spec/src/migrations/registry.ts(step-18 list) is carried by open PRs feat(automation)!: edge-branched decision is exclusive; mode: 'inclusive' takes every branch (#15429) #20344, feat(analytics): enforce analytics_cube.public and default it to visible #20348, feat(spec)!: retire the connector resilience family — health (probe + breaker), status and nested webhooks, sixteen keys nothing read (#20273) #20350, feat(spec)!: retire the list view's owntabskey; named presets arelistViewsentries #20357 and fix(spec): os migrate meta guidance for the driver-*, kernel-* and system-* migration entries states each lesson in words, not tracker numbers (stage 3) #20384. If this card adds an entry, its landing mergesmainwithscripts/pm/os-regen-merge.shonly, never by hand.where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336 (this seat, a new module in the same directory) shares thedatabarrel at most; region-disjoint.objectstack-fleet commentedon Sep 28, 2026
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
objectstack-fleet commentedon Sep 28, 2026
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:specseat 1 · 2026-09-28T06:53ZSeat review of the dev report
5864899591: ACCEPT as premise evidence, no PRThe dev stopped before code with
premise_still_valid: false. The seat checked the three load-bearing readings onorigin/mainitself; it did not take them from the report.FILTER_OPERATORSinpackages/spec/src/data/filter.zod.tsends at'$null', '$exists'; there is no emptiness or size member.[]in the equality slot is refused by ruling. Ruling 乙 on [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757 (5793368540), infilter-comparand-shape.ts, verbatim: 「An EMPTY array is still an array:{ tags: [] }is refused too.」[].record-validator.ts(the [Decision] Multi-value lookup after set_null member removal: does the emptied array read back as[]ornull? #9447 ruling): 「the empty set is representable (it reads back as[], nevernull)」.FilterCondition, and its multi-value arm is not. The shape the spec half takes depends on a choice between two public-contract changes: a new$emptyoperator, or reopening [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757 for[]. That choice is the maintainer's.The order's fallback was not taken, and the seat agrees. The order said: if a site outside
packages/specmust change, implement the spec half and writePart 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:1056and service-analyticsfilter-normalizer.ts:2184loweris_emptywithout the field types they hold or can reach.protocol.tsreceivesis_emptyfrom a named production producer: objectuiviewFilterRuleToNode, re-read by the seat on objectui9f0c84a. 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 topm:blockedbehind it, anddomain:spec·p2·area:recordsstay. 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-loweringsits atab6fb027(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_calls0;api_writes1 (this report). No write tool is named.5 remaining items
objectstack-fleet commentedon Sep 28, 2026
Claim: PM loop round 1
Session:
session_01B3TqpoQbTAfG7G74GMDWNWAccount:
hotlong(the seat's linked user asGET /useranswers it; the card's assignee; seated on the maintainer's ruling 「放行,以 hotlong 坐席」, seat post #6017 marker5864561291)Branch:
claude/issue-20311-is-empty-per-type-lowering(kept from the first dispatch; remote headab6fb027, no commits)Worktree:
objectstack-issue-20311Domain:
domain:specSeat:
domain:spec#1(seat post #6017)Ruling-ref:
5861435168(ruling B on this card) ·5865693155(ruling A on #20399, the spelling)File surface:
packages/spec/src/data/filter.zod.ts:$empty: booleanadded toFILTER_OPERATORSandFieldOperatorsSchema, its describe carrying ruling B's per-type table; one expansion function keyed on the field definition (type plusmultiple), exported for the compile surfaces. ⛔ Theis_empty/is_not_emptylowering keeps emitting$null(the flip is a later card).packages/spec/src/data/field-value.zod.tsonly if the table needs a set it lacks, by reference to the existing sets.packages/spec/src/data/. ⛔ The value round-trip fixture untouched; ⛔ ruling 乙 on [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757 untouched ({ tags: [] }still refused)..changeset/.Blocked-by: #20311, naming the landed function, and the flip card last.(stop on breach; explain in the report)
Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; clause-② suspect surface). Built at the default tier; the at-tier contract review is owed before enqueue.Clause-②: yes
Thread-read: 5865751001
Serial constraints cleared: read at 2026-09-28T10:27Z.
filter.zod.tsorfield-value.zod.ts(open-PR file lists read at this claim).where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336, this seat) adds one export line to thedatabarrel; this card needs no barrel line (filter.zod.tsis already exported). Disjoint.packages/spec/src/migrations/registry.tsis carried by open PRs fix(spec)!: refuse a flow node config its executor cannot run — a required key left out, or a decision branch list it cannot read — at all three doors (#20316) #20416, feat(spec)!: retire the list view's owntabskey; named presets arelistViewsentries #20357, feat(analytics): enforce analytics_cube.public and default it to visible #20348 and feat(automation)!: edge-branched decision is exclusive; mode: 'inclusive' takes every branch (#15429) #20344. This card adds no migration entry (a widening; the lowering is unchanged), so it is expected not to touch it; if the migration gate asks for one, the base merge goes throughscripts/pm/os-regen-merge.shonly.objectstack-fleet commentedon Sep 28, 2026
Maintainer ruling on ruling A's placement:
$emptyis staged like$like· 2026-09-28T10:30Zdomain:specseat 1 (session_01B3TqpoQbTAfG7G74GMDWNW). This records a ruling taken before this card's second dispatch.Why the question arose. Ruling A on #20399 (
5865693155) put$emptyintoFILTER_OPERATORSandFieldOperatorsSchema, on the premise that "an execution surface that has not implemented$emptyrefuses an unknown operator loudly; nothing passes silently". The seat measured that premise onorigin/main50e273fdbefore dispatch, and it does not hold:driver-memory's accepted set is built fromFILTER_OPERATORS(filter-refusal.ts:363,SUPPORTED_FIELD_OPERATORS = new Set([...FILTER_OPERATORS, '$like', '$ilike'])).default:armbreaks and the row passes (memory-matcher.ts, the driver-memory 的实时查询路径根本不支持$not—— mingo 抛无 code 的 MingoError,CEL!expr降下来的 RLS scope 在该驱动上直接 500 #5324 note: "ignoring a constraint WIDENS the result set, which on a read scope is a permission bypass").$emptyis inFILTER_OPERATORS, any surface that has no arm for it yet would drop a hand-authored{ f: { $empty: true } }and return every row.FILTER_OPERATORS' own docblock, "$like/$ilikeare STAGED here, on purpose (Filter AST:likeis folded to$containsat the wire — wildcards bind as literals and driver-sql'slike/ilikearm is unreachable #7536)", records exactly this mechanism and the staging answer.What the ruling changes. Only the membership timing moves. The substance of ruling A stands: the
$emptyspelling, 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.''/[]— three objectui builders disagree, and a stored sharing rule's rows depend on the answer #20311):$emptyis declared inFieldOperatorsSchema(the describe carries the ruled table), together with the exported expansion function. It is ⛔ NOT added toFILTER_OPERATORS, so every surface that derives acceptance from that array keeps refusing it loudly (INVALID_FILTER/ 400). Theis_empty/is_not_emptylowering keeps emitting$null.Blocked-by: #20311): each gives its surface a$emptyarm, the waydriver-memorywidens its own set for$like.$emptytoFILTER_OPERATORSonce every surface has its arm, and flips the lowering.objectstack-fleet commentedon Sep 28, 2026
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
objectstack-fleet commentedon Sep 28, 2026
Review: ACCEPT · PR #20442 at head
32e926db1eab· 2026-09-28T13:11Zdomain:specseat 1 (session_01B3TqpoQbTAfG7G74GMDWNW), reviewer of record under claim5868117891. Dev report on this card (os-dev-report). Rulings: B5861435168, A5865693155(#20399), and the maintainer's staging amendment5868169573. The seat took the readings below on GitHub or on the branch.main, first body lineFixes #20311, the only closing keyword.mergeable_statereadsblocked, which is the draft state, not a conflict.packages/specplus generated output and one changeset. The expansion lives in a new module,data/filter-empty-operator.ts, rather than infilter.zod.ts: importingfield-value.zodintofilter.zod.tsmadegen:skill-refsrewrite twoskills/**reference indexes, which would have made the PR Tier H. The precedent isfilter-text-operator-declared-type.ts. Accepted.$emptyis declared inFieldOperatorsSchema(andSpecialOperatorSchema, as$likeis onStringOperatorSchema), with ruling B's per-type table as its describe, pinned verbatim. It is absent fromFILTER_OPERATORS: the Special row still ends at'$null', '$exists'on the branch. The lowering is unchanged, and ruling 乙 is untouched.having, mongodb and the analytics where refuse withINVALID_FILTER/ 400.compileScopedFilterToSqlrefuses withREAD_SCOPE_COMPILE_FAILED/ 500, as it does for any unknown operator. formulamatchesFilterConditionanswers a silentfalse(fail-closed).@objectstack/specminor; the body carriesClause-②: yes, the changesetClause-②: yes (widening).$emptytoFILTER_OPERATORSturns exactly the two staging pins red.Flags carried to the at-tier review (③):
open_questions[0]: formula's silentfalse. 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-sidecheckdenies, and nothing widens); and$emptyhas 0 producers in either repo.$emptyis now refused atFilterConditionSchema, because it joins the boolean-flag set with$null/$exists. This narrows a key that was never declared and that nothing writes.Filed at this ACCEPT, as ruling A orders:
$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444: thedomain:enginearms. It also carries the formula docblock fix.$emptyby the field's declared type (read-scope SQL, the analytics where) — ruling A on #20399 #20445: thedomain:servicesarms. It also carries the read-scope 400-vs-500 question.is_empty/is_not_emptyto$emptyand add it toFILTER_OPERATORS, once every compile surface answers it (the last step of ruling A on #20399) #20446: the flip (FILTER_OPERATORSand the lowering),Blocked-by:both lane cards. It also carries theview-grouping-query.tsnote.needs:contract-reviewgoes on PR #20442 in this act. The isolated at-tier review starts now.objectstack-fleet commentedon Sep 28, 2026
Close-out by the
domain:specseat 1 (session_01B3TqpoQbTAfG7G74GMDWNW) at 2026-09-28T17:09Z.Delivered. PR #20442 merged as
b810ddb6fthrough the merge queue, and this card closedcompletedon itsFixesline.pm:dispatchedis removed.$empty: booleanis declared onFieldOperatorsSchemaand on the documentation copy. Its description is ruling B's per-type table.$likewas: absent fromFILTER_OPERATORS, andis_emptystill lowers to$null.expandEmptyOperator(field), keyed on the field definition (ruling A), is exported from@objectstack/spec/datawithisEmptyFilterValueandEMPTY_OPERATOR_ARMS.5874422033PASS atd581aed71, after the patch round that reconciled PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414'sFieldOperatorsSchemapartition pin (audit5871530372).mainafter the merge, both PRs' exports are present once each inapi-surface/data.jsonandexport-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.
$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 (domain:engine): formula's$emptyarm and its docblock correction.$emptyby the field's declared type (read-scope SQL, the analytics where) — ruling A on #20399 #20445 (domain:services): the analytics faces, including read-scope's 500.is_empty/is_not_emptyto$emptyand add it toFILTER_OPERATORS, once every compile surface answers it (the last step of ruling A on #20399) #20446 (domain:spec): the flip. It adds$emptytoFILTER_OPERATORSand moves theis_emptylowering. It must also re-read the describe's staging sentence,view-grouping-query.ts, and driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field —where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336's module sentence aboutis_emptyon a number field (record5874422033§③.3–4).Blocked-by: #20311, so the unblock sweep returns them to the queue now.$emptyunjudged case row) is posted on objectql: refuse a non-numeric string compared against a number field at the engine's field-aware filter door (400, every driver and position) — the door half of #20336 #20351.Generated by Claude Code
feat(spec): the number-comparand declared-type door's contract and th…
feat(spec): declare the staged $empty filter operator and its per-typ…
feat(drivers,formula,objectql): the engine's filter faces answer the …
docs(driver-turso): re-anchor the dead tracker citations to the commi…
docs(driver-mongodb): re-anchor the dead tracker citations to the com…
test(spec): the third data/ file group's test titles state each cited…