Skip to content

fix(service-analytics): both filter faces answer $empty by the field's declared type (#20445) - #20498

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20445-analytics-empty-operator-arms
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20445-analytics-empty-operator-arms

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20445

Clause-②: yes (widening)

The domain:services lane's arms for the $empty operator, 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:

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 #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:
  • 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:

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 #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 #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 #20444. Untouched.
half-face objectql having-filter (applyHaving / matchesHaving) out of scope: sibling #20444. Untouched.
unfrozen driver-memory checkCondition, driver-mongodb translateFieldOperators out of scope: sibling #20444. Untouched.

Two package-local consumers of face 4's tree, named so they don't read as missed:

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 filter: the engine's compile surfaces answer $empty by 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 $empty in a read scope (the CEL lowering's is_empty emits $null).

Seat append (domain:services seat #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

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 34 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class ObjectQLStrategy))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9bf5e67affab69ce740037f33b003f5faf45d205 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 42cf8389c22991a0bc417f213f399b09e3247245 — the merge of head 611daa435f9947e1e13cb85b8b4121a8a4123e76 into base 9bf5e67affab69ce740037f33b003f5faf45d205, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9bf5e67affab69ce740037f33b003f5faf45d205 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6a07f8ccbd7a68757cae7bebb37e41c32be1aa8a
Local-runs: none

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 main at 45f428d, the PR's base), file contents at the head and on origin/main, and the head's check-runs (read 2026-09-28T19:25Z: 34 names, 31 success, 3 skipped, 0 failure, 0 in progress). Nothing was built, run or re-run.

① Derived judgments

The spec as it stands on main, which every judgment below is read against: FieldOperatorsSchema and SpecialOperatorSchema (packages/spec/src/data/filter.zod.ts) declare $empty: z.boolean() whose describe() IS the ruled per-type table (ruling B on #20311, spelled by ruling A on #20399) and says in its own text "STAGED: declared ahead of its backends and absent from FILTER_OPERATORS. Until each face has its arm, the query executors refuse it"; filter-empty-operator.ts exports the one expansion (expandEmptyOperator, EMPTY_OPERATOR_ARMS); FILTER_OPERATORS deliberately omits $empty (「照 $like 先例分阶段」) and its docblock's "no face answers it yet" table lists both service-analytics rows as REFUSES with the flip card as the closer.

  1. Accept set, the where face (POST /api/v1/analytics/query, /analytics/sql, dataset filters). $empty: true | false is lowered to a valueless empty / notEmpty leaf and answered by the field's declared row; before the diff normalizeAnalyticsFilterTree refused it INVALID_FILTER / 400. Reading: a runtime face honouring an already-declared key, not a widening of a published accept set. RIGHT. The key, its boolean domain and its per-type meaning are all published by the spec on main; the refusal the diff removes is the spec's own stated interim ("until each face has its arm"), and ruling A orders exactly one arm per compile surface. Absence from FILTER_OPERATORS is the staging mechanism for driver-memory's gate, not this package's accept set, and the $like precedent (Filter AST: like is folded to $contains at the wire — wildcards bind as literals and driver-sql's like/ilike arm is unreachable #7536) landed per-face arms ahead of that array the same way. Under execution-duties ("条款②只指已发布契约面,拉回已声明契约不触它") this arm on its own reads no.
  2. Accept set, the read-scope compiler (compileScopedFilterToSql). $empty now compiles; before the diff it fell into the default: arm as an unsupported operator, READ_SCOPE_COMPILE_FAILED / 500. Same reading as 1: RIGHT. The default: arm itself is unchanged (500, message withheld) and pinned with $bogus — right under the analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 ruling the module header records (re-affirmed [spec] 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, extended by security: the analytics ObjectQL execute face answers a row-level read scope it cannot run with INVALID_FILTER / 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995): the scope is ctx.getReadScope(object) output (the security service's compiled sharing rules or the host's getReadScope option), never the query caller's input. This is the card's named question, answered in code and in the PR, not changed silently.
  3. Boolean-domain gate. $empty joins $null / $exists in both faces' non-boolean refusal (assertBooleanFlagComparands, assertBooleanNullFlags) and in assertDefinedComparands' skip list, so an undefined flag still reaches the boolean gate. A narrowing relative to reading === true on anything, matching z.boolean(). RIGHT, pinned on both faces including under $not.
  4. RLS over-admission audit — can any arm make a read scope admit MORE rows than its declared semantics (a dropped or TRUE-reading predicate), on every strategy and dialect the diff compiles for?
    • null_only: col IS NULL / col IS NOT NULL. Total.
    • text: (col IS NULL OR col = '') and its complement (IS NOT NULL AND not-equal ''), the '' bound, dialect-free. Total.
    • multi_value: (col IS NULL OR L) / (col IS NOT NULL AND NOT L). SQLite: L is a CASE guarded by json_valid with ELSE 0, so a malformed text, '', or a non-array JSON value reads FALSE, never NULL, never TRUE. Postgres: jsonb equality with '[]' — TRUE/FALSE for any JSON value; a malformed text column errors (a database error, fail-closed) rather than reading TRUE. MySQL: JSON_TYPE = 'ARRAY' AND JSON_LENGTH = 0 — same. Unknown dialect: L has no construct, the compiler answers null and BOTH callers refuse (whereEmptyLeafSql throws INVALID_FILTER / 400; compileEmptyOperator throws READ_SCOPE_COMPILE_FAILED / 500) — null is never read as "no constraint".
    • NULL column: TRUE OR x / FALSE AND x, so both polarities are total; operatorIsNullTotal('$empty') = true is therefore exact and NOT (…) is the exact complement; nullValueSatisfiesOperator('$empty') = (value === true) is right (null is empty on every row).
    • No declaration for the field, or no hook at all (a caller of the exported compiler that passes no declaredValueShape, a bare spec StrategyContext): refused before anything binds, params stays aligned — the same refusal class as before the diff.
    • Every consumer of the lowered tree on the head, enumerated by reading: NativeSQLStrategy.buildFilterClause compiles the leaf BEFORE its values.length === 0 drop; ObjectQLStrategy.buildFilterClauseSql (the echo) the same, via the same function; ObjectQLStrategy.convertFilter hands { $empty: true | false } to the engine verbatim, and every engine driver refuses it loudly per the spec's measured table (driver-sql measured by the dev today; driver-memory's Unsupported filter operator; driver-mongodb). No fourth walker reads operator. Nothing drops, nothing reads TRUE.
      Verdict on the RLS question: no arm admits more rows than the declared semantics on any strategy or dialect this diff compiles for. RIGHT. One host-composition residue is in ③.6.
  5. Public surface, symbol by symbol, against the package's exports map (only .) and src/index.ts on the head:
    • StrategyContext — UNTOUCHED. It is the spec's interface (packages/spec/src/contracts/analytics-service.ts:265), re-exported unchanged. The brief's premise that the diff adds declaredValueShape? to it is not what the diff does; the member is added to DatasetScopedStrategyContext (strategies/types.ts).
    • DatasetScopedStrategyContext.declaredValueShape? — NOT PUBLISHED, no trigger. RIGHT. The entry re-exports only AnalyticsStrategy, StrategyContext, AnalyticsDriverCapabilities from ./strategies/types.js; the interface's only typed uses are AnalyticsService's private baseCtx field and private callCtx method (private members are emitted typeless), and the strategies cast to it internally while their public methods take the spec's StrategyContext. Under contract-review.md ("从入口类型图不可达、且 exports 不可寻址的 .d.ts 是出货字节,⛔ 不是已发布接受集") it is shipped bytes. The same holds for empty-operator-sql.ts's exports (declaredValueShapeResolver, emptyOperatorPredicateSql, whereEmptyLeafSql, EmptyOperatorSqlRequest): package-internal, unreachable from the entry.
    • AnalyticsServiceConfig.sourceFieldMeta — PUBLISHED and WIDENED. AnalyticsServiceConfig is exported by name from the entry (export type { AnalyticsServiceConfig }). The hook's return shape gains multiple?: boolean, a key a host may now write and that the runtime now reads (AnalyticsService.declaredValueShape, AnalyticsServicePlugin relays it). Before the diff a contextually-typed return literal carrying multiple was an excess-property error; after it, it is the way a host declares a list-valued field. The public surface grows by one authorable member. The claim of no is WRONG on this symbol.
    • compileScopedFilterToSql — PUBLISHED and WIDENED. Exported by name; its third parameter is ReadScopeCompileOptions, reachable from the entry type graph through that signature, and it gains declaredValueShape?. The accept set of an exported function grows by one option. WRONG on this symbol too. The changeset's own prose names both under "Host API".
    • AnalyticsServicePluginOptions — unchanged (plugin.ts changes the hook body only). NativeSQLStrategy / ObjectQLStrategy public signatures — unchanged. FILTER_OPERATORS, the is_empty / is_not_empty lowering, packages/spec — untouched; the staging is honoured. RIGHT.
    • The mechanical tell family (T1–T4, check-widening-tells.mjs) polices packages/spec shapes and the spec api-surface only, and the live --pair sweep is retired, so nothing mechanical would have flagged these two members: this seat-level reading is the live control the rules name for the direction claim.
  6. Prose. The where face's unsupported-operator refusal now lists $empty among the supported names; the read-scope non-boolean refusal names three flags. Consistent with the arms. RIGHT.

② Semver level

  • Declared: .changeset/20445-analytics-empty-operator-arms.md front matter '@objectstack/service-analytics': patch, its body, the PR body (line 3) and the claim 5875970963 all carry the line Clause-②: no.
  • Ruling: WRONG. The runtime $empty behaviour alone would have read no (①.1–2), but the diff also adds two entry-reachable published members (①.5): multiple? on AnalyticsServiceConfig.sourceFieldMeta's return shape and declaredValueShape? on compileScopedFilterToSql's options. Rules that decide it: AGENTS.md Post-Task Checklist — the declaration is yes|no plus at most one arm from the closed pair, and "yes takes at least minor"; execution-duties — the criterion is "本卡放宽接受集或扩大公开面吗"; lanes/spec.md — "放宽接受集或扩大公开面的卡,不论多小,即条款②" and "公开导出面增删" is one of the five contract-surface shapes; contract-review.md — the published surface is judged by the package's exports map, and both members are reachable from .. Landing-operations names a wrong no an auditable false declaration ("错误的 no 是可审计的假申报"), which is why this is FAIL and not a note.
  • Precedent in this very package: CHANGELOG 17.4.0 "Minor Changes" carries 54bb2f1 (new optional AnalyticsServiceConfig.sqlDialect, service-analytics: all three SQL compilers emit a plain LIKE for the case-sensitive $contains family, which folds ASCII case on SQLite — the read scope and the native where admit rows the #4706 contract excludes #15684) and a646120 (new optional nonTextColumn on compileScopedFilterToSql's options, driver-memory's reference matcher answers $notContains NO for every valued NON-STRING row — the live mingo path answers YES #14079) — the identical two shapes, leveled minor.
  • Exact correction (one patch round):
    1. changeset front matter → '@objectstack/service-analytics': minor;
    2. the Clause-② line on all three carriers — the changeset body, the PR body's line-start line, and the claim comment (corrected by the seat, as the ACCEPT record already provides) — → yes (widening), arm (widening) from the closed pair;
    3. nothing else: not breaking, so no ADR-0087 marker; no skip-changeset; no spec edit; no api-surface baseline (this package carries none); the fix: title may stay — the level follows the published-surface delta, not the verb.

③ Boundary flags

  1. deviations[0] — file surface beyond the claim's two faces (six more service-analytics files). Answered, RIGHT to extend: the where face's leaf has three consumers and an unhandled leaf answers null = no clause = TRUE, the exact widening the RLS note asks about; the declared-shape channel (multiple) did not exist. The claim's prohibited items (packages/spec, FILTER_OPERATORS, the lowering flip) are untouched and the ACCEPT amends the surface on record. No escalation.
  2. deviations[1] / open_questions[0] — ObjectQL echo renders the declared $empty arm while ObjectQL execute is refused by the engine until filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444. The seat answered A. Concur: no row is served wrong (execute refuses INVALID_FILTER / 400 loudly, measured on driver-sql; the spec's table has every engine driver refusing), and under B the divergence would outlive filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 with nothing turning red to prompt the flip. The window closes when the sibling lands; the carrier is already knocked to filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444. No escalation.
  3. deviations[2] — attribution trailer choice. No contract bearing; not judged here.
  4. The card's 400-vs-500 question (read-scope unknown operator). Stated in code at compileOperator's default: arm and in the PR: 500 kept. RIGHT: an accepted ruling binds until superseded (Prime Directive [WIP] Add Chinese version of the documentation #13); analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 closed 400 on this face for misattribution and policy disclosure, and the triage reading's premise ("the caller authored it") does not hold here — the scope's producer is the security service or the host's getReadScope. Pinned with $bogus. No escalation.
  5. NOT MEASURED — Postgres / MySQL execution of the multi-value arm (compiled strings pinned only; no live analytics job). Stated honestly. Read here (①.4): neither string can read TRUE for a non-empty value; the failure mode on malformed storage is a database error, fail-closed. One dialect note, not new to this diff: the text arm's bound '' inherits MySQL's PAD SPACE equality on collations that have it, exactly as $eq: '' already does there. Accepted as a stated gap. No escalation.
  6. Residual population (this review's, not a dev flag). A host whose sourceFieldMeta answers type without multiple has a multi-capable field declared multiple: true (select / radio / lookup / user / file / image) read as null-only, so { f: { $empty: false } } in a read scope admits rows holding [] on that field. The changeset discloses the mechanism ("read as single-valued") but not its read-scope direction. Reading: not a FAIL ground. The spec's own isMultiValueField requires multiple === true, so an absent flag reads as the field's default; the compiler reads the declaration the host hands it, and a host that strips a declared multiple hands a false declaration — the producer to fix (Prime Directive Add comprehensive test suite for Zod schema validation #12). The in-repo composition (AnalyticsServicePlugin) relays it. Recommended for the same patch round, not required: one clause in the changeset naming that on a host omitting multiple, a read scope's $empty: false over such a field admits the empty list.
  7. Out-of-scope findings (4) — carriers assigned in the ACCEPT (the flip card for three, filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 for one). Nothing here changes them.
  8. Checks on the head at 2026-09-28T19:25Z: 34 names, 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke — path-skipped / opt-in), 0 failure, 0 in progress. All green; the FAIL rests on ② alone.

Implemented-by: claude/issue-20445-analytics-empty-operator-arms
Reviewed-by: session_017B6YKCGu8CTY2KBWgwaHAs

VERDICT: FAIL

Rendered by an isolated contract-review subagent and adopted by the domain:services seat (#6021, session_017B6YKCGu8CTY2KBWgwaHAs) at 2026-09-28T19:30Z after a transcript check: 85 harness model stamps, all at CONTRACT_REVIEW_TIER, zero fallbacks; reads only, one Write confined to its own scratch record, zero GitHub writes. The seat takes the correction as ruled: a patch round on this PR moves the changeset to minor and every Clause-② carrier to yes (widening), and carries the recommended residual clause. The seat corrects the claim line and the PR body line itself.


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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 611daa435f9947e1e13cb85b8b4121a8a4123e76
Local-runs: none

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 ## Contract review record 5876996555, FAIL on 6a07f8cc, included — and the net diff against main: merge base fc0db22b on both the PR's recorded base 45f428d8 and origin/main 2b24b8b8, 11 files, +1089 / −18), file contents at the head through a fetched review ref and the spec on origin/main, and the head's check-runs (read 2026-09-28T19:53Z: 34 names, 31 success, 3 skipped, 0 failure, 0 in progress, every run on 611daa43). Nothing was built, run or re-run.

Delta since the prior record, proven from git. git diff --name-status 6a07f8cc 611daa43 names exactly one path, .changeset/20445-analytics-empty-operator-arms.md, +3 / −3; git log 6a07f8cc..611daa43 is one commit, 611daa43 (chore(changeset): …), whose sole parent is 6a07f8cc. No source, test, spec or entry file moved: packages/services/service-analytics/src/index.ts and package.json are byte-identical to the merge base, and the net diff against main is the same 11 files the prior record judged. Hence the prior record's ① judgments carry to this head unchanged; they are restated below with what was re-read here.

① Derived judgments

The spec on main, which every judgment is read against: FieldOperatorsSchema / SpecialOperatorSchema declare $empty: z.boolean() with the ruled per-type table as its meaning and say the executors refuse it until each face has its arm; filter-empty-operator.ts exports the one expansion (expandEmptyOperator, EMPTY_OPERATOR_ARMS), keyed on isMultiValueField (MULTI_OPTION_TYPES always; MULTI_CAPABLE_TYPES = select / radio / lookup / user / file / image only with multiple === true) and STRING_VALUE_TYPES; FILTER_OPERATORS deliberately omits $empty (「照 $like 先例分阶段」).

  1. Accept set, the where face (/analytics/query, /analytics/sql, dataset filters). fieldLeaves lowers $empty: true | false to a valueless empty / notEmpty leaf where it used to refuse INVALID_FILTER / 400; the leaf is answered by the field's declared row. RIGHT, and on its own not a Clause-② widening: the key, its boolean domain and its per-type meaning are already published by the spec; the refusal removed is the spec's own stated interim, and ruling A on [Decision] How does a filter say 「is empty」 on a multi-value field? A declared $empty operator, or reopen the empty-list refusal (ruling B on #20311, its third arm) #20399 orders one arm per compile surface. Carries.
  2. Accept set, the read-scope compiler (compileScopedFilterToSql). A $empty arm in compileOperator → compileEmptyOperator, where the operator used to fall into default: (READ_SCOPE_COMPILE_FAILED / 500). The default: arm itself is unchanged — 500, message withheld, per the analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 ruling the module header records (re-affirmed [spec] 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, extended by security: the analytics ObjectQL execute face answers a row-level read scope it cannot run with INVALID_FILTER / 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995) — and pinned with $bogus. RIGHT. Carries.
  3. Boolean-domain gate. $empty joins $null / $exists on both faces (BOOLEAN_FLAG_OPERATORS in read-scope-sql.ts; FLAG_MEANINGS / isBooleanFlagOperator in filter-normalizer.ts) and in both assertDefinedComparands skip lists, so a non-boolean flag — the string "true" included — is refused rather than compiled to the false arm. A narrowing to the declared z.boolean(). RIGHT. Carries.
  4. RLS over-admission audit — does any arm admit more rows than the declared semantics on any strategy or dialect the diff compiles for? Re-read at this head, empty-operator-sql.ts and both callers:
    • null_only: col IS NULL / col IS NOT NULL. Total.
    • text: (col IS NULL OR col = '') and its complement (IS NOT NULL AND not-equal ''), the '' bound. Total, dialect-free.
    • multi_value: (col IS NULL OR L) / (col IS NOT NULL AND NOT L). SQLite L is a CASE guarded by json_valid with ELSE 0, so malformed text, '' and a non-array JSON value read FALSE, never NULL or TRUE; Postgres jsonb equality with '[]'; MySQL JSON_TYPE = 'ARRAY' AND JSON_LENGTH = 0. On the 'unknown' dialect L has no construct, emptyOperatorPredicateSql answers null having bound nothing, and BOTH callers refuse (whereEmptyLeafSql → INVALID_FILTER / 400; compileEmptyOperator → READ_SCOPE_COMPILE_FAILED / 500) — null is never read as "no constraint".
    • NULL column: both polarities spell their NULL case out, so operatorIsNullTotal('$empty') = true is exact on both faces and NOT (…) is the exact complement; nullValueSatisfiesOperator('$empty') = (value === true) is right on every row.
    • No declaration (no declaredValueShape option, a bare spec StrategyContext, an unknown field): refused before anything binds, params aligned — the refusal class the face had before the diff.
    • Every consumer of the lowered tree: NativeSQLStrategy.buildFilterClause answers the leaf ahead of its empty-values drop; ObjectQLStrategy.buildFilterClauseSql (the echo) through the same function; ObjectQLStrategy.convertFilter hands { $empty: true | false } to the engine verbatim, where every engine driver refuses it until filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444. Nothing drops, nothing reads TRUE.
      No arm admits more than the declared semantics. RIGHT. Carries. The one host-composition residue is ③.6, now disclosed in the changeset.
  5. Public surface, against the package's exports map (only .) and src/index.ts at the head, both unchanged:
    • StrategyContext — the spec's interface, re-exported untouched. UNTOUCHED.
    • DatasetScopedStrategyContext.declaredValueShape? (strategies/types.ts) and the exports of empty-operator-sql.ts (declaredValueShapeResolver, emptyOperatorPredicateSql, whereEmptyLeafSql, EmptyOperatorSqlRequest) — not re-exported from the entry, unreachable from its type graph (private baseCtx / callCtx; the strategies cast internally). Shipped bytes, not a published accept set. NOT PUBLISHED, no trigger. RIGHT.
    • AnalyticsServiceConfig.sourceFieldMeta — exported by name; its return shape gains multiple?: boolean, a key a host may now write and the runtime reads. PUBLISHED and WIDENED.
    • compileScopedFilterToSql — exported by name; ReadScopeCompileOptions (its third parameter, reachable through the signature) gains declaredValueShape?. PUBLISHED and WIDENED.
    • AnalyticsServicePluginOptions, the strategies' public signatures, FILTER_OPERATORS, the is_empty / is_not_empty lowering and packages/spec — unchanged; the staging is honoured. RIGHT.
      Two published optional members added ⇒ the declaration must read yes (widening) and the level at least minor. At 6a07f8cc it read no / patch (WRONG, the prior FAIL); at this head it reads yes (widening) / minor on every carrier (②). Now RIGHT.
  6. Prose. The where refusal's supported-operator list now names $empty; the read-scope non-boolean refusal names three flags; the changeset (a contract-review face) names both published members and the host-omission residue. Consistent with the arms; the residue's accuracy is judged in ③.6. RIGHT.

② Semver level

  • Declared at this head. Changeset front matter '@objectstack/service-analytics': minor; the changeset body's line-start declaration reads yes (widening); the PR body's line 3 (its only line-start declaration; the "Seat append" mentions it inline only) reads yes (widening); the claim comment 5875970963, edited by the seat at 19:31:28Z, carries one line-start declaration reading yes (widening) and quotes it inline in a sub-note. All three carriers agree with the front matter.
  • Ruling: RIGHT. Two entry-reachable optional published members (①.5) make the diff a widening of the public surface: the declaration is yes with the arm (widening) from the closed pair, and yes takes at least minor (AGENTS.md Post-Task Checklist 3; execution-duties 「本卡放宽接受集或扩大公开面吗」; lanes/spec.md 「不论多小,即条款②」). Not breaking — both members optional, nothing removed, renamed or narrowed — so minor is the exact level, no ADR-0087 disposition marker is owed and none is present, and skip-changeset is neither owed nor on the PR (labels: documentation, size/xl, tests, tooling, needs:contract-review). Precedent in this package's own CHANGELOG 17.4.0 "Minor Changes": 54bb2f1 (new optional AnalyticsServiceConfig.sqlDialect) and the new optional nonTextColumn option on compileScopedFilterToSql — the identical two shapes, levelled minor.
  • The prior record's "Exact correction", item by item. (1) front matter patch → minor: DONE. (2) the declaration line on all three carriers → yes (widening): DONE on the changeset body (the one commit), the PR body and the claim comment (both corrected in place by the seat). (3) nothing else: no ADR-0087 marker (none), no skip-changeset (absent), no spec edit (file list unchanged), no api-surface baseline (this package carries none), the fix: title kept — DONE, nothing beyond the order was touched. The recommended residual clause: PRESENT (③.6).
  • Check Changeset on this head: success — the gate's level axis (a PR declaring yes must grade at least one package whose published source it moves minor or above) is satisfied by the front matter, and check-adr-0087-registration inside the green lint job reads a non-breaking changeset. The mechanical read and this seat-level read agree.

③ Boundary flags

  1. Round-1 deviations[0] — six service-analytics files beyond the claim's two faces. Carries: RIGHT to extend (an unhandled leaf would answer null = no clause = TRUE, the exact RLS widening; the multiple channel did not exist); the prohibited items (packages/spec, FILTER_OPERATORS, the lowering flip) are untouched; the ACCEPT amended the surface on record. No escalation.
  2. Round-1 open_questions[0] / deviations[1] — the ObjectQL echo renders the declared $empty arm while ObjectQL execute is refused by the engine until filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444. The seat answered A; concur: no row is served wrong, and under B the divergence would outlive filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 with nothing turning red. Carrier knocked to filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444. No escalation.
  3. Round-1 deviations[2] — attribution trailer choice. No contract bearing.
  4. The card's 400-vs-500 question. 500 kept, stated in code at compileOperator's default: arm and in the PR; RIGHT under Prime Directive [WIP] Add Chinese version of the documentation #13 (analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 binds until superseded; the scope's producer is the security service or the host's getReadScope, never the query caller); pinned with $bogus. No escalation.
  5. NOT MEASURED — Postgres / MySQL execution of the multi-value arm. Unchanged since round 1 (no live analytics job in the PG + MySQL lane); compiled strings pinned; neither string can read TRUE for a non-empty value, and malformed storage fails as a database error, fail-closed. Accepted as a stated gap. No escalation.
  6. The recommended residual clause, judged against the code at this head. The changeset now says: a host whose sourceFieldMeta answers type but not multiple has every multi-capable field it declared multiple: true (select / radio / lookup / user / file / image) read as single-valued, the null-only row; on such a field a read scope's $empty: false admits rows holding [] and $empty: true misses them; relay the field's multiple to get the list row. ACCURATE on every clause: AnalyticsService.declaredValueShape maps the hook's answer to { type, multiple: meta.multiple === true }, so an omitted flag is false; expandEmptyOperator reaches multi_value only through isMultiValueField, which for the multi-capable set requires multiple === true, and that set on main is exactly the six types the clause lists, none of them text-like, so the field lands on null_only; that row compiles col IS NULL / col IS NOT NULL, under which a stored [] is admitted by $empty: false and missed by $empty: true. The multi-option types (multiselect, checkboxes, tags) are unaffected by the omission, and the clause correctly scopes itself to the multi-capable ones. The in-repo composition (AnalyticsServicePlugin) relays multiple: f.multiple === true. Not a FAIL ground, as ruled before: the compiler reads the declaration it is handed, and a host that strips a declared multiple hands a false declaration — the producer to fix (Prime Directive Add comprehensive test suite for Zod schema validation #12); the residue is now disclosed with its read-scope direction and its remedy. No escalation.
  7. Round-2 report 5877331052 carries deviations: [] and open_questions: []; its claim that only the changeset moved is what git shows. Nothing new to answer.
  8. Out-of-scope findings (4) — carriers assigned in the ACCEPT (the flip card for three, filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 for one). Unchanged.
  9. Checks on this head at 2026-09-28T19:53Z: 34 names, 31 success, 3 skipped, 0 failure, 0 in progress. The seven required contexts — Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — are all success; Check Changeset is success. The three skips are expected by construction: Build Docs and Console Pin Gate are gated on the filter job's docs / console outputs, whose path lists (apps/docs/**, content/**, pnpm-lock.yaml, ci.yml; .objectui-sha, five console scripts, ci.yml) match none of the 11 changed paths; Packed-tarball smoke (opt-in) runs only under the needs:pack-smoke label, which the PR does not carry.
  10. needs:contract-review is on the PR. Per contract-review.md a PASS record for the current head lifts it by the seat that writes the record — a seat write; none is made here.

Implemented-by: claude/issue-20445-analytics-empty-operator-arms
Reviewed-by: session_017B6YKCGu8CTY2KBWgwaHAs

VERDICT: PASS

Rendered by an isolated contract-review subagent and adopted by the domain:services seat (#6021, session_017B6YKCGu8CTY2KBWgwaHAs) at 2026-09-28T20:00Z after a transcript check: 71 harness model stamps, all at CONTRACT_REVIEW_TIER, zero fallbacks; reads only, one Write confined to its own scratch record, zero GitHub writes. This PASS on head 611daa43 supersedes FAIL 5876996555 (head 6a07f8cc). The seat lifts needs:contract-review in this round.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 20:04
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 2b53993 Sep 28, 2026
39 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20445-analytics-empty-operator-arms branch September 28, 2026 20:26
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: service-analytics' two filter faces answer $empty by the field's declared type (read-scope SQL, the analytics where) — ruling A on #20399

2 participants