fix(service-analytics): both filter faces answer $empty by the field's declared type (#20445) - #20498
Conversation
…'s declared type (wip) Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude <noreply@anthropic.com>
…red row Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 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 42cf8389c22991a0bc417f213f399b09e3247245 && git checkout 42cf8389c22991a0bc417f213f399b09e3247245
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9bf5e67affab69ce740037f33b003f5faf45d205 611daa435f9947e1e13cb85b8b4121a8a4123e76 && git checkout -B drift-repro 9bf5e67affab69ce740037f33b003f5faf45d205 && git merge --no-ff 611daa435f9947e1e13cb85b8b4121a8a4123e76
node scripts/docs-audit/affected-docs.mjs --json 9bf5e67affab69ce740037f33b003f5faf45d205
|
Contract reviewServed-tier: Inputs read: card #20445 (body and all 5 comments, the claim 5875970963 and the ACCEPT 5876774357 included), PR #20498 (body, 11-file list, net diff against ① Derived judgmentsThe spec as it stands on
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Rendered by an isolated contract-review subagent and adopted by the Generated by Claude Code |
…es (widening) Two new optional published members (sourceFieldMeta's multiple, compileScopedFilterToSql's declaredValueShape) widen the package's public surface; names the read-scope residual of a host that answers type without multiple. Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #20445 (body and all 6 comments — the claim 5875970963 as it stands after the seat's 2026-09-28T19:31:28Z edit, the ACCEPT 5876774357, the dev reports 5876735617 and 5877331052, the two triage records), PR #20498 (body as it stands, the 11-file list, both PR comments — the prior Delta since the prior record, proven from git. ① Derived judgmentsThe spec on
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Rendered by an isolated contract-review subagent and adopted by the Generated by Claude Code |
…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 #20445
Clause-②: yes (widening)
The
domain:serviceslane's arms for the$emptyoperator, under ruling A on #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 #20442), and keep no copy of the table:$empty: truematches$empty: false''multiple: trueon a multi-capable type)[]The staging does not move (「照 $like 先例分阶段」):
$emptyis not added toFILTER_OPERATORS, and theis_empty/is_not_emptylowering still emits$null. There is no$eq: []comparand anywhere (ruling 乙 on #19757 stands).What changed
empty-operator-sql.ts(new). The SQL for one$emptypredicate 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), whereLis the dialect's empty-JSON-list test: SQLitejson_validguard inside aCASE, thenjson_type(col) = 'array' AND json_array_length(col) = 0; Postgres ajsonbequality with'[]'; MySQLJSON_TYPE='ARRAY'andJSON_LENGTH= 0.$empty: falseis the exact complement of each. Every predicate is TOTAL (never UNKNOWN), so a$notover$emptyneeds no NULL guard.compileScopedFilterToSql). A new$emptyarm incompileOperator. It asks the caller for the field's declaration (new optionaldeclaredValueShapeoption; both callers pass it from the context), callsexpandEmptyOperator, 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).whereface (lowerAnalyticsWhere/normalizeAnalyticsFilterTree).fieldLeavesstops refusing$emptyand lowers it to a valuelessempty/notEmptyleaf. 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 theObjectQLStrategyecho both callwhereEmptyLeafSql: 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 (filter: the engine's compile surfaces answer$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444).assertBooleanNullFlagswith$null/$exists. Both polarity tables gain the row.DatasetScopedStrategyContext.declaredValueShape(object, field).AnalyticsServiceanswers it from the existingsourceFieldMetahook (type, and nowmultiple).AnalyticsServicePluginrelaysmultiplefrom the field definition.The field's declaration is never guessed. A face that cannot name it refuses, before anything binds. On the
whereface that isINVALID_FILTER/ 400; on the read-scope face it isREAD_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 withcode+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 atcompileOperator'sdefault: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:compileScopedFilterToSql. Its only two in-package callers areNativeSQLStrategy.applyReadScopeand theObjectQLStrategy.generateSqlecho. Both passctx.getReadScope(object).AnalyticsServicePluginanswers that hook either from thesecurityservice'sgetReadFilter, which compiles admin-authored sharing rules and permission sets, or from the host's owngetReadScopeplugin option. The analytics caller's own filter takes the other road,filter-normalizer.ts, and that road answersINVALID_FILTER/ 400 for an unknown operator.service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #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". security: the analytics ObjectQL execute face answers a row-level read scope it cannot run withINVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 (60fdaa9e, PR fix(service-analytics): the ObjectQL face refuses a read scope carrying a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (#19995) #20072) extended the same withheld 500 to the ObjectQL engine door for exactly that disclosure reason.$bogusin a read scope still answersREAD_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).driver-sqlapplyFilterCondition(and itsextends SqlDriverheirs)domain:engine). Measured today: it refuses$emptywithINVALID_FILTER/ 400, operator and field withheld. Untouched here.RemoteTransportbuildWhereSQLread-scope-sqlcompileScopedFilterToSql$emptyarm above, and the boolean gate.filter-normalizerlowerAnalyticsWhere/normalizeAnalyticsFilterTreeempty/notEmptyleaf, answered by NativeSQL and the ObjectQL echo, and handed to the engine by ObjectQL execute.formulamatchesFilterConditionhaving-filter(applyHaving/matchesHaving)driver-memorycheckCondition,driver-mongodbtranslateFieldOperatorsTwo package-local consumers of face 4's tree, named so they don't read as missed:
{ $empty }to the engine. Until filter: the engine's compile surfaces answer$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444'sdriver-sqlarm 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.preview-evaluator.ts). Unchanged. It already refuses$empty(INVALID_FILTER/ 400) with its other unevaluated operators,$nullamong them.Evidence (HEAD
6a07f8cc; the suite ran at20994c10, and HEAD adds only the changeset on top of it)fc0db22bbefore editing.lowerAnalyticsWherepassed{ f: { $empty: true } }through, andnormalizeAnalyticsFilterTreerefused itINVALID_FILTER/ 400.compileScopedFilterToSqlrefused itREAD_SCOPE_COMPILE_FAILED/ 500, and$bogusgot the same answer.git grep -c '$empty'overservice-analytics/srcread 0 hits; the control word$nullread 10+ files.read-scope-empty-operator.test.ts: 31 tests, executed onsql.js.where-empty-operator.test.ts: 27 tests. NativeSQL executes onsql.js, the ObjectQL echo runs on the same database and must return the same rows, and the condition handed to the engine is pinned.tags, alookupwithmultiple: true, aselectand anumberfield. Rows: null,'',[], a non-list JSON value, and a value.$empty: falseas the complement; nesting under$and/$or/$not; beside another operator on the same field. Refusals assertcode+status.pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2:Test Files 134 passed (134)·Tests 3151 passed (3151).pnpm --filter @objectstack/service-analytics exec tsc --noEmit --listFilesexits 0, and its file list contains all three new files.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 62 commands; all 62 ran.--ranreconciliation: "62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED".check:dual-build-cjs-loadsandcheck:type-check-debt. Both exit 3 withPREREQUISITE NOT MET, because they need the whole workspace built. Narrowed probe instead: this package's builtdist/index.cjsanddist/index.jsboth load and exportcompileScopedFilterToSql.eslint.config.mjslintspackages/**/*.{ts,tsx,mts,cts}with no type-aware parsing (noparserOptions.project). A verdict on an untouched file therefore cannot move with this diff.pnpm exec eslint --no-inline-config --format jsonover the 10 changed.tsfiles: 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) withgit diff HEADempty.''. Thenull_onlyarm 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'.$empty: falsestops 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)
FILTER_OPERATORSTSDoc table inpackages/spec/src/data/filter.zod.tsstill lists both service-analytics rows as REFUSES. Carrier: the flip card, which rewrites that table. Nopackages/specedit here.$emptyconformance table (per-type rows × stored states, the wayFILTER_LOGIC_CASESworks) would let every face run one standard. That is the spec lane's to add, with the flip card, and is not added here.$emptyrows inobjectql-echo-operator-coverage.test.ts(OPERATOR_CASES) andobjectql-icontains-arm.test.ts(SAMPLES). Both assert their tables equalFILTER_OPERATORS, so they go red on the flip until the rows exist.$emptyon the ObjectQL execute face is refused by the engine's driver asINVALID_FILTER/ 400 with the operator and field withheld, not the read-scope 500.judgeFilteradmits the operator because it stops before the driver. This predates the PR and closes when filter: the engine's compile surfaces answer$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 lands the driver arm. No in-repo producer emits$emptyin a read scope (the CEL lowering'sis_emptyemits$null).Seat append (
domain:servicesseat #6021,session_017B6YKCGu8CTY2KBWgwaHAs)Clause-②line changed fromnotoyes (widening), per contract review FAIL5876996555. The PR adds two published members,multiple?onAnalyticsServiceConfig.sourceFieldMeta's return shape anddeclaredValueShape?oncompileScopedFilterToSql's options. The changeset moves tominorin the patch round on this PR.Generated by Claude Code