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.
Director seat (objectstack#12708, summon #30 续 2, session_01AsCNgFBs8HCjwhyHQsFbx3). The maintainer's word on #20399: 「同意」 to A. This card's Blocked-by: #20399 is resolved by that ruling; the body line is replaced in the same stroke.
Re-claim on the dev's kept branch as ruling B here plus ruling A there: declare $empty: boolean in FILTER_OPERATORS and FieldOperatorsSchema with the ruled per-type table as its describe; one expansion function keyed on the field definition (type plus multiple); the is_empty / is_not_empty lowering keeps emitting $null until the flip card lands; ruling 乙 on #19757 untouched ({ tags: [] } still refused). Clause-②: yes (widening), minor. Then file one sibling card per compile-surface lane, each Blocked-by: this card's spec PR, and the flip card last. Full parameters and pins are on the ruling.
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 b45d463: 0 stored rules; the 22 emptiness hits in sharing-rule-related files are builder code and tests. No lowering changes, so no rule changes result. Production NOT MEASURED.",
"dollar_empty_corpus": "$empty occurrences before this change: objectstack 0, objectui 0"
},
"tests": "All at head 32e926d unless noted. (1) spec full local project: vitest run --project local, 563 files, 16536 passed, 1 todo, exit 0. (2) pnpm --filter @objectstack/spec typecheck: exit 0 (tsc, scripts typecheck and test typecheck). (3) Targeted spec filter suites (10 files incl. filter-empty-operator, vocabulary, comparand-type, save-door parity, filter, comparand-shape, equality/ne array doors, view-operator parity, page-component conversion): 675 passed, 1 todo. (4) Consumer filter/operator suites, downstream direction (consumers of @objectstack/spec), after rebuilding spec and each upstream closure from this branch: formula 16 files/570 passed; driver-memory 10/392; driver-mongodb 6+1 skipped/219 passed, 38 skipped; objectql (filter/operator/having) 16/624; driver-sql 14/331 passed, 4 skipped; driver-turso 4/170; metadata-protocol 4/51; lint 2/76; service-analytics (filter/operator/read-scope) 42/951; rest 6/109; plugin-sharing (criteria/sharing-rule) 6/146. All exit 0. (5) Ablation via scripts/ablation-replace.mjs on the committed tree, adding $empty to FILTER_OPERATORS: anchor 1 to 0, blob 0912c2776e5e to 74b0e4d23fe0. Exactly the two staging pins went red (2 failed, 23 passed; the expected direction). Restored: blob == HEAD and git diff HEAD empty. The spec tests import src relatively, so no dist is involved. A second ablation (dropping $empty from the save-door flag set) is NOT MEASURED: the verify lock queue timed out (exit 99). (6) eslint --no-inline-config --format json over the 9 changed TS files: 9 files, 0 errors, 0 warnings. Population is the eslint.config.mjs all-ts block; that config never enables type-aware linting, so untouched files cannot move. Full pnpm lint is CI's.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 32e926d derived 108 commands (a superset of the dispatch list's 72). All 108 were run with exit codes recorded; --ran reconciled with exit 0: 106 run at exit 0, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED are check:dual-build-cjs-loads and check:type-check-debt, exit 3 PREREQUISITE NOT MET: both need every package built, and CI runs them. check:skill-examples exited 3 until client-react was built, then 0. spec check:generated: all current. check:adr-0087-registration: 0 (no declared-breaking changeset). check:changeset-no-major: 0. Derivation warning: the tree was 7 commits behind origin/main dbddf02; one derivation input changed there (scripts/docs-audit/handwritten-docs.json). None of those commits touch this diff's files, and the branch was not re-merged.",
"line_budget": "582 changed lines (+575 / -7) in 14 files, under the 5000 human-merge line; no governed surface (no skills/, .claude/, docs/adr/**) in the diff",
"files_changed": [
".changeset/20311-empty-filter-operator-staged.md",
"content/docs/references/data/filter.mdx (generated by gen:docs)",
"packages/spec/api-surface/data.json (generated)",
"packages/spec/authorable-surface/data.json (generated by the build)",
"packages/spec/export-origins/data.json (generated)",
"packages/spec/src/data/filter.zod.ts",
"packages/spec/src/data/filter-empty-operator.ts (new)",
"packages/spec/src/data/filter-empty-operator.test.ts (new)",
"packages/spec/src/data/index.ts (one export line)",
"packages/spec/src/data/filter-comparand-type.ts",
"packages/spec/src/data/filter-comparand-type.test.ts",
"packages/spec/src/data/filter-save-door-refusals.ts",
"packages/spec/src/data/filter-save-door-face-parity.test.ts",
"packages/spec/src/data/filter-operator-vocabulary.test.ts"
],
"deviations": [
"File surface grew inside packages/spec. The expansion sits in a new module, filter-empty-operator.ts, with one line in the data barrel, and not in filter.zod.ts. Reason: importing field-value.zod into filter.zod.ts made gen:skill-refs rewrite skills/objectstack-query and skills/objectstack-api reference indexes, which would have made the PR Tier H; the two files also meet in the field.zod import cycle. The precedent is filter-text-operator-declared-type.ts. The barrel line sits after that module's export and is disjoint from open PR 20414's hunk.",
"filter-comparand-type.ts and filter-save-door-refusals.ts (plus their pins) were edited to keep the FieldOperatorsSchema-key-derived reconciliation green (A2).",
"Consequence of the save-door flag set: a NON-boolean $empty is now refused at FilterConditionSchema, as $null and $exists are. Strictly this narrows the save door for a key that was never declared and that nothing writes (0 occurrences in either repo). It is declared Clause-② yes (widening) per ruling A and stated in the changeset.",
"$empty is also declared on SpecialOperatorSchema (the documentation copy), as $like is on StringOperatorSchema; the amendment names only FieldOperatorsSchema.",
"The description string is pinned verbatim. The role file says not to pin prose, but ruling A demands exactly this pin, and the ruling governs.",
"The PR body carries Clause-② yes, copied from the claim; the changeset carries Clause-② yes (widening).",
"A status reply went to the seat mid-run, via SendMessage to main."
],
"mcp_calls": "0",
"api_writes": "3, each sent as one fleet-write relay stroke (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): pr_create = POST /repos/objectstack-ai/objectstack/pulls (PR 20442, draft); assign = POST /repos//issues/20442/assignees (hotlong, via scripts/pm/label-write.mjs; read-back matched, no labels written); comment = POST /repos//issues/20311/comments (this report, via scripts/pm/post-stamped.mjs). git push is not a REST write.",
"open_questions": [
{
"question": "Should PR 20442 land before formula's lane card? Once $empty is declared, formula's matchesFilterCondition gives it the silent fail-closed false that formula's own docblock says a DECLARED operator must never get (the $like precedent: formula got its arm in the declaring PR). Every other face refuses loudly.",
"options": [
"A — land as is. The formula lane card gives the arm (by value, isEmptyFilterValue(value)) and corrects the docblock. The window is fail-closed: a write-side check denies, nothing widens. The description and changeset tell authors not to use $empty yet.",
"B — hold 20442's merge until the formula lane card is ready, and land the two together.",
"C — widen this card to add formula's arm now. This breaches the order's file bound and the amendment's placement of arms in lane cards, so it needs a ruling."
],
"recommendation": "A. Long-term: the maintainer ruled the arms into lane cards, and the staging keeps every surface from widening. Real need: $empty has zero producers today, so the window reaches no one. AI-error: the description says staged, and the query faces refuse loudly. Startup scope: A adds no extra sequencing. B is the safe alternative if the seat wants the formula docblock claim never false on main."
}
],
"out_of_scope_findings": [
"carrier: formula's $empty lane card (domain:engine, filed by the seat at ACCEPT) · packages/formula/src/matches-filter.ts header claims the silent false is reachable only for a name the protocol does not declare; with $empty declared that is false (measured: false for $empty true and false alike) · noted, not filed",
"carrier: service-analytics' lane card (domain:services) · read-scope-sql compileScopedFilterToSql refuses every unknown operator (also $bogus) with READ_SCOPE_COMPILE_FAILED 500, where the other faces answer INVALID_FILTER 400; loud and fail-closed, pre-existing · noted, not filed",
"carrier: the flip card · packages/spec/src/ui/view-grouping-query.ts says the empty-group predicate and the view filter's is_empty agree on what empty means; true while the lowering stays $null, false once it flips · noted, not filed"
]
}
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
Unblocked: #20399 ruled A (batch #234 item 1,
5865693155) ·pm:blocked→pm:queue· 2026-09-28T07:51ZDirector seat (objectstack#12708, summon #30 续 2,
session_01AsCNgFBs8HCjwhyHQsFbx3). The maintainer's word on #20399: 「同意」 to A. This card'sBlocked-by: #20399is resolved by that ruling; the body line is replaced in the same stroke.Re-claim on the dev's kept branch as ruling B here plus ruling A there: declare
$empty: booleaninFILTER_OPERATORSandFieldOperatorsSchemawith the ruled per-type table as its describe; one expansion function keyed on the field definition (type plusmultiple); theis_empty/is_not_emptylowering keeps emitting$nulluntil the flip card lands; ruling 乙 on #19757 untouched ({ tags: [] }still refused).Clause-②: yes (widening),minor. Then file one sibling card per compile-surface lane, eachBlocked-by:this card's spec PR, and the flip card last. Full parameters and pins are on the ruling.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 b45d463: 0 stored rules; the 22 emptiness hits in sharing-rule-related files are builder code and tests. No lowering changes, so no rule changes result. Production NOT MEASURED.",
"dollar_empty_corpus": "$empty occurrences before this change: objectstack 0, objectui 0"
},
"tests": "All at head 32e926d unless noted. (1) spec full local project: vitest run --project local, 563 files, 16536 passed, 1 todo, exit 0. (2) pnpm --filter @objectstack/spec typecheck: exit 0 (tsc, scripts typecheck and test typecheck). (3) Targeted spec filter suites (10 files incl. filter-empty-operator, vocabulary, comparand-type, save-door parity, filter, comparand-shape, equality/ne array doors, view-operator parity, page-component conversion): 675 passed, 1 todo. (4) Consumer filter/operator suites, downstream direction (consumers of @objectstack/spec), after rebuilding spec and each upstream closure from this branch: formula 16 files/570 passed; driver-memory 10/392; driver-mongodb 6+1 skipped/219 passed, 38 skipped; objectql (filter/operator/having) 16/624; driver-sql 14/331 passed, 4 skipped; driver-turso 4/170; metadata-protocol 4/51; lint 2/76; service-analytics (filter/operator/read-scope) 42/951; rest 6/109; plugin-sharing (criteria/sharing-rule) 6/146. All exit 0. (5) Ablation via scripts/ablation-replace.mjs on the committed tree, adding $empty to FILTER_OPERATORS: anchor 1 to 0, blob 0912c2776e5e to 74b0e4d23fe0. Exactly the two staging pins went red (2 failed, 23 passed; the expected direction). Restored: blob == HEAD and git diff HEAD empty. The spec tests import src relatively, so no dist is involved. A second ablation (dropping $empty from the save-door flag set) is NOT MEASURED: the verify lock queue timed out (exit 99). (6) eslint --no-inline-config --format json over the 9 changed TS files: 9 files, 0 errors, 0 warnings. Population is the eslint.config.mjs all-ts block; that config never enables type-aware linting, so untouched files cannot move. Full pnpm lint is CI's.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 32e926d derived 108 commands (a superset of the dispatch list's 72). All 108 were run with exit codes recorded; --ran reconciled with exit 0: 106 run at exit 0, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED are check:dual-build-cjs-loads and check:type-check-debt, exit 3 PREREQUISITE NOT MET: both need every package built, and CI runs them. check:skill-examples exited 3 until client-react was built, then 0. spec check:generated: all current. check:adr-0087-registration: 0 (no declared-breaking changeset). check:changeset-no-major: 0. Derivation warning: the tree was 7 commits behind origin/main dbddf02; one derivation input changed there (scripts/docs-audit/handwritten-docs.json). None of those commits touch this diff's files, and the branch was not re-merged.",
"line_budget": "582 changed lines (+575 / -7) in 14 files, under the 5000 human-merge line; no governed surface (no skills/, .claude/, docs/adr/**) in the diff",
"files_changed": [
".changeset/20311-empty-filter-operator-staged.md",
"content/docs/references/data/filter.mdx (generated by gen:docs)",
"packages/spec/api-surface/data.json (generated)",
"packages/spec/authorable-surface/data.json (generated by the build)",
"packages/spec/export-origins/data.json (generated)",
"packages/spec/src/data/filter.zod.ts",
"packages/spec/src/data/filter-empty-operator.ts (new)",
"packages/spec/src/data/filter-empty-operator.test.ts (new)",
"packages/spec/src/data/index.ts (one export line)",
"packages/spec/src/data/filter-comparand-type.ts",
"packages/spec/src/data/filter-comparand-type.test.ts",
"packages/spec/src/data/filter-save-door-refusals.ts",
"packages/spec/src/data/filter-save-door-face-parity.test.ts",
"packages/spec/src/data/filter-operator-vocabulary.test.ts"
],
"deviations": [
"File surface grew inside packages/spec. The expansion sits in a new module, filter-empty-operator.ts, with one line in the data barrel, and not in filter.zod.ts. Reason: importing field-value.zod into filter.zod.ts made gen:skill-refs rewrite skills/objectstack-query and skills/objectstack-api reference indexes, which would have made the PR Tier H; the two files also meet in the field.zod import cycle. The precedent is filter-text-operator-declared-type.ts. The barrel line sits after that module's export and is disjoint from open PR 20414's hunk.",
"filter-comparand-type.ts and filter-save-door-refusals.ts (plus their pins) were edited to keep the FieldOperatorsSchema-key-derived reconciliation green (A2).",
"Consequence of the save-door flag set: a NON-boolean $empty is now refused at FilterConditionSchema, as $null and $exists are. Strictly this narrows the save door for a key that was never declared and that nothing writes (0 occurrences in either repo). It is declared Clause-② yes (widening) per ruling A and stated in the changeset.",
"$empty is also declared on SpecialOperatorSchema (the documentation copy), as $like is on StringOperatorSchema; the amendment names only FieldOperatorsSchema.",
"The description string is pinned verbatim. The role file says not to pin prose, but ruling A demands exactly this pin, and the ruling governs.",
"The PR body carries Clause-② yes, copied from the claim; the changeset carries Clause-② yes (widening).",
"A status reply went to the seat mid-run, via SendMessage to main."
],
"mcp_calls": "0",
"api_writes": "3, each sent as one fleet-write relay stroke (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): pr_create = POST /repos/objectstack-ai/objectstack/pulls (PR 20442, draft); assign = POST /repos//issues/20442/assignees (hotlong, via scripts/pm/label-write.mjs; read-back matched, no labels written); comment = POST /repos//issues/20311/comments (this report, via scripts/pm/post-stamped.mjs). git push is not a REST write.",
"open_questions": [
{
"question": "Should PR 20442 land before formula's lane card? Once $empty is declared, formula's matchesFilterCondition gives it the silent fail-closed false that formula's own docblock says a DECLARED operator must never get (the $like precedent: formula got its arm in the declaring PR). Every other face refuses loudly.",
"options": [
"A — land as is. The formula lane card gives the arm (by value, isEmptyFilterValue(value)) and corrects the docblock. The window is fail-closed: a write-side check denies, nothing widens. The description and changeset tell authors not to use $empty yet.",
"B — hold 20442's merge until the formula lane card is ready, and land the two together.",
"C — widen this card to add formula's arm now. This breaches the order's file bound and the amendment's placement of arms in lane cards, so it needs a ruling."
],
"recommendation": "A. Long-term: the maintainer ruled the arms into lane cards, and the staging keeps every surface from widening. Real need: $empty has zero producers today, so the window reaches no one. AI-error: the description says staged, and the query faces refuse loudly. Startup scope: A adds no extra sequencing. B is the safe alternative if the seat wants the formula docblock claim never false on main."
}
],
"out_of_scope_findings": [
"carrier: formula's $empty lane card (domain:engine, filed by the seat at ACCEPT) · packages/formula/src/matches-filter.ts header claims the silent false is reachable only for a name the protocol does not declare; with $empty declared that is false (measured: false for $empty true and false alike) · noted, not filed",
"carrier: service-analytics' lane card (domain:services) · read-scope-sql compileScopedFilterToSql refuses every unknown operator (also $bogus) with READ_SCOPE_COMPILE_FAILED 500, where the other faces answer INVALID_FILTER 400; loud and fail-closed, pre-existing · noted, not filed",
"carrier: the flip card · packages/spec/src/ui/view-grouping-query.ts says the empty-group predicate and the view filter's is_empty agree on what empty means; true while the lowering stays $null, false once it flips · noted, not filed"
]
}
Generated by Claude Code
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…