feat(spec): declare the staged $empty filter operator and its per-type expansion (#20311) - #20442
Conversation
…pe expansion Declares `$empty: boolean` in FieldOperatorsSchema and SpecialOperatorSchema, its description carrying the ruled per-type table, plus the exported expandEmptyOperator / isEmptyFilterValue / EMPTY_OPERATOR_ARMS. Staged like $like: absent from FILTER_OPERATORS; the is_empty lowering still emits $null. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
… changeset Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
filter.zod.ts takes no import from field-value.zod: the two meet in the field.zod import cycle, and filter.zod.ts' import closure is what the published query and api skill reference indexes walk. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…ference for $empty Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check20 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 28f379d6550d53906ab8730cd2cc3bc47e9f4672 && git checkout 28f379d6550d53906ab8730cd2cc3bc47e9f4672
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fbeb56e4b5e4f7b33336926353bcd0c77147ad7f d581aed71f8b00f1f8df25a5d8f178e5029d1a82 && git checkout -B drift-repro fbeb56e4b5e4f7b33336926353bcd0c77147ad7f && git merge --no-ff d581aed71f8b00f1f8df25a5d8f178e5029d1a82
node scripts/docs-audit/affected-docs.mjs --json fbeb56e4b5e4f7b33336926353bcd0c77147ad7f |
Contract reviewServed-tier: Inputs read: card #20311 (body; ruling B Check-runs on this head, read at 2026-09-28T13:31Z: every gate-carrying context is ① Derived judgments
② Semver level
The one incidental narrowing is confined to a never-declared spelling: a NON-boolean Clause-②: yes (widening) — the changeset carries exactly that line. The PR body carries ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Pulled from the merge queue by the Signature. In the merge group Why this is not a flake, and whose it is. It is a semantic conflict between two PRs that are each green alone, and it is deterministic. This PR is the later lander, so the reconciliation is this PR's. Fix (patch round, after PR #20414 lands).
Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36431602315 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36434107738 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…-empty-per-type-lowering
…ference over the merged tree Both sides' exports: #20336's number-comparand door and this branch's $empty expansion; filter.mdx carries main's frontmatter and the $empty rows. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…and door's partition The #20336 pin partitions FieldOperatorsSchema's keys into the judged positions, the text operators and the boolean flags; $empty is a flag (a boolean, not a value of the field), so it joins $null / $exists there, in the module's "Not judged" docblock and as an unjudged case row. The door's verdict logic is unchanged. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: This is the patch-round record. It re-judges the whole net diff on this head, not only the three patch-round commits. Inputs read: card #20311 (body; ruling B Check-runs on this head, read at 2026-09-28T16:40Z: 42 runs, all completed, 38 ① Derived judgments
② Semver level
The one incidental narrowing stays confined to a never-declared spelling: a NON-boolean Clause-② — the changeset carries ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…s declared type (objectstack-ai#20445) (objectstack-ai#20498) Fixes objectstack-ai#20445 Clause-②: yes (widening) The `domain:services` lane's arms for the `$empty` operator, under ruling A on objectstack-ai#20399 (`5865693155`). Both service-analytics filter faces now answer `{ f: { $empty: true | false } }` by the field's DECLARED row of the ruled per-type table. They reach it through the spec's one expansion, `expandEmptyOperator(fieldDef)` from `@objectstack/spec/data` (PR objectstack-ai#20442), and keep no copy of the table: | declared row | `$empty: true` matches | `$empty: false` | |---|---|---| | text-like | null or `''` | the complement | | multi-value (incl. `multiple: true` on a multi-capable type) | null or `[]` | the complement | | every other type | null only | the complement | The staging does not move (「照 $like 先例分阶段」): `$empty` is **not** added to `FILTER_OPERATORS`, and the `is_empty` / `is_not_empty` lowering still emits `$null`. There is no `$eq: []` comparand anywhere (ruling 乙 on objectstack-ai#19757 stands). ## What changed - **`empty-operator-sql.ts` (new).** The SQL for one `$empty` predicate per declared row. Null-only: `col IS NULL`. Text: `(col IS NULL OR col = '')`, with the empty string bound. Multi-value: `(col IS NULL OR L)`, where `L` is the dialect's empty-JSON-list test: SQLite `json_valid` guard inside a `CASE`, then `json_type(col) = 'array' AND json_array_length(col) = 0`; Postgres a `jsonb` equality with `'[]'`; MySQL `JSON_TYPE` = `'ARRAY'` and `JSON_LENGTH` = 0. `$empty: false` is the exact complement of each. Every predicate is TOTAL (never UNKNOWN), so a `$not` over `$empty` needs no NULL guard. - **Read-scope face (`compileScopedFilterToSql`).** A new `$empty` arm in `compileOperator`. It asks the caller for the field's declaration (new optional `declaredValueShape` option; both callers pass it from the context), calls `expandEmptyOperator`, and compiles the row. The flag joins the existing boolean-domain gate with `$null` / `$exists`. Both NULL-polarity tables gain the row (null satisfies `$empty: true`; the arm is total). - **`where` face (`lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree`).** `fieldLeaves` stops refusing `$empty` and lowers it to a valueless `empty` / `notEmpty` leaf. The normalizer sees no field declaration, and the multi-value row cannot be spelled in the lowered vocabulary, so the row is resolved by each consumer of the tree: - `NativeSQLStrategy.buildFilterClause` (the executed statement) and the `ObjectQLStrategy` echo both call `whereEmptyLeafSql`: the host's declared shape, then the spec's expansion, then the row's SQL. - `ObjectQLStrategy.convertFilter` (the engine path) hands `{ $empty }` to the engine as written. Its arm is the engine lane's (objectstack-ai#20444). - The flag joins `assertBooleanNullFlags` with `$null` / `$exists`. Both polarity tables gain the row. - **Where the declaration comes from.** A new context hook, `DatasetScopedStrategyContext.declaredValueShape(object, field)`. `AnalyticsService` answers it from the existing `sourceFieldMeta` hook (`type`, and now `multiple`). `AnalyticsServicePlugin` relays `multiple` from the field definition. **The field's declaration is never guessed.** A face that cannot name it refuses, before anything binds. On the `where` face that is `INVALID_FILTER` / 400; on the read-scope face it is `READ_SCOPE_COMPILE_FAILED` / 500. The same holds for a multi-value field on the `'unknown'` dialect, where no JSON test parses everywhere; the text and null-only rows need no dialect. Why a guess is not possible: the "no declaration" reading the spec gives the JS faces (null, `''` and `[]` all empty) has no SQL form without the type. `amount = ''` is a type error on Postgres, and an empty list is only recognisable as JSON. Both refusals are pinned with `code` + `status`. ## The question the card asked: should the read-scope face answer an unknown operator with 400? **No. It keeps `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld, and this PR says so in code at `compileOperator`'s `default:` arm.** The triage reading ("an authoring mistake answered as a server error is the wrong class") assumes the caller authored the input. Measured, the caller does not: - **Who writes what reaches `compileScopedFilterToSql`.** Its only two in-package callers are `NativeSQLStrategy.applyReadScope` and the `ObjectQLStrategy.generateSql` echo. Both pass `ctx.getReadScope(object)`. `AnalyticsServicePlugin` answers that hook either from the `security` service's `getReadFilter`, which compiles admin-authored sharing rules and permission sets, or from the host's own `getReadScope` plugin option. The analytics caller's own filter takes the other road, `filter-normalizer.ts`, and that road answers `INVALID_FILTER` / 400 for an unknown operator. - **The ruling already on file.** The read-scope module header records the objectstack-ai#5367 maintainer ruling (2026-08-06, re-affirmed as objectstack-ai#7598 Q2 = A). A read-scope refusal is a server fault: a 400 "told them to fix a request that was never the problem, and hid the fault from the 5xx alerting". A 4xx body also relayed "THE FIELD NAMES AND COMPARANDS OF THE RLS POLICY". The header states that the envelope "is not to be rewritten". objectstack-ai#19995 (`60fdaa9e`, PR objectstack-ai#20072) extended the same withheld 500 to the ObjectQL engine door for exactly that disclosure reason. - Pinned: `$bogus` in a read scope still answers `READ_SCOPE_COMPILE_FAILED` / 500 (`read-scope-empty-operator.test.ts`, last case). The existing envelope suites stay green unchanged. ## Filter-semantics compile-surface declaration Roster re-grepped on `fc0db22b` (`grep -rn 'matchesFilterCondition\|buildWhereSQL\|compileScopedFilterToSql' packages --include=*.ts`). | # | face | conclusion | |---|---|---| | 1 | `driver-sql` `applyFilterCondition` (and its `extends SqlDriver` heirs) | **out of scope**: sibling objectstack-ai#20444 (`domain:engine`). Measured today: it refuses `$empty` with `INVALID_FILTER` / 400, operator and field withheld. Untouched here. | | 2 | turso `RemoteTransport` `buildWhereSQL` | **out of scope**: sibling objectstack-ai#20444. Untouched. | | 3 | service-analytics `read-scope-sql` `compileScopedFilterToSql` | **changed**: the `$empty` arm above, and the boolean gate. | | 4 | service-analytics `filter-normalizer` `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree` | **changed**: the `empty` / `notEmpty` leaf, answered by NativeSQL and the ObjectQL echo, and handed to the engine by ObjectQL execute. | | 5 | `formula` `matchesFilterCondition` | **out of scope**: sibling objectstack-ai#20444. Untouched. | | half-face | objectql `having-filter` (`applyHaving` / `matchesHaving`) | **out of scope**: sibling objectstack-ai#20444. Untouched. | | unfrozen | `driver-memory` `checkCondition`, `driver-mongodb` `translateFieldOperators` | **out of scope**: sibling objectstack-ai#20444. Untouched. | Two package-local consumers of face 4's tree, named so they don't read as missed: - **ObjectQL execute.** It hands `{ $empty }` to the engine. Until objectstack-ai#20444's `driver-sql` arm lands, the engine refuses it, so this query is refused on this strategy, while the echo prints the declared arm and the native statement answers it. No face drops it. - **Draft preview (`preview-evaluator.ts`).** Unchanged. It already refuses `$empty` (`INVALID_FILTER` / 400) with its other unevaluated operators, `$null` among them. ## Evidence (HEAD `6a07f8cc`; the suite ran at `20994c10`, and HEAD adds only the changeset on top of it) - **Premise, re-measured on `fc0db22b` before editing.** `lowerAnalyticsWhere` passed `{ f: { $empty: true } }` through, and `normalizeAnalyticsFilterTree` refused it `INVALID_FILTER` / 400. `compileScopedFilterToSql` refused it `READ_SCOPE_COMPILE_FAILED` / 500, and `$bogus` got the same answer. `git grep -c '$empty'` over `service-analytics/src` read 0 hits; the control word `$null` read 10+ files. - **New pins.** - `read-scope-empty-operator.test.ts`: 31 tests, executed on `sql.js`. - `where-empty-operator.test.ts`: 27 tests. NativeSQL executes on `sql.js`, the ObjectQL echo runs on the same database and must return the same rows, and the condition handed to the engine is pinned. - Fixture: a text, a `tags`, a `lookup` with `multiple: true`, a `select` and a `number` field. Rows: null, `''`, `[]`, a non-list JSON value, and a value. - Covered: `$empty: false` as the complement; nesting under `$and` / `$or` / `$not`; beside another operator on the same field. Refusals assert `code` + `status`. - Postgres / MySQL SQL strings are pinned as compiled, **NOT MEASURED** as executed: there is no live server here. - **Package suite.** `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2`: `Test Files 134 passed (134)` · `Tests 3151 passed (3151)`. - **Typecheck.** `pnpm --filter @objectstack/service-analytics exec tsc --noEmit --listFiles` exits 0, and its file list contains all three new files. - **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 62 commands; all 62 ran. `--ran` reconciliation: "62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED". - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Both exit 3 with `PREREQUISITE NOT MET`, because they need the whole workspace built. Narrowed probe instead: this package's built `dist/index.cjs` and `dist/index.js` both load and export `compileScopedFilterToSql`. - **Lint, narrowed.** - Population: `eslint.config.mjs` lints `packages/**/*.{ts,tsx,mts,cts}` with no type-aware parsing (no `parserOptions.project`). A verdict on an untouched file therefore cannot move with this diff. - `pnpm exec eslint --no-inline-config --format json` over the 10 changed `.ts` files: the JSON has 10 file entries, 0 errors, 0 warnings. ### Ablation: the negative pins can fail Committed first; each leg ran through `scripts/ablation-replace.mjs`, which applies the mutation, runs, and restores. Restore was proven blob == HEAD (`05d539c76470`) with `git diff HEAD` empty. - **A1: the null-only row counts `''`.** The `null_only` arm falls through to the text arm. **9 red across both files**, including "the null-only row does NOT count the empty string": `AssertionError: expected [ 'n', 's' ] to not include 's'`. - **A2: `$empty: false` stops being the complement.** The text arm's false branch becomes an OR. **7 red**, including "name: $empty: false is the exact complement": `expected [ 'l', 'o', 's', 'v' ] to deeply equal [ 'l', 'o', 'v' ]`. ## Acceptance notes (observations, not filed) - **The spec's staging prose goes stale here.** The `FILTER_OPERATORS` TSDoc table in `packages/spec/src/data/filter.zod.ts` still lists both service-analytics rows as REFUSES. Carrier: the flip card, which rewrites that table. No `packages/spec` edit here. - **Shared conformance cases belong in the spec.** A shared `$empty` conformance table (per-type rows × stored states, the way `FILTER_LOGIC_CASES` works) would let every face run one standard. That is the spec lane's to add, with the flip card, and is not added here. - **The flip card will need `$empty` rows** in `objectql-echo-operator-coverage.test.ts` (`OPERATOR_CASES`) and `objectql-icontains-arm.test.ts` (`SAMPLES`). Both assert their tables equal `FILTER_OPERATORS`, so they go red on the flip until the rows exist. - **A read scope carrying `$empty` on the ObjectQL execute face** is refused by the engine's driver as `INVALID_FILTER` / 400 with the operator and field withheld, not the read-scope 500. `judgeFilter` admits the operator because it stops before the driver. This predates the PR and closes when objectstack-ai#20444 lands the driver arm. No in-repo producer emits `$empty` in a read scope (the CEL lowering's `is_empty` emits `$null`). ## Seat append (`domain:services` seat objectstack-ai#6021, `session_017B6YKCGu8CTY2KBWgwaHAs`) - The `Clause-②` line changed from `no` to `yes (widening)`, per contract review FAIL `5876996555`. The PR adds two published members, `multiple?` on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape and `declaredValueShape?` on `compileScopedFilterToSql`'s options. The changeset moves to `minor` in the patch round on this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… field at the engine's filter door, and narrow a numeric one (objectstack-ai#20351) (objectstack-ai#20501) Fixes objectstack-ai#20351 Clause-②: no (narrowing) ## What this adds Lane (2) of the two-lane route objectstack-ai#20336 took on objectstack-ai#15661's precedent: the engine door that consults the contract PR objectstack-ai#20414 published in `@objectstack/spec/data` (`filter-number-comparand-declared-type.ts`). The contract half is untouched; `packages/spec` is not in this diff. - **The door**, `packages/objectql/src/number-comparand-declared-type-door.ts`, beside the text-operator and temporal doors. For each comparand at a judged position on a declared numeric field it asks `numberComparandDoorVerdict` and routes the answer: - `door-refusal`: throws `INVALID_FILTER` / 400 (the existing `invalidFilterError` envelope) in the contract's words, `numberComparandRefusalMessage`, before any driver is resolved; - `narrows`: rewrites the numeric string to its number, copy-on-write (the caller's filter is never edited, and a filter with nothing to narrow comes back by reference); - `passes` / `deferred`: leaves it alone. The door reads no string itself. The grammar, the judged types (`NUMERIC_VALUE_TYPES` by identity), the judged operators (`NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS` / `NUMBER_COMPARAND_DOOR_LIST_OPERATORS`) and the words are all the spec's. - **Its calls in `engine.ts`, at the collection point only**, fifth after the temporal door in the same order everywhere: - `lowerWhereFilterArray`, object form (before `normalizeFilterComparandTypes`) and array form (on the lowered condition). So `find` / `findOne` / `count` / `aggregate` / `update` / `delete` and the judge-only `judgeFilter` (`judgeWhereAdmission` calls the same function) all inherit it; - each per-aggregation `filter`, rooted at `aggregations[i].filter`, against the object's declared fields; - `having`, after the temporal `having` door, over the columns `aggregatedRowColumnClasses` classes `numeric` (`count` / `sum` / `avg`, and a groupBy or `min` / `max` of a numeric field). The `judgeWhereAdmission` docblock's pipeline list names the new door (comment only). - **A changeset**, `.changeset/20351-number-comparand-door.md`: `@objectstack/objectql` `minor`, BREAKING, `Clause-②: no (narrowing)`, a FROM → TO line, and the ADR-0087 disposition `not-required (no-migration-prescription)` in the form PR objectstack-ai#20469 and PR objectstack-ai#20370 used. `@objectstack/objectql`'s root exports are unchanged: the door module is not re-exported from `index.ts` or `core.ts`, like its two siblings. ## What it does to the card's three answers Measured through `engine.find` / `engine.aggregate` and `POST /api/v1/data/:object/query`, three rows (5, 12, 30), on InMemoryDriver, SqlDriver on SQLite and SqlDriver on a local PostgreSQL 16.13 server: | position | comparand on a `number` field | base `3062e5001`: memory · SQLite · PostgreSQL | this branch, all three | |:--|:--|:--|:--| | `where` | `$gt` / `$eq` / implicit / a `$in` member `"abc"` | 200 no rows · 200 no rows · 500 `DATABASE_ERROR` | 400 `INVALID_FILTER` | | `where` | `$ne "abc"` | every row · every row · 500 | 400 | | `where` | `$eq ""` | no rows · no rows · 500 | 400 | | `where`, REST | `$gt "{current_user_id}"` (resolved to the user's id) | no rows · no rows · 500 | 400 | | per-aggregation `filter` | `$gt "abc"` / `$ne "abc"` | count 0 / count 3, on all three | 400 | | `having` on `sum(amount)` | `$gt "abc"` / `$ne "abc"` | no group / every group, on all three | 400 | | `where` | `$gt "12"` / `$eq "12"` | **no rows** · 1 row · 1 row | 1 row on all three | | all three positions | `$gt 10` (the numeric control) | 2 rows / count 2 / both groups | the same | The last-but-one row is the narrowing's point: InMemoryDriver compared `"12"` as a string and matched nothing. ## Premise check, and the order's hypotheses - **H1 holds, reproduced at `3062e5001`** (the table above). SqlDriver's server-side log line on PostgreSQL reads `(22P02) … invalid input syntax for type numeric: "abc"`. - **H2: the collection point is where the order says**, and the new door sits after the temporal door at each call. `judgeFilter` passes through it: `judgeWhereAdmission` calls `lowerWhereFilterArray` (pinned: `judgeFilter` answers `INVALID_FILTER` / 400 for `"abc"` and `{ ok: true }` for `"12"`). **RLS / sharing / tenant predicates do NOT pass through it at runtime.** The middleware chain composes them onto the AST after this seam, and `plugin-security`'s `judgeCompiledComparands` runs only the two field-agnostic faces (`rls-compiler.ts`, the `[objectstack-ai#20212]` block). A policy predicate reaches this door at authoring instead: `validateRlsPredicateEnforceability` asks the engine's `judgeFilter` when the host hands the rule a judge. - **H3 holds.** The verdict is `numberComparandDoorVerdict` over `NUMBER_COMPARAND_DOOR_JUDGED_TYPES` with the scalar and list operators, and the words are `numberComparandRefusalMessage`. A numeric string is **narrowed** to its number (the verdict's `narrows`, as the contract review's judgment 7 asks). The pins assert the rewritten filter the driver receives, not only the 400s. - **H4: MySQL is NOT MEASURED.** No MySQL server is available in this container. The REST suite carries a MySQL cell, a named skip without `OS_TEST_MYSQL_URL`. - **H5: neither consults the same verdict everywhere.** - `service-analytics`: the ObjectQL strategy sends the caller's `where` into `engine.aggregate` and asks `judgeFilter` about the read scope (`assertReadScopeAdmittedByEngine`), so both inherit the door. The **NativeSQL strategy's decline** (`NativeSQLStrategy.canHandle`) declines a cross-field reference and an uninterpretable temporal comparand, but does not consult the number verdict. So a raw-SQL deployment compiles `amount > 'abc'` itself (read at source, not measured). - **The metadata save door:** RLS `using` is judged through `judgeFilter`, as above. No lint rule reads `numberComparandDoorVerdict` (`git grep` over `packages/lint/src` finds zero hits), so a stored view or report filter comparing a number field with a non-numeric string saves clean and is refused at query time. Both are reported as findings below and are not edited here. ## The staged `$empty` row: pinned at the door alone `NUMBER_COMPARAND_DOOR_CASES` carries PR objectstack-ai#20442's `unjudged` `$empty` row. The engine suite partitions it out of the end-to-end drive and pins it at the door alone: `findNonNumericComparand` answers `null`, and `narrowNumberComparands` returns the same reference. A partition guard asserts the table is split exactly. So the row can neither turn this suite red for a reason that is not the door's, nor vanish unnoticed. The contract's `formula` rows are partitioned the same way the text door's suite does it: they are pinned in the direction they answer (`INVALID_FIELD` / 400 from the objectstack-ai#8296 materializable door, one door earlier). The door's own walk is pinned to judge `f_formula_number` by its `returnType`. ## Tests (at `09da7a4cc`, the merged head, unless noted) - **New: `packages/objectql/src/engine-number-comparand-declared-type-door.test.ts`, 29 tests.** It drives the contract's case table through a real `ObjectQL` and a recording driver, per the contract header: - of the table's 137 cases, 51 refusals (the 52nd is the `f_formula_number` row), asserting `code` + `status` + `httpStatus`, every `mustMention` substring, and no driver read. All 8 refusal forms and every judged position are covered, both ways; - 23 `narrows` cases, asserting the driver receives `c.expectedFilter()` and the caller's filter is untouched; - 57 `passes` cases, reaching the driver unchanged; - the formula (5) and `$empty` (1) partitions above. Beside the table: - every verb (read and write, no read and no write on refusal); - `FilterArray` sugar, both refused and narrowed; - `$and` / `$or` / `$not`; - a placeholder refused unresolved; - `judgeFilter`; - the per-aggregation `filter`, refused at its path, with numeric strings counting what their numbers count; - `having` on `count` / `sum` / a numeric `min`, refused, narrowed, and a placeholder on `count`; - the four `findData` doors (`where` object, `$filter`, filter AST, implicit query parameter), both ways; - the registry-less, unknown-key, by-reference and unrecognised-combinator guards. - **New: `packages/rest/src/data-number-comparand-door.test.ts`.** It runs `POST /api/v1/data/:object/query` and `engine.find` / `engine.aggregate` over SqlDriver, with a cell per dialect: - `where`: 9 refused spellings; - the per-aggregation `filter`; - `having` on `sum` and `max(currency)`, on the native and the rows path; - numeric-string controls, equal to their numbers at all three positions. The SQLite cell always runs. The PostgreSQL cell ran against the local server: 3/3 passed at `09da7a4cc`.⚠️ **No CI job provisions `OS_TEST_POSTGRES_URL` for `@objectstack/rest`.** The `Temporal Conformance (live PG + MySQL)` job runs `driver-sql`'s suite, `metadata-protocol`'s `live-*` files and one `runtime` file, and a `driver-sql`-only pin cannot reach an engine door. So the live cells are red-capable and un-run in CI; the local run above is their measurement. - **Re-pinned, test side only.** Four existing pins asserted the old silent answer for a string on a numeric column: - `engine-aggregate-having-temporal-door.test.ts`: the three "a string on sum / count / avg keeps no group" rows move to a refusal pin in the number door's words; - `engine-aggregate-positions.test.ts`: the "unknown token on count" row moves to a text column, which neither field-aware door judges, and the count-column case is pinned in the new suite; - `rest-aggregate-numeric-having.test.ts`: three rows move from `KEPT` to a `REFUSED` table, SQLite and PostgreSQL both run locally; - `data-query-having-temporal-door.test.ts`: "a string on sum" becomes a number control plus a refusal pin. - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 330 files, 6118 tests passed. `--project repo`: 1 file, 5 passed. - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: 219 files, 3930 passed, 40 skipped. `--project repo`: 1 file, 8 passed. - The live PostgreSQL run of the two PostgreSQL-capable REST files: 30 passed (15 live-postgres), 15 skipped (MySQL). - `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter @objectstack/rest typecheck`: exit 0. `check:test-typecheck` is OK for both, with no debt added (objectql 40 files / 234 errors held; rest 0 / 0). ## Ablation (reverse verification) The mutation is in the door's walk, which every position routes through: `if (!meta || numberComparandFieldVerdict(meta) !== 'judged') continue;` → `if (meta || 'ABLATION_20351') continue;`. It is made with `scripts/ablation-replace.mjs`: anchor 1 → 0, and blob `aa4a3247` → `56a5bdf6`. - **Mutated leg:** after `pnpm --filter @objectstack/objectql build`, `ablation-dist-preflight` found the marker in 4 built files. The objectql door suite went **20 failed / 8 passed**; the 8 are the guards and partitions that do not depend on the door firing. The REST door suite went **6 failed / 3 skipped**. The SQLite cell answered `200` with `records: []`, the PostgreSQL cell `500 DATABASE_ERROR`, and the per-aggregation `$in ["5","30"]` counted 0 instead of 2: the card's defect, back. - **Restore leg:** the blob is back to `aa4a3247` = HEAD and `git diff HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent` found the marker absent from all 14 built files and the tree clean. Both suites passed again (28/28 and 6 + 3 skipped at that commit, `872d7708b`). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at **`09da7a4cc`** derives 65 commands, the same list as at the first merged head. All 65 ran with each exit code recorded before any pipe: - 63 exited 0 on the first pass; - `check:dual-build-cjs-loads` and `check:type-check-debt` answered exit 3 (PREREQUISITE NOT MET) until the whole workspace was built (`turbo run build --filter=!@objectstack/docs`, 72/72), then exited 0. `dispatch-gates --ran`: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. The branch merged `origin/main` twice with true merge commits, no rebase and no force-push; the last merge base is `45f428d8f`. ## Acceptance notes - **`having` words.** A numeric aggregated column has no declared `FieldType`, so the door hands the verdict `number` (the member of the numeric class the column holds). The spec's words then read "compares a declared number field against … at having.total.$gt". The `not-a-number` clause ("backends answer it differently (PostgreSQL with a server error)") is the `where` fact: `having` is evaluated by the engine on every driver, and there it kept no group, or every group under `$ne`. The words are the contract's, and the path names the position. - **Out of the contract, measured, unchanged:** a boolean or a `Date` compared against a number field is not judged (the contract judges strings). `$gt true`: no rows on memory, every row on SQLite, 500 on PostgreSQL. A `Date`: no rows · no rows · 500. Both hold on the base and on this branch. Handed to the seat below. - **Not measured:** MySQL (no server in this container); `driver-mongodb` (the door sits in front of it); the NativeSQL analytics path (read at source). - **Line budget:** n/a (no `skills/**` path in the diff). ## Out of scope, handed to the seat (not filed by this dev) 1. **Class (a), reach measured at REST.** A boolean or a `Date` comparand against a number field answers `500 DATABASE_ERROR` on PostgreSQL. It is `POST /api/v1/data/:object/query` with `where: { amount: { $gt: true } }` against a `number` field, on a local PostgreSQL 16 server, on the base and on this branch. The contract review of PR objectstack-ai#20414 said to file this only if it answered 500; it does. Dedupe words: `boolean comparand number field postgres 500` · `Date comparand numeric column database_error` · `non-string comparand declared number type`. 2. **Carrier: none. Noted, not filed (read at source, reach not measured).** `NativeSQLStrategy.canHandle` does not consult the number verdict, so a raw-SQL analytics deployment does not fall through to this door. Dedupe words: `native sql decline number comparand` · `analytics raw sql non-numeric string`. 3. **Carrier: none. Noted, not filed (read at source, no named producer).** No authoring rule reads `numberComparandDoorVerdict`, so a stored view or report filter with a non-numeric string on a number field saves clean and is refused at query time. Dedupe words: `stored view filter non-numeric number field lint` · `authoring number comparand verdict`. 4. **Carrier: none. Noted, not filed.** The runtime RLS compile (`judgeCompiledComparands`) does not consult the number verdict. The authoring judge does, when present. Dedupe words: `rls compiled predicate number comparand` · `policy using string against number field`. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…staged $empty operator (objectstack-ai#20444) (objectstack-ai#20523) Fixes objectstack-ai#20444 Clause-②: yes (widening) The `domain:engine` lane's arms for the staged `$empty` operator, under ruling A on objectstack-ai#20399 (`5865693155`): 「**One sibling card per compile-surface lane**, each `Blocked-by:` objectstack-ai#20311's spec PR: `domain:engine` — driver-sql and its heirs, turso `RemoteTransport`, driver-memory, driver-mongodb, formula, objectql `having`; `domain:services` — service-analytics' two faces. The two faces with no field declarations (the formula matcher, objectql `having`) judge by value, diverging only on a non-text column holding `''` (the write-door class objectstack-ai#20308 closed).」 Every arm calls the spec's one expansion from PR objectstack-ai#20442 (`expandEmptyOperator` / `isEmptyFilterValue` in `@objectstack/spec/data`); no face keeps a copy of the table. The staging does not move (the maintainer's 「照 $like 先例分阶段」, `5868169573`): `$empty` is **not** added to `FILTER_OPERATORS`, the `is_empty` / `is_not_empty` lowering still emits `$null`, and the engine's front door still refuses the operator. A driver or evaluator called directly now answers it. ## What each face does now | face | reads | `$empty: true` | undeclared field | |---|---|---|---| | `driver-sql` `applyFilterCondition` (and `driver-sqlite-wasm`, `driver-turso` local, which inherit it) | declared row | null-only: `col IS NULL`; text: `(col IS NULL OR col = '')`; multi-value: `(col IS NULL OR L)` | refused | | `driver-turso` `RemoteTransport.buildWhereSQL` | declared row, via a resolver `TursoDriver` wires from the same registry | same SQL, SQLite dialect | refused (also when used standalone with no resolver) | | `driver-memory` live path (`find` / `count` / `update` / `delete` through mingo) | declared row | null-only `{ f: { $eq: null } }`; text `{ f: { $in: [null, ''] } }`; multi-value `{ $or: [{ f: { $eq: null } }, { f: { $size: 0 } }] }` | refused | | `driver-mongodb` `translateFilter` (and the aggregate `$match`) | declared row, via a new optional `valueShape` resolver | the same three documents | refused (also standalone with no resolver) | | `driver-memory` reference matcher (`match`) | by value | `isEmptyFilterValue(value)` | answered by value (it holds no declarations) | | `formula` `matchesFilterCondition` | by value | `isEmptyFilterValue(actual)` | answered by value | | objectql `having` and per-aggregation `filter` | by value | `isEmptyFilterValue(value)` | answered by value | | `driver-memory` analytics (cube) face | — | refused `INVALID_FILTER` / 400 as a declared operator it cannot compile, as it refuses `$null` | — | `$empty: false` is the exact complement on every face: `(col IS NOT NULL AND NOT L)` / a non-null value other than `''` (the not-equal operator against a bound `''`) / `IS NOT NULL` on SQL, `$nin` / `$nor` / `$ne` on the document faces, `!isEmptyFilterValue` on the value faces. A non-boolean flag is refused on every query face (`INVALID_FILTER` / 400, on each driver's validating walk, so an identity that settles the node first cannot skip it); formula answers it `false`, its standing posture for an unevaluable `check`. `L`, the empty-list test on a multi-value column (a JSON column: TEXT on SQLite, `json` on PostgreSQL and MySQL): - SQLite (and libSQL): `(CASE WHEN json_valid(col) THEN json_type(col) = 'array' AND json_array_length(col) = 0 ELSE 0 END)` — a malformed legacy cell answers FALSE instead of failing the statement; a non-array JSON value is not an empty list; - PostgreSQL: `(CAST(col AS jsonb) = CAST('[]' AS jsonb))`; - MySQL: `(JSON_TYPE(col) = 'ARRAY' AND JSON_LENGTH(col) = 0)`; - any other knex dialect: the multi-value row is refused (the text and null-only rows need no dialect). An empty list is always tested as a stored value, never bound as a `$eq: []` comparand (ruling 乙 on objectstack-ai#19757 stands). Every SQL predicate is TOTAL (never UNKNOWN), so `$not` over `$empty` needs no NULL guard: both SQL compilers' polarity tables gain the row (`operatorIsNullTotal` → true, `nullValueSatisfiesOperator` → `value === true`). ## PM hypotheses, measured - **H1 — held, with the sources named.** Measured on base `4a1df1965` by driving each face directly (a scratch probe, not committed) with `{ f: { $empty: true } }`, `$empty: false` and `{ $and: [{ g: 'x' }, { f: { $empty: true } }] }`, beside a `$null` control (answered on every face) and a `$bogus` control. Refusal sources: driver-sql the emitter's `default:` arm (`unsupportedFilterOperatorError`); turso remote its own vocabulary refusal (`unsupportedOperator`); driver-memory live path and matcher both at the shared shape gate (`assertFilterConditionShape`, `filter-refusal.ts`); driver-mongodb `translateFieldOperators`' `default:`; objectql `having` `unknownOperator`. All `INVALID_FILTER` / 400. formula answered `[]` for all three shapes (the silent `false`), exactly as `$bogus`. After this PR, the same probe answers `['2','3']` / `['1']` / `['2','3']` on every face that holds the declaration or judges by value, and refuses on the two standalone entry points given no declaration. - **H2 — each declared-type face's declaration.** `driver-sql`: a new per-table registry `valueShapeFields` (`{ type, multiple }` per field), filled beside `jsonFields` at `registerManagedObjectMetadata` (so `initObjects` and `registerObjectMetadata`), `registerExternalObject`, and the shard alias. turso remote: `registerRemoteFieldMetadata` → `registerExternalObject` fills the same registry, and `TursoDriver` hands the transport `setDeclaredValueShapeResolver`. driver-memory and driver-mongodb: a map filled by `syncSchema` beside the temporal-kind map. The engine's registry injects the audit / tenant / owner fields into the object's field map before it is synced (per `registry.ts`' own docblock; not re-measured end to end here), so those are declared too. **A field with no declaration (a knex-built table, the builtin `id`, a field with no `type`) is a refusal, never a row guessed from a value:** the spec's by-value reading has no SQL form without the type (`amount = ''` is a type error on PostgreSQL). A declared non-member type (`string`, `object`, `array` from an introspected or test object) takes the row the spec's expansion gives it, null-only. - **H3 — SQL arms**, above. Pinned on SQLite locally; `sql-driver-20444-empty-operator.test.ts` runs on every cell of the live dialect matrix, so PostgreSQL and MySQL are measured by the `Temporal Conformance (live PG + MySQL)` job. **Locally NOT MEASURED** on PG / MySQL: no server is reachable in this container. The MySQL `' '` row relies on the NO PAD default collation of the job's `mysql:8.0`. - **H4 — the conformance table.** `FILTER_LOGIC_CASES` gains seven `$empty` cases on the fixture's nullable column `d` (true, false, both under `$not`, inside `$or`, inside `$and`, beside `$ne` on the same field). The fixture stores neither `''` nor `[]`, so on it every row of the table agrees; the rows pin that every face HAS an arm, that `$not` over it is total and that it composes. The per-type discrimination is each face's own suite (below). Census of every consumer that iterates the table: - driver-sql `sql-driver-or-filter.test.ts` — built its table through knex, so the harness now registers the fixture's declaration (`registerObjectMetadata`); - driver-sqlite-wasm, driver-turso local and remote, driver-memory live path and matcher, driver-mongodb live suite — already declared the fixture (`initObjects` / `syncSchema`), pass unchanged; - driver-memory analytics face — the harness's rule is "agree or refuse loudly", and it refuses; - driver-mongodb `mongodb-filter-logic-translation.test.ts` — calls `translateFilter` standalone, so it now passes a declaration resolver; - formula `matches-filter-or-semantics.test.ts` — by value, passes unchanged; - spec `filter-verdict.test.ts` — the rows reduce to `clause`, passes unchanged; lint `validate-empty-combinators.test.ts` reads only the `objectstack-ai#5322` rows; - service-analytics `read-scope-sql-conformance.test.ts` and `native-sql-filter-logic-conformance.test.ts` — outside this lane. Since PR objectstack-ai#20498 (merged) both faces answer `$empty`, but only when handed the field's declaration; each harness now passes a `text` declaration for the fixture (test-only, no service-analytics source touched), so they pass the rows rather than partition them. Declared as a deviation below. - **H5 — `having`'s conclusion.** By value over the aggregated row: null, a column the row lacks, `''` and `[]` are empty. A numeric aggregate holding `0` (a `count` over nothing, a `sum` netting to zero) is **not** empty. A `groupBy` text column holding `''` **is** empty — the row a declared text field takes too. The per-aggregation `filter` shares the walker and the reading. Pinned in `having-empty-operator.test.ts`, including the row-independent refusal of a non-boolean flag. - **H6 — formula's docblock.** Its header claimed a DECLARED operator never gets the silent `false`; that was false from objectstack-ai#20311's declaration until this arm. The header now records that, names the declared-but-staged set (`$like`, `$ilike`, `$empty`) as answered, and says the next declared name is owed an arm by the PR that lets an author write it or by its staging's lane card. ## Tests (head measured: `436a10a3e`) - New per-face pins, each over a text, a multi-value and a scalar field with null, `''`, `[]` and value rows, `$empty: false`, nesting under `$and` / `$or` / `$not`, a sibling operator on the same field, and refusals asserted by `code` + `status`: `sql-driver-20444-empty-operator.test.ts` (dialect matrix), `turso-20444-empty-operator.test.ts` (local and remote held to one row set, plus `count()`), `memory-20444-empty-operator.test.ts` (live, matcher, analytics face, and the one pinned cell where the declared row and the by-value reading part), `mongodb-20444-empty-operator.test.ts` (emitted documents and their rows; a live-`mongod` half runs when the opt-in server is available), `matches-filter-empty-operator.test.ts`, `having-empty-operator.test.ts`. - Extended: the withheld-refusal seam tests of driver-sql (three new builders, one needing the `'unknown'` dialect) and of the turso remote transport (two methods, and the local / remote one-sentence table), and driver-memory's operator-key clobber sweep (now declares its column and covers `$empty`). - Full package suites on the pre-merge head `ea3d95994`, each run through the verify lock: driver-sql 197 files passed, 1 failed, 11 skipped — the failure was the withheld-refusal seam enumeration, which the new refusal builders owed rows; they are added in this PR and that file re-ran green (107 tests); driver-turso 77 files, 2080 passed; driver-sqlite-wasm 36 files, 665 passed; driver-memory 59 files, 1419 passed; driver-mongodb 29 passed / 5 skipped, 656 passed; formula 42 files, 1227 passed; objectql `--project local` 332 files, 6636 passed; service-analytics 134 files, 3165 passed. - On the merged head `436a10a3e`: `typecheck` exit 0 for all seven packages above (spec's own `typecheck` ran green on the pre-merge head); the `$empty` suites and every `FILTER_LOGIC_CASES` harness re-run green (driver-sql 154 passed / 4 skipped, turso 240, sqlite-wasm 37, memory 194, mongodb 65 / 50 skipped, formula 43, objectql 36, service-analytics 72, spec 73). - **Ablations**, each through `scripts/ablation-replace.mjs` on the committed tree with a restore trap; every leg restored to blob == HEAD with `git diff HEAD` empty: - A1 — driver-sql's text arm drops its `''` limb: 4 red in `sql-driver-20444-empty-operator.test.ts`; the `FILTER_LOGIC_CASES` sweep stayed green, which is the measured proof the shared rows do not discriminate the text row. - A2 — driver-memory's multi-value lowering written as `$in: [null, []]`: 8 red (mingo does not match a stored `[]` that way). - A3 — formula's arm removed (the silent `false` back): 13 red, 6 in the new pins and all 7 `$empty` rows of the shared table. The first A3 attempt did not run: its replacement text already occurred in the anchor, the tool refused the non-rising count, and the file was restored; it was re-run with a distinct replacement. - `check:driver-conformance` read before and after: 50 covered cells, 0 DEBT, 0 exempt on both sides. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `436a10a3e` (after merging `origin/main` with a merge commit) derived 91 commands; all 91 ran, each exit code recorded before any pipe. `--ran` reconciliation: "91 derived famil(ies) accounted for — 89 run, 2 NOT-MEASURED". NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 (`PREREQUISITE NOT MET`: they need the whole workspace built). Narrowed probe instead: the built CJS entry of each changed package loads under `require` (driver-sql 51 exports, driver-turso 14, driver-memory 23, driver-mongodb 11, formula 47, objectql 178). Lint, narrowed: `eslint.config.mjs` lints `packages/**/*.{ts,tsx,mts,cts}` with no type-aware parsing (no `parserOptions.project`), so no verdict on an untouched file can move with this diff. `pnpm exec eslint --no-inline-config --format json` over the 26 changed `.ts` files: 26 file entries, 0 errors, 0 warnings. ## Deviations - **service-analytics test files** (`read-scope-sql-conformance.test.ts`, `native-sql-filter-logic-conformance.test.ts`) are edited, although the order bars service-analytics. The edit is test-only: it hands each harness the fixture's declaration so the new shared rows pass (H4). No service-analytics source moves. - **`packages/spec/src/data/filter-logic-conformance.ts`** gains the seven rows and a header paragraph, a declared cross-lane test-data edit (the claim names it). ## Acceptance notes (observations, not filed) - The `FILTER_OPERATORS` TSDoc table in `packages/spec/src/data/filter.zod.ts` still says no face answers `$empty` and lists each face as refusing it; `filter-empty-operator.ts`' header still says nothing in the repository calls the expansion. Both were already stale after PR objectstack-ai#20498 and are staler now. Carrier: the flip card, which rewrites that paragraph when it adds the operator. - `@objectstack/formula`'s `matchesFilterCondition` has accepted the object's declared columns (`options.fields`, type and `multiple`) since PR objectstack-ai#20427, after ruling A was taken. With them it could answer `$empty` by the declared row, as the read side of the same RLS policy does. This PR keeps the by-value reading the ruling and the card assign; the two part only on a stored state the declaration does not predict. Carrier: none named. - For the flip card: the engine's front door is the one remaining refusal on the ObjectQL execute path PR objectstack-ai#20498 names. `driver-memory`'s analytics face refuses `$empty` exactly as it refuses `$null` today, so the flip moves nothing there. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20311
Clause-②: yes
Declares the emptiness operator
$empty: booleanin@objectstack/spec. Its description is ruling B's per-type table. The one expansion every compile surface will call is exported beside it. The operator is staged the way$likewas: it is declared, but deliberately absent fromFILTER_OPERATORS, and theis_empty/is_not_emptylowering still emits$null.Ruling-ref: 5861435168 (ruling B on #20311), 5865693155 (ruling A on #20399, the spelling), 5868169573 (the maintainer's amendment, 「照 $like 先例分阶段」).
What changed
FieldOperatorsSchemaandSpecialOperatorSchema(the enforced copy and the documentation copy, one shared constant) gain$empty: z.boolean().optional(). The description is the ruled table: text-like (STRING_VALUE_TYPES) = null or''; multi-value (isMultiValueField: multiselect, checkboxes, tags, and select / radio / lookup / user / file / image withmultiple: true) = null or[]; every other type = null only;falseis the exact complement; a face with no field declaration judges by value. It also says in plain words that the operator is staged and that no face answers it yet.packages/spec/src/data/filter-empty-operator.ts, published on the data entry:expandEmptyOperator(field)keys on the field DEFINITION (type plusmultiple) and returns one of the frozenEMPTY_OPERATOR_ARMSrows{ arm, emptyString, emptyList }.isEmptyFilterValue(value, expansion?)is the value-level half: with an expansion it applies the declared row; without one it applies the by-value reading for the formula matcher andhaving. The result is surface-neutral, deliberately not aFilterCondition, because[]stays refused as an equality comparand (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).FILTER_OPERATORSis unchanged. Its docblock gains a$emptystaging paragraph with the measured per-face table below. The flip card is named as the one that adds the operator.filter-operator-vocabulary.test.ts'STAGED_AHEAD_OF_BACKENDSbecomes['$empty', '$ilike', '$like'].FieldOperatorsSchema's keys, kept green the way$like's staging kept them:filter-comparand-type.ts) gains$empty;filter-save-door-refusals.ts) gains$empty. A non-boolean$emptyis then refused at save, as$null/$existsalready are, with the flags' first sentence and an$empty-specific prescription.@objectstack/specminor,Clause-②: yes (widening). It says plainly that authoring$emptytoday is refused at query time.Why the expansion is its own module, not in
filter.zod.tsThe first version put the functions in
filter.zod.ts, importing the sets fromfield-value.zod.ts.gen:skill-refsthen rewroteskills/objectstack-query/references/_index.mdandskills/objectstack-api/references/_index.md: that import pulledfield-value.zod.ts,field.zod.tsand four shared modules into those skills' transitive reference lists. Anyskills/**path would make this PR Tier H. The two files also meet in thefield.zodimport cycle. So the expansion sits in a sibling module, the precedent beingfilter-text-operator-declared-type.ts, which reads the same sets from the same position.filter.zod.tstakes no new import. At the final headcheck:skill-refsis green with zeroskills/**changes. The description names its type lists literally, andfilter-empty-operator.test.tspins each list to the sets the function reads, so the two cannot drift.A1: what every compile surface does with a hand-authored
$empty(measured)Probe: a scratch script run once and not committed. It drove each surface with
{ f: { $empty: true } },{ f: { $empty: false } }and{ $and: [{ g: 'x' }, { f: { $empty: true } }] }, after this change was built. Two controls ran beside it: the declared{ f: { $null: true } }, and an undeclared{ f: { $bogus: true } }.$empty(all three shapes)$null$bogusapplyFilterCondition, viaSqlDriver.findon better-sqlite3 (driver-sqlite-wasm and driver-turso local inherit this compiler; not driven separately)INVALID_FILTER/ 400['2']INVALID_FILTER/ 400RemoteTransport.buildWhereSQL, viafindINVALID_FILTER/ 400IS NULLINVALID_FILTER/ 400compileScopedFilterToSqlREAD_SCOPE_COMPILE_FAILED/ 500 (fail-closed)IS NULLlowerAnalyticsWherenormalizeAnalyticsFilterTree, REFUSESINVALID_FILTER/ 400notSetmatchesFilterConditionfalsefor every record, with flagtrueand with flagfalsefalseapplyHaving/matchesHavingINVALID_FILTER/ 400findandmatch(checkCondition)INVALID_FILTER/ 400['2']translateFilter(translateFieldOperators)INVALID_FILTER/ 400{ f: { $eq: null } }Conclusion per surface, for this card: explicitly out of scope; each gets its
$emptyarm from its lane card. No surface drops the predicate, so nothing widens. Two readings differ from premise A1, "the staging leaves every surface loud":falsefor any operator it has no arm for: its decided fail-closed posture (JS 求值面全体拒收$icontains(driver-memory 两面 / driver-mongodb / objectqlhaving/ formula)—— SQL 族已实现,同一 filter 在内存 double 上抛错 #6520), identical for the undeclared$bogus, and unchanged by this PR. It denies a write-sidecheckrather than widening anything. But its own docblock says a DECLARED operator must not get that silentfalse("the same defect under a new name"), and$emptyis now declared. So that claim is stale until formula's lane card lands.$likegot its formula arm in the PR that declared it; this card is barred from formula by the order and by the amendment's placement of arms in lane cards. Flagged in the report as an open question for the seat.READ_SCOPE_COMPILE_FAILED/ 500, notINVALID_FILTER/ 400. It is loud and fail-closed, and it does the same for every unknown operator. The description and the changeset say "refuse", not "400".A2 to A4
FieldOperatorsSchema's own keys and saw$empty:filter-comparand-type.test.ts(judged set) andfilter-save-door-face-parity.test.ts(BOOLEAN_SLOTS). Both are updated, with the source sets they reconcile.filter-operator-vocabulary.test.tsrecords the staging. Suites that enumerateFILTER_OPERATORS(filter-view-operator-parity,page-component-filter-record-to-rule-array, service-analytics' echo coverage) are untouched, because the array is. No test outsidepackages/specenumeratesFieldOperatorsSchema's keys (repo grep: only prose mentions). No gate needed an edit outsidepackages/spec.50e273fd7:STRING_VALUE_TYPEShas the 14 text types;isMultiValueField=MULTI_OPTION_TYPESor aMULTI_CAPABLE_TYPESmember withmultiple: true. The sets are disjoint, a pin asserts it, andlookupvslookupwithmultiple: trueland on different rows.isEmptyFilterValue(value)with no expansion is the declaration-free reading (null,undefined,'',[]). It differs from the declared table only on a non-text column holding'', and a pin shows exactly that divergence.Pins (
filter-empty-operator.test.ts){ tags: { $empty: true } }parses atFieldOperatorsSchema(kept, not stripped), atFilterConditionSchema(any depth) and through the normalized AST.{ tags: [] }and{ tags: { $eq: [] } }are still refused (issue at the slot; the query face answersINVALID_FILTER/ 400). A non-boolean$emptyis refused at the slot and at the save door.expandEmptyOperatorreturns the text, multi-value and null arms for the three kinds, and everyFieldTypelands on its value class's arm.isEmptyFilterValueanswers each arm, plus the by-value reading.$emptyis ABSENT fromFILTER_OPERATORS; a comment names the flip card as the one that adds it.is_empty/isempty/is_not_empty/isnotemptystill lower to$null.Verification (final head
32e926db1)packages/specfull local suite:vitest run --project local, 563 files, 16536 passed, 1 todo.pnpm --filter @objectstack/spec typecheck: exit 0 (tsc, scripts typecheck, test typecheck).scripts/ablation-replace.mjs. It added'$empty'toFILTER_OPERATORS: anchor count 1 to 0, blob0912c2776e5eto74b0e4d23fe0. The run went red on exactly the two staging pins (filter-empty-operator§4 andfilter-operator-vocabulary), with 2 failed and 23 passed. The file was restored to blob == HEAD withgit diff HEADempty. The second ablation (dropping$emptyfrom the save-door flag set) was not run: the verify-lock queue timed out.@objectstack/spec, i.e. downstream.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 108 commands at this head (a superset of the dispatch list's 72). All were run, and--ranreconciled: 106 exited 0; 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET) because they need every package built. Those two arecheck:dual-build-cjs-loadsandcheck:type-check-debt, and CI runs them.check:skill-examplesfirst exited 3 (client-react unbuilt) and exited 0 after building it.pnpm --filter @objectstack/spec check:generated: every artifact current.eslint --no-inline-config --format jsonover the 9 changed TypeScript files reported 9 files, 0 errors, 0 warnings. The population iseslint.config.mjs'**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}block. That config never enables type-aware linting (its own note says so), so this diff cannot move a verdict on any untouched file. The fullpnpm lintis CI's.Patch round: reconciled with #20414 (head
d581aed71)where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336) landed asb28550818. Its partition pin holdsFieldOperatorsSchema’s keys equal to its judged positions, the text operators and$null/$exists. With$emptyadded, that pin went red in every merge group carrying both PRs (audit5871530372; the diagnosis is on Queue-flake anchor: src/data/filter-number-comparand-declared-type.test.ts #20455). This PR is the later lander, so it carries the reconciliation.where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336’s files.$emptyjoins the flag operators in the partition; the test title now says "three flag operators".$empty.NUMBER_COMPARAND_DOOR_CASESexists.origin/mainb28550818was merged throughos-regen-merge.sh(43801c9ed) and regenerated in859d9bd82. Both PRs’ exports are present, once each, inapi-surface/data.jsonandexport-origins/data.json, andfilter.mdxcarriesmain’s frontmatter and the$emptyrows.d581aed71.test(local): 566 files, 16687 passed;typecheck: exit 0;check:generated: exit 0;check:dual-build-cjs-loads,check:type-check-debt): both need the whole-repo build.test:reporan in 9 shards: 8 passed, and one was cut by the local time cap, so it is NOT MEASURED locally. CI’sTest Core, which runstest:repo, is green on this head.Stored sharing rules (ruling B, parameter 3)
50e273fd7: the criteria sharing rules inexamples/that use emptiness = 0.b45d463a9: none either; the 22 emptiness hits in sharing-rule-related files are builder code and tests, not stored rules.Acceptance notes
PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414’s number-comparand partition pin now names
$emptyamong the boolean flag operators (see the patch round above). The flip card filter: flipis_empty/is_not_emptyto$emptyand add it toFILTER_OPERATORS, once every compile surface answers it (the last step of ruling A on #20399) #20446 must keep it there when$emptyentersFILTER_OPERATORS.formula's docblock claim ("the silent answer is reachable only for a name the protocol does not declare") goes stale with this declaration. Carrier: formula's lane card, which gives it the
$emptyarm (by value,isEmptyFilterValue(value)).read-scope-sql answers every unknown operator with
READ_SCOPE_COMPILE_FAILED/ 500 rather than the ADR-0112INVALID_FILTER/ 400 the other faces use. It is pre-existing, fail-closed and loud. Carrier: service-analytics' lane card.packages/spec/src/ui/view-grouping-query.tssays the empty-group predicate and the view filter'sis_emptyagree on what "empty" means. That stays true while the lowering is$null. Carrier: the flip card.is_emptystill means null-only on every face until the flip card: the gap ruling B closes is still open for users, by design of the staging.The patch-round section and the first acceptance note were added by the
domain:specseat 1 (session_01B3TqpoQbTAfG7G74GMDWNW) from the dev’s round report.