Skip to content

feat(spec)!: $empty joins FILTER_OPERATORS, and is_empty / is_not_empty lower to it (#20446) - #20570

Merged
os-justin merged 18 commits into
mainfrom
claude/issue-20446-empty-joins-filter-operators
Sep 29, 2026
Merged

os-justin merged 18 commits into
mainfrom
claude/issue-20446-empty-joins-filter-operators

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Closes #20446

$empty joins FILTER_OPERATORS, and the view operators is_empty / is_not_empty lower to $empty: true | false instead of $null. This is the last step of ruling A on #20399, built as option A under the seat's rulings 5881406205, 5881556735 and 5886202626 on the card.

  • A stored 「is empty」 is answered by the field's declared type: a text field also finds '', a multi-value field also finds [], and is_not_empty is the exact complement. canonicalAstOperator folds the empty pair onto its own names, and driver-memory's QueryAST-node arm follows it.
  • BREAKING, declared as a narrowing. A { $empty: … } written as a field value is refused (VALIDATION_FAILED). is_empty / is_not_empty where no face holds the column's declaration is refused. Stored metadata meets that refusal on the built-in id, a federated object on a driver without registerExternalObject, an AnalyticsService built without sourceFieldMeta, and a multi-value column on a SQL dialect driver-sql does not model.
  • ADR-0087 D3 entry filter-is-empty-lowers-to-empty-operator. The staging texts are retired; the operator-enumeration tests and the service-analytics README follow.

Clause-②: yes (narrowing)


Generated by Claude Code

The measured diff of the pre-flight: `$empty` joins `FILTER_OPERATORS`, and
`is_empty` / `isempty` / `is_not_empty` / `isnotempty` lower to
`$empty: true | false` in `AST_OPERATOR_MAP`, the array-sugar lowering and
`canonicalAstOperator`. Texts, tables, pins and the changeset are NOT done:
the pre-flight measured the diff refusing inputs accepted today (a record
write carrying `{ $empty: ... }` as a text value; a lowered `is_empty` on a
column the driver holds no declaration for), so the work stopped at the
report, as ordered. Tests in this tree still assert the staging and are red
on purpose.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…bles and the QueryAST arm follow (WIP)

Retires the staging texts the flip falsifies, adds `$empty` to every
operator-enumeration table that goes red, un-partitions the engine number
door's `$empty` row, and aligns driver-memory's QueryAST-node `is_empty`
case with the canonical fold. Pins, changeset and generated artifacts
follow.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…1 and N2 refusals (WIP)

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…numeration (WIP)

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…check:generated --fix)

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…wing) (WIP, marker pending the gate)

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…et carries its registration marker

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…table, as the engine does

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
One content conflict, in
packages/objectql/src/engine-number-comparand-declared-type-door.test.ts,
at the GUARD's partition lines. Both truths kept: this branch's removal of
the STAGED partition (the $empty row is one of PASSES, driven end to end)
and #20502's refused form-set, which adds NON_NUMERIC_VALUE_FORMS beside
NON_NUMERIC_STRING_FORMS.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:data protocol:ui labels Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 7 package(s): @objectstack/driver-memory, @objectstack/driver-sql, @objectstack/driver-turso, @objectstack/formula, @objectstack/objectql, @objectstack/service-analytics, @objectstack/spec, touching 20 documentable anchor(s). ⚠️ 9 changed file(s) yielded no anchor (packages/objectql/src/having-filter.ts, packages/services/service-analytics/README.md, packages/services/service-analytics/src/read-scope-sql.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/deployment/cli.mdx (via is_null (literal, a string literal in canonicalAstOperator; a string literal in convertComparison; a string literal in convertConditionToMongo))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

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

  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via AST_OPERATOR_MAP (symbol, a top-level const object))
  • content/docs/releases/v17/17-5.mdx (via FILTER_OPERATORS (symbol, a top-level const object), SqlDriver (symbol, a top-level class), is_empty (literal, a string literal in AST_OPERATOR_MAP; a string literal in canonicalAstOperator; a string literal in convertComparison; a string literal in convertConditionToMongo))

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 changed file(s) yielded no anchor (packages/objectql/src/having-filter.ts, packages/services/service-analytics/README.md, packages/services/service-analytics/src/read-scope-sql.ts, …) — pages documenting those are invisible to this run
  • 4 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 — 142 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 542670da6dfc17d3a2ec919139afb7caae018d02 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2b34b21e459a1dd54bd26a09b21718084040fb7 — the merge of head c96e1feacafd896e4b646dcb5e3b2c0480a01c40 into base 542670da6dfc17d3a2ec919139afb7caae018d02, 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 b2b34b21e459a1dd54bd26a09b21718084040fb7 && git checkout b2b34b21e459a1dd54bd26a09b21718084040fb7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 542670da6dfc17d3a2ec919139afb7caae018d02 c96e1feacafd896e4b646dcb5e3b2c0480a01c40 && git checkout -B drift-repro 542670da6dfc17d3a2ec919139afb7caae018d02 && git merge --no-ff c96e1feacafd896e4b646dcb5e3b2c0480a01c40

node scripts/docs-audit/affected-docs.mjs --json 542670da6dfc17d3a2ec919139afb7caae018d02

⚠️ 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 542670da6dfc17d3a2ec919139afb7caae018d02 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… what was decided, not which card

The migrate-meta guidance pin (packages/cli/test/migrate-meta-engine-guidance.test.ts)
covers the filter- family: no author-shown field of a covered entry may cite a
tracker id. This entry's reason cited six, plus three comment record ids. Each
sentence now states the decision in words; ADR ids stay. A cut: the reason is
one line shorter. The registry is regenerated by gen:migration-registry;
spec-changes.json and the upgrade guide carry no protocol-18 entry yet, and
their generators wrote no change.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…y record key alone

main retired the inner `name` on cube measures and dimensions (the record key
is the member's name), and spec's tree-scoped absence pin
(cube-member-inner-name-retirement.test.ts) refuses any authoring of it inside
its radius. This PR's where-empty-flip-host.test.ts, written before that
retirement landed and merged in with it, still spelled `name` on its one
measure and one dimension. Both are dropped; the host test's three pins are
unchanged and still pass.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 395c53182a5e42e68b262ebbe3150a630e02b2fa
Local-runs: none

Inputs: card #20446 (body and all 13 comments, rulings 5881406205, 5881556735, 5886202626 and the six os-dev-report comments included), PR #20570 (body, 45-file list, net diff origin/main...395c531, merge base c1d8051e0a), the 35 check-runs on the head, and the cited texts (#20399 ruling A 5865693155, #20311 ruling B 5861435168, the amendment 5868169573, ADR-0087 §level, AGENTS.md §Post-Task 3). Read-only: git show / git grep / git diff against the fetched refs, unauthenticated REST GETs.

① Derived judgments

Accept-set changes the diff implies — each judged.

  1. FILTER_OPERATORS gains $empty (filter.zod.ts:3127), so ALL_OPERATORS and the OperatorKey union widen — RIGHT. Every face reached from that array holds an arm since 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 / analytics: service-analytics' two filter faces answer $empty by the field's declared type (read-scope SQL, the analytics where) — ruling A on #20399 #20445 (read at the head: memory emptyOperatorCondition, sql applyEmptyOperator, mongodb translateFilter + valueShapeFor, turso pushEmptyOperator, analytics whereEmptyLeafSql / compileEmptyOperator, formula evalOp, having checkCondition), so no face drops the predicate — the staging's one reason. STAGED_AHEAD_OF_BACKENDS drops it; $like / $ilike stay.
  2. AST_OPERATOR_MAP and convertComparison lower is_empty / isempty to { f: { $empty: true } } and is_not_empty / isnotempty to { f: { $empty: false } }; direction from the NAME, filler ignored (pinned for true, false, 'x', undefined, nested under or) — RIGHT, ruling A verbatim. is_null / is_not_null keep $null (pinned).
  3. canonicalAstOperator folds the empty pair onto is_empty / is_not_empty instead of is_null / is_not_null — RIGHT. Its two non-test consumers: driver-memory's QueryAST node path (aligned in this diff, item 5) and packages/lint/src/validate-preset-comparands.ts:676, which tests the canonical name only against the ordering / between / equality / membership sets — is_null and is_empty were both outside those sets before and are after, so the lint's verdict on every input is unchanged.
  4. The write door (record-validator.ts:528, FILTER_OPERATOR_KEYS = ALL_OPERATORS + retired) and plugin-security's mirror (position-catalog-refusal.ts:253) now count $empty as an operator key — RIGHT, the 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 derivation working as designed; N1 below.
  5. driver-memory memory-driver.ts:1464-1468: the QueryAST-node is_empty / empty and is_not_empty / not_empty / notempty cases route to emptyOperatorCondition instead of { field: null } / { $ne: null } — RIGHT (ruling 1's surface item); one rule gets one answer in either shape, pinned against the lowered rule on five cells and refused on id.
  6. driver-memory filter-refusal.ts: the by-hand '$empty' entry is removed; the set derives it after $exists — RIGHT; the accepted set is identical, only the rank and the refusal message's list order move (item E-b).
  7. view-grouping-query.ts: the empty-group predicate stays $null, the note is corrected — RIGHT (the card allowed either; a group key is one stored value, '' is its own bucket).
  8. conversions/registry.ts ruleOperatorForFilterOperator: $empty passes the membership check and is then declined with $null / $exists — verdict unchanged before and after, docblock count moves; pinned (declined = ['$empty', '$exists', '$null']).

Newly REFUSED inputs, per face, and whether the changeset's FROM → TO names each.

  • Write door (objectql insert / update, plugin-security position catalog): { f: { $empty: true|false } } as a field value → VALIDATION_FAILED / invalid_type, message "$empty is a filter operator, not a value … a filter belongs in the query 'where'" (record-validator.ts; pinned end to end, driver never written). NAMED (N1 line). plugin-security only hands such a value to the engine's refusal; no second refusal.
  • driver-memory, driver-sql, driver-mongodb, driver-turso remote: a lowered is_empty / is_not_empty on a column the face holds no declaration for → INVALID_FILTER / 400 prescribing $null (undeclaredEmptyOperatorFieldError on each; mongodb's via unsupportedFilterError, code INVALID_FILTER, status 400, read at mongodb-filter.ts:423-427). Reached from a stored rule by: the built-in id (no object declares it; pinned on memory and sql) — NAMED; a federated object on a driver with no registerExternalObject (memory and mongodb hold none, git grep count 0; plugin.ts:1437 puts it on unsupported and :1455 logs the NOT-bound error naming it; pinned on memory) — NAMED; and the N3 class, a driver driven directly on a table it was never handed (the two harnesses) — NOT in FROM → TO, by ruling 5886202626, whose production-caller test I re-read: no non-test source in packages/metadata* or packages/cli names is_empty, is_not_empty, isempty or $empty, and the one view-rule lowering path (rest/view-filter-rule-lowering.ts → findData) runs on a declared object.
  • driver-sql (and its heirs): a multi-value column on a knex client whose dialectName is 'unknown' — anything but the isSqlite / isPostgres / isMysql sets (sql-driver.ts:13329-13334) → emptyListUnsupportedDialectError, INVALID_FILTER / 400, $null prescribed; text and null-only rows compile. NAMED, and measured in this round on a real mssql client (pinned), not code-read.
  • service-analytics where door: a host without sourceFieldMeta → invalidFilterError, INVALID_FILTER / 400, $null prescribed, no SQL run (pinned on generateSql and query). NAMED. The read-scope compiler on such a host → READ_SCOPE_COMPILE_FAILED / 500 (read-scope-sql.ts:2088). NAMED as the envelope. The in-repo production host (plugin.ts:1273) wires sourceFieldMeta from the registry.
  • Faces that refuse nothing new: formula matchesFilterCondition, objectql having, driver-memory's reference matcher (all by value, ruling A's reading for a face without declarations); driver-memory's cube face and the analytics draft preview refused $null before and refuse $empty after (same verdict class). The RLS / CEL lowering in plugin-security and plugin-auth emits neither is_empty nor $empty (grep count 0), so no read scope changes there.

Rulings' premise, tested against the diff: after the flip, an author-declared column on an object the face was handed (syncSchema, initObjects / registerObjectMetadata / registerExternalObject, turso's inherited resolver, analytics sourceFieldMeta) is answered on every declared-row face, and refused only where the declaration is absent or (sql, analytics) the multi-value row meets the unknown dialect — exactly ruling 2 item 4's list plus the ruled N3 class. No production path reaches a refusal the FROM → TO text does not name.

Newly ANSWERED-differently inputs. (a) a stored 「is empty」 on a text-like column (STRING_VALUE_TYPES: text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) also finds ''; on a multi-value column (MULTI_OPTION_TYPES multiselect / checkboxes / tags, or MULTI_CAPABLE_TYPES select / radio / lookup / user / file / image with multiple: true) also finds []; is_not_empty loses those rows — NAMED, and the changeset's type lists match the sets byte for byte. (b) driver-memory's refusal message lists $empty after $exists rather than after $ilike; mongodb's FIELD_OPERATOR_RANK ranks it last — output ordering and wording only, not named, not a verdict. (c) driver-turso remote: a NODE-position $empty now takes the misplaced-field-operator repair instead of the "names nothing" tail (MISPLACED_FIELD_OPERATORS derives from FILTER_OPERATORS, remote-transport.ts:255); both arms throw INVALID_FILTER, which that module's docblock guarantees by design — a message change the changeset does not name, harmless. (d) driver-memory's QueryAST legacy spellings empty / not_empty / notempty take the declared arm too; the changeset names the node with is_empty only — same arm, one meaning. (e) formula: a policy filter array's is_empty now judges '' and [] as empty by value — the ruling's reading for that face, inside the headline sentence ("any filter array").

Author-shown text, sentence by sentence. Changeset — every FROM → TO line, the N1 message, the type lists, the boot-error description, the dialect clause ("a knex client other than SQLite, PostgreSQL or MySQL"), the ALL_OPERATORS claim (filter.zod.ts:3138) and the "not rewritten; re-read" sentence hold against the tree. One over-broad phrase: "refused with INVALID_FILTER / 400 (READ_SCOPE_COMPILE_FAILED / 500 on an analytics read scope) and the prescription $null" — the read-scope message prescribes fixing the scope's producer or the host's field metadata and does not spell $null; the four driver messages and the analytics where message do. D3 entry filter-is-empty-lowers-to-empty-operator: surface — "a sharing rule" is sourced by ruling B item 3 and the sharing plugin's array-form lowering through parseFilterAST (sharing-plugin.ts:1445); replacement — every clause matches a refusal read above, and the "(the boot error names the object)" clause matches plugin.ts:1455. Two over-broad sentences, wording only: reason's "which the $null lowering missed while the three builders already showed them as empty" — ruling B sources one builder's comment treating '' as empty and a multi-value builder that emitted $in: [''], which the SQL driver REFUSED (an error, not a display), so "the three builders already showed them" overstates the source; acceptanceCriteria's "it now also selects the rows holding the empty string or the empty list" is true of is_empty and the inverse of what an is_not_empty rule now does (it EXCLUDES them) — the same field names both operators in its first sentence, and the replacement and the changeset state the complement, so an author who reads the entry whole is not misled, but the sentence should say "is_empty now also selects; is_not_empty no longer does". The same field names INVALID_FILTER for the analytics host and omits the read-scope envelope the changeset names. PR body: every sentence holds (the "option A under rulings …" line, the four compositions, the D3 id, "Clause-②: yes (narrowing)").

Tests edited or deleted. filter-empty-operator.test.ts §4: the two staging pins ("not in FILTER_OPERATORS", "still emits $null") were written to be inverted by this card and are replaced by four pins that assert the inversion plus the null pair's untouched lowering and the canonical fold — kept in spirit, inverted by rule. filter-operator-vocabulary.test.ts: STAGED loses $empty; the "differ by EXACTLY the staged operators" assertion still holds by construction. filter-view-operator-parity.test.ts: KNOWN gains $empty; the direction-from-name pin moves to $empty and gains the $null pair — nothing dropped. page-component-filter-record-to-rule-array.test.ts: the declined set gains $empty; the registry docblock follows. engine-number-comparand-declared-type-door.test.ts: the STAGED partition is removed, its truth (the $empty row's verdict is passes) carried by PASSES.filter(isEmptyFlagCase) length 1 and the row now driven end to end with the driver receiving the filter as written; #20502's widened form set is kept in the GUARD (the conflict resolution reported in 5887543217 matches the file). memory-filter-ast-vocabulary.test.ts and sql-driver-null-operators.test.ts declare their tables (ruled 5886202626); the null-operators fixture stores no '', so its asserted rows are unchanged. sql-driver-json-column-operator-refusal.test.ts KEPT gains $empty (a multi-value row compiles on SQLite). mongodb-operator-key-clobber.test.ts hands the translator a text resolver and gains the $in / $nin rows. memory-analytics-echo-operator-coverage.test.ts REFUSED gains $empty (refused as $null is). The two analytics echo tables gain a $empty row and a declaredValueShape context. The *-20444-* and compile-refusal-seam edits are comment-only. No assertion was weakened; the flip makes none false.

The card's pins. Ruling A's multi-value pin ([] + null, refuses nothing) — memory and sql; the text '' pin — memory and sql; the exact complement — memory and sql on every declared field; every compile surface's conformance row with $empty in FILTER_OPERATORS — Test Core (1/6..6/6) and Temporal Conformance (live PG + MySQL) success on the head. Pre-flight item 2 (an engine-injected column) — measured in 5881367401 and 5881534257: created_by / owner_id answered; item 3 (a list holding a null element on document faces) — unpinned, carried as a stored-state note by the engine seat's notice, not this card's.

Check-runs on 395c531 (35, deduped by name, newest started_at): 33 success, 2 skipped — Console Pin Gate (path filter: needs.filter.outputs.console false, ci.yml:2337) and Packed-tarball smoke (opt-in). Green among them: Check Changeset, Lint and Repo Gates, Governed Surface Queue Guard, Spec property liveness, Build Core, Build Docs, Test Core and its six shards, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate and its three shards, Dogfood Verify CLI, TypeScript Type Check and the four Type Check jobs (source gates, consumer gates, debt ledger, workspace), the three claim guards. None still running.

② Semver level

Changeset .changeset/20446-empty-joins-filter-operators.md: @objectstack/spec minor, @objectstack/driver-memory minor, @objectstack/service-analytics patch; feat(spec)!: summary; **BREAKING** banner; Clause-②: yes (narrowing); the adr-0087 registered filter-is-empty-lowers-to-empty-operator marker, whose entry is in entries/semantic/18.* and in the regenerated registry.ts (step 18, the pending major, beside 241 siblings; PROTOCOL_VERSION 17.0.0). RIGHT: AGENTS.md §Post-Task 3 — yes takes at least minor, (narrowing) is BREAKING and a breaking changeset carries FROM → TO and one disposition marker; ADR-0087's amended level half — pre-GA a metadata-facing break ships minor with the banner and its entry (and check-changeset-no-major forbids major); the registration gate's own fixture names Clause-②: yes (narrowing) as "a diff that widens AND narrows". The arm matches the diff: a widening (text / multi-value rows) and a narrowing (N1, N2). driver-memory minor fits a functional change in its own source (the QueryAST arm). service-analytics patch fits README + comment-only source. driver-sql, driver-turso, formula and objectql publish comment-only edits from their own source and carry no bump; objectql's N1 refusal is the derived #5922 rule and the spec changeset carries its FROM → TO (update('task', { title: { $empty: true } }) → write the value). The PR body's Clause-②: yes (narrowing) line matches. Check Changeset: success.

③ Boundary flags

  • 5881367401 open question (A / B / C) — answered by ruling 5881406205: A, C refused, B not a prerequisite. out_of_scope_findings: [0] every text the flip falsifies — rewritten in the diff (verified per file above; scripts/check-driver-conformance.mjs carries no $empty sentence, nothing to fix); [1] QueryAST arm — aligned; [2] the engine notice's "front door still refuses $empty" measured false — carried by the seat for the engine lane, not this PR's; [3] STAGED's location — seat corrected; [4] two governed skills/** files list the special operators without $empty — a gap, not a false sentence; governed tier H, stays out, carried by the seat (Governed Surface Queue Guard success); [5] a model identifier in the dispatch's trailer block — seat corrected, the branch's commits carry the model-free pair.
  • 5881534257 open question (F1 / F2 / F3) — answered by ruling 5881556735: F3, premise corrected to the four compositions. Its findings: the unmodeled-dialect case — measured this round on mssql (pinned); turso inherits registerExternalObject — code-read, and sql-driver-20446 pins the SqlDriver federation path it inherits.
  • 5886160392 out_of_scope_findings: [0] the harness composition — answered by ruling 5886202626 (N3 class, edits stand, no FROM → TO line; process note recorded); [1] the refusals prescribe the operator spelling $null where the author wrote is_empty, and the analytics message says "no field metadata is wired" rather than sourceFieldMeta — wording, the changeset and the D3 replacement map to is_null / is_not_null and the README names sourceFieldMeta; acceptable, no refusal is wrong; [2] skills gap — as above; [3] Closes vs Fixes (same closing class) and STALE TREE — merged since (bd85fdfc52, 1dca3e3ff4 onto the PR base c1d8051e0a; merge-tree clean against b80ab579d8). NOT MEASURED there (skill-examples, dual-build-cjs-loads, type-check-debt, live PG / MySQL) — answered by the head's check-runs (Lint and Repo Gates, Build Core, Type Check · debt ledger, Temporal Conformance, all success).
  • 5887543217 deviations: merge commit 1dca3e3ff4 carries git's default message and no trailer pair — made by scripts/pm/os-regen-merge.sh (AGENTS.md names it the in-repo authority for such a merge), carries no model identifier (the rule's hard edge) and no card relation; check:commit-card-trailers green on push. Acceptable. Open question (guidance-pin red) — answered A and fixed in b4087e563e, the CLI pin run and green (5888035539).
  • 5888625241: the spec --project repo cube inner-name red at b4087e563e — fixed at the head by naming the host cube's members by record key alone (2 lines; the file's three pins unchanged); Test Core success on the head.
  • Residual, escalated as NOT MEASURED, not blocking: driver-mongodb's federated composition end to end on a live mongod (its code path is the same valueShapeFor undefined → refused, read at mongodb-driver.ts:839-843); turso remote on a federated object; callers of translateFilter / compileScopedFilterToSql / AnalyticsService outside this repository (cloud).
  • Wording flags for a text-only follow-up, not verdict-bearing (named in ①): the D3 acceptanceCriteria sentence that reads as if is_not_empty also gains the '' / [] rows and names only INVALID_FILTER for the analytics host; the D3 reason's "three builders already showed them as empty"; the changeset's "and the prescription $null" applied to the read-scope envelope.
  • objectui: the card defers convergence to objectui#10813 after the spec release; the Console Pin Gate was skipped by path filter on this head, so nothing here measures the pinned sibling — no sentence in the PR names it, and nothing exported is removed or renamed.

Implemented-by: claude/issue-20446-empty-joins-filter-operators
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-09-29T11:04Z · rendered by the seat's at-tier review subagent on this head; its served tier family was read from the subagent transcript before posting. The seat folds in three of the wording flags as CUTS on a new head, because the text ships to authors: the changeset in CHANGELOG.md, the D3 fields in os migrate meta. The cuts: in the D3 acceptanceCriteria, the clause that reads as if is_not_empty gains the empty rows, and "with INVALID_FILTER" for the analytics host; in the D3 reason, the "three builders" clause; in the changeset, "and the prescription $null", which the read-scope envelope does not spell. needs:contract-review stays on both carriers until the new head's record.


Generated by Claude Code

…uidance and one from its changeset

The at-tier review's wording flags, folded in as cuts:
- acceptanceCriteria: "it now also selects the rows holding the empty string
  or the empty list" read as if is_not_empty gained those rows; it loses them.
- acceptanceCriteria: "fails with INVALID_FILTER" — the analytics read scope
  fails with a different code.
- reason: "while the three builders already showed them as empty" is cut.
- changeset: "and the prescription `$null`" — the read-scope envelope's message
  does not spell it; the FROM → TO lines carry the remedy.

registry.ts regenerated by gen:migration-registry.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…refusal prescribes $null

", loudly and with the $null prescription," is cut from the entry's reason:
the analytics read-scope refusal's message does not spell $null, the same
reason the changeset bullet lost that claim. registry.ts regenerated by
gen:migration-registry.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c96e1feacafd896e4b646dcb5e3b2c0480a01c40
Local-runs: none

Inputs: card #20446 (body and all 15 comments — the engine notice 5880345266, triage's unlock 5880958298, the claim 5881012382 and its surface extension 5881034132, rulings 5881406205, 5881556735, 5886202626, and the eight os-dev-report comments through 5889430733), PR #20570 (body, the 45-file list, the net diff against main at merge base c1d8051e0a, i.e. c1d8051e0a..c96e1feaca, and the delta from the previously reviewed head 395c53182a), the 35 check-runs on c96e1feaca, the earlier ## Contract review record on the PR (5888973436, head 395c531) as a comment, and the cited texts (#20399 ruling A 5865693155, #20311 ruling B 5861435168 and the amendment 5868169573 as the card quotes them, ADR-0087 §addendum level, AGENTS.md §Post-Task 3, PD #12/#14, objectui#10813's body). Read-only: git show / git grep / git diff against the fetched refs in /home/user/objectstack, git show at the pinned .objectui-sha dd3f7e1b in /home/user/objectui, unauthenticated REST GETs. Nothing built, run or re-run.

① Derived judgments

Accept-set and public-surface changes the diff implies — each judged.

  1. FILTER_OPERATORS gains $empty after $exists (filter.zod.ts:3127); ALL_OPERATORS and the OperatorKey union widen; STAGED_AHEAD_OF_BACKENDS (filter-operator-vocabulary.test.ts:73) drops it, $like / $ilike stay — RIGHT. Every face that derives its accepted set from the array holds an arm at the head (memory emptyOperatorCondition :1854, sql applyEmptyOperator :16210, mongodb translateEmptyOperator via valueShapeFor, turso pushEmptyOperator :3985, analytics whereEmptyLeafSql / compileEmptyOperator, formula evalOp $empty :768, having :225/:628, memory reference matcher :678), so no face drops the predicate — the staging's one reason; Test Core (1/6..6/6) and Temporal Conformance (live PG + MySQL) are green on the head.
  2. AST_OPERATOR_MAP and convertComparison lower is_empty / isempty to { f: { $empty: true } } and is_not_empty / isnotempty to { f: { $empty: false } }, direction from the NAME, filler ignored (pinned for true, false, 'x', undefined, upper-case spellings, nested under or); is_null / is_not_null keep $null (pinned) — RIGHT, ruling A verbatim.
  3. canonicalAstOperator folds the empty pair onto is_empty / is_not_empty (own names) instead of is_null / is_not_null — RIGHT. In-repo non-test consumers: driver-memory's QueryAST node path (aligned, item 5) and packages/lint/src/validate-preset-comparands.ts:676, whose sets are ordering / between / equality / membership only, so is_null and is_empty were outside them before and after — no lint verdict moves. The sibling consumer is item ③-h.
  4. The write door (record-validator.ts:527 FILTER_OPERATOR_KEYS = ALL_OPERATORS + retired) and plugin-security's mirror (position-catalog-refusal.ts:252, the same two inputs) now count $empty as an operator key — RIGHT, the 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 derivation as designed (N1 below). valueMayBeAnObject (:557) is false for a text-like field, so before the join a { $empty: … } value passed filterOperatorKeysIn empty and reached the bounded-string branch (:990, String(value)), which refuses nothing — the changeset's "Before, a text-like field stored it as data" is sourced by the code and by the dev's measurement at 0368a336db.
  5. driver-memory memory-driver.ts:1465-1468: the QueryAST-node is_empty / empty and is_not_empty / not_empty / notempty cases route to emptyOperatorCondition instead of { field: null } / { $ne: null } — RIGHT (ruling 1's surface item); pinned against the lowered rule on five cells and refused on id.
  6. driver-memory filter-refusal.ts: the hand-written '$empty' entry is removed, the set derives it — RIGHT; accepted set identical, only FIELD_OPERATOR_RANK and the refusal message's list order move.
  7. view-grouping-query.ts: the empty-group predicate stays $null, the note is corrected (a group key is one stored value; '' is its own bucket) — RIGHT, the card allowed either.
  8. conversions/registry.ts ruleOperatorForFilterOperator: $empty now passes the membership check and is declined with $null / $exists — the verdict for every input is unchanged (undefined before and after); the docblock count moves, pinned (declined = ['$empty', '$exists', '$null']).

Newly REFUSED inputs, per face, and whether the changeset's FROM → TO names each.

  • Write door (objectql insert / update / updateMany; plugin-security's position-catalog mirror): a { $empty: true|false } object as a field value → VALIDATION_FAILED / invalid_type, "$empty is a filter operator, not a value — a filter belongs in the query 'where'" (pinned end to end, driver never written). NAMED (the N1 line; the mirror is the same input class and takes the same fix).
  • driver-memory, driver-sql (and heirs), driver-mongodb, driver-turso remote: a lowered is_empty / is_not_empty on a column the face holds no declaration for → INVALID_FILTER / 400 prescribing $null (undeclaredEmptyOperatorFieldError on each; mongodb's unsupportedFilterError sets code INVALID_FILTER, status 400, :423-427). Reached from a stored rule by: the built-in id (no object declares it; pinned on memory and sql) — NAMED; a federated object on a driver without registerExternalObject (memory and mongodb hold none; plugin.ts:1437-1438 lists it as unsupported, :1455 logs the NOT-bound error naming it; turso inherits it, turso-driver.ts:808) — NAMED, with the ruled F3 reading (today's answer came from a table named after the object); the N3 class — a driver driven directly on a table it was never handed (the two harnesses) — NOT in FROM → TO, by ruling 5886202626: the one production view-rule lowering path (rest/view-filter-rule-lowering.ts → findData, and the engine's lowerWhereFilterArray → parseFilterAST, engine.ts:947/1074) runs on a declared object, and no non-test source under packages/metadata* / packages/cli spells is_empty, is_not_empty, isempty or '$empty' (grep at the head).
  • driver-sql: a multi-value column on a knex client whose dialectName is 'unknown' → emptyListUnsupportedDialectError, INVALID_FILTER / 400, $null prescribed; text and null-only rows compile — NAMED; measured this round on a real mssql client (pinned), no longer code-read.
  • service-analytics: a host without sourceFieldMeta → whereEmptyLeafSql's invalidFilterError (INVALID_FILTER / 400, $null prescribed, no SQL run) on the NativeSQL strategy's query and generateSql, on the ObjectQL strategy's generateSql echo, and READ_SCOPE_COMPILE_FAILED / 500 on a read scope (compileEmptyOperator, read-scope-sql.ts:2079). NAMED as a composition. One precision point, not a wrong refusal: on the ObjectQL strategy's execute face such a host FORWARDS { $empty } to the engine (convertFilter, :1856-1857) and the echo's refusal is swallowed (execute, :340-345 try { generateSql } catch { sql = undefined }), so query() there is answered by the engine's declared row and only the echo goes missing — the changeset's "an AnalyticsService constructed without sourceFieldMeta" and the D3 acceptanceCriteria's "the query now fails instead of answering" are over-broad for that one face (wording, ③-i). The pinned host is the native strategy, where the sentence holds on both generateSql and query. The in-repo production host (plugin.ts:1273) wires sourceFieldMeta.
  • Faces that refuse nothing new: formula matchesFilterCondition, objectql having, driver-memory's reference matcher (by value); driver-memory's cube face (memory-analytics.ts:1518 uncompilableFieldOperatorError) and the analytics draft preview (PREVIEW_FIELD_OPERATORS has neither $null nor $empty) refused $null before and refuse $empty after — same verdict class, pinned (REFUSED_OPERATORS gains $empty; preview-unevaluable-operator.test.ts parametrised by FILTER_OPERATORS). plugin-auth's ObjectQL adapter and plugin-security's RLS compiler spell neither is_empty nor $empty (grep count 0), so no read scope built there changes.

Rulings' premise, tested against the diff: after the flip an author-declared column on an object the face was handed (syncSchema, initObjects / registerObjectMetadata / registerExternalObject, turso's inherited resolver, analytics sourceFieldMeta) is answered on every declared-row face, and refused only where the declaration is absent or (sql, analytics) the multi-value row meets the unknown dialect — ruling 2 item 4's four compositions plus the ruled N3 class. No in-repo production path reaches a refusal the FROM → TO text does not name.

Newly ANSWERED-differently inputs. (a) A stored 「is empty」 on a text-like column also finds ''; on a multi-value column (multiselect / checkboxes / tags, or select / radio / lookup / user / file / image with multiple: true) also finds []; is_not_empty loses those rows — NAMED; the changeset's type lists are the spec's own describe (EMPTY_PREDICATE_DESCRIPTION), which §1 pins equal to the enforced copy. (b) driver-memory's refusal message lists $empty after $exists; mongodb's FIELD_OPERATOR_RANK ranks it last — wording and order only. (c) driver-turso remote: a NODE-position $empty takes the misplaced-field-operator repair instead of the "names nothing" tail (MISPLACED_FIELD_OPERATORS derives from FILTER_OPERATORS, remote-transport.ts:255); both arms INVALID_FILTER — message change only, harmless. (d) driver-memory QueryAST legacy spellings empty / not_empty / notempty take the declared arm too — one arm, one meaning; the changeset names the node with is_empty, the same arm. (e) formula: a policy check written as a filter array with is_empty now judges '' and [] as empty by value — inside the changeset's "any filter array" sentence.

Author-shown text, sentence by sentence. Changeset (at this head): the summary, the per-type sentence, the ALL_OPERATORS claim (filter.zod.ts:3138), the canonicalAstOperator fold sentence, the driver-memory QueryAST sentence, the N1 bullet and its FROM → TO, the N2 lead sentence — now without "and the prescription $null" (cut 4, a1402a37ba; the read-scope envelope does not spell $null) — the four compositions with their FROM → TO lines, "(driver-memory, driver-mongodb)" (grep count 0 for registerExternalObject in both src trees), "(driver-sql and its heirs, driver-turso)" (turso-driver.ts:808 inherited), "a knex client other than SQLite, PostgreSQL or MySQL" (sql-driver.ts:13329 dialectName), "not rewritten; re-read", Clause-②: yes (narrowing), the adr-0087 registered marker — every sentence holds against the tree, with the one over-broad face named above (the ObjectQL strategy's execute on a host without sourceFieldMeta answers). D3 entry filter-is-empty-lowers-to-empty-operator (entries/semantic/18.*, regenerated in registry.ts step 18): surface — "a sharing rule" sourced by the engine's lowerWhereFilterArray on the array authoring form (sharing-plugin.ts:1445 names it); replacement — each clause matches a refusal read above and prescribes is_null / is_not_null, sourceFieldMeta or a federation-capable binding; reason — the "three builders" clause is cut (cut 3) and the "loudly and with the $null prescription" clause is cut (the fifth cut, c96e1feaca); read whole from the module, every sentence now says what was decided and carries no tracker id (the CLI guidance pin's regex, per 5888035539); acceptanceCriteria — the clause that read as if is_not_empty also gains the '' / [] rows is cut (cut 1) and "with INVALID_FILTER" is cut (cut 2); its remaining "the query now fails instead of answering" for an analytics host is over-broad on the ObjectQL execute face (③-i). PR body: every sentence holds (option A under the three rulings, the four compositions, the D3 id, Clause-②: yes (narrowing)). The regenerated content/docs/references/data/filter.mdx carries the new describe byte for byte in its five $empty rows; the service-analytics README example now shows sourceFieldMeta and says is_empty / $empty need it.

Tests edited or deleted. filter-empty-operator.test.ts §4: the two staging pins ("not in FILTER_OPERATORS", "still emits $null") were written to be inverted by this card and are replaced by four pins asserting the inversion, the null pair's untouched lowering and the canonical fold — inverted by rule, nothing weakened. filter-operator-vocabulary.test.ts: STAGED loses $empty, "differ by EXACTLY the staged operators" holds by construction. filter-view-operator-parity.test.ts: KNOWN gains $empty; the direction-from-name pin moves to $empty and gains the $null pair. page-component-filter-record-to-rule-array.test.ts: the declined set gains $empty. filter-save-door-face-parity.test.ts: comment only. engine-number-comparand-declared-type-door.test.ts: the STAGED partition is removed, its truth (the $empty row's verdict is passes) carried by PASSES.filter(isEmptyFlagCase) length 1, and the row is now driven end to end with the driver receiving the filter as written; #20502's widened form set is kept in the GUARD (the conflict resolution reported in 5887543217 matches the file). memory-filter-ast-vocabulary.test.ts and sql-driver-null-operators.test.ts declare their tables (ruled 5886202626); the null-operators fixture stores no '', so its asserted IS NULL rows are unchanged. sql-driver-json-column-operator-refusal.test.ts KEPT gains ['$empty', true] (a multi-value row compiles on SQLite). mongodb-operator-key-clobber.test.ts hands the translator a text resolver and gains the $in / $nin rows and a comparand row. memory-analytics-echo-operator-coverage.test.ts REFUSED gains $empty. The two analytics echo tables gain a $empty row and a declaredValueShape context. The *-20444-*, matches-filter-empty-operator, having-empty-operator and compile-refusal-seam edits are comment-only. New pin files: memory-20446-empty-flip.test.ts (7), sql-driver-20446-empty-flip.test.ts (7, including the federated object answered through registerExternalObject and the mssql dialect refusal), where-empty-flip-host.test.ts (3; its cube members named by record key alone since 395c53182a, the inner-name retirement pin green), operator-object-write-value.test.ts N1 (2). No assertion was weakened; the flip makes none false.

The card's pins. Ruling A's multi-value pin ([] + null, refuses nothing) — memory and sql; the text '' pin — memory and sql; the exact complement on every declared field — memory and sql; every compile surface's conformance row with $empty in FILTER_OPERATORS — Test Core and Temporal Conformance green on the head. Pre-flight item 2 (an engine-injected column) — measured in 5881367401 and 5881534257, created_by / owner_id answered; item 3 (a list holding a null element on document faces) — unpinned, a stored-state note the engine notice carries, not this card's.

Check-runs on c96e1feaca (35, deduped by name, newest started_at; all completed, none still running): 33 success, 2 skipped — Console Pin Gate (path filter, needs.filter.outputs.console) and Packed-tarball smoke (opt-in). Green among them: Check Changeset, Lint & Repo Gates, Governed Surface Queue Guard, Spec property liveness, Build Core, Build Docs, Test Core and its six shards, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate and its three shards, Dogfood Verify CLI, TypeScript Type Check and the four Type Check jobs (source gates, consumer gates, debt ledger, workspace), Flag docs affected, the three claim guards, filter.

② Semver level

Changeset .changeset/20446-empty-joins-filter-operators.md: @objectstack/spec minor, @objectstack/driver-memory minor, @objectstack/service-analytics patch; feat(spec)!: summary; **BREAKING** banner; Clause-②: yes (narrowing); the adr-0087 registered marker naming filter-is-empty-lowers-to-empty-operator, whose entry is in entries/semantic/18.* and the regenerated registry.ts (step 18, the pending protocol major; PROTOCOL_VERSION 17.0.0, so neither spec-changes.json nor the upgrade guide carries it yet — check:spec-changes / check:upgrade-guide green inside Lint & Repo Gates). RIGHT: AGENTS.md §Post-Task 3 — yes takes at least minor, (narrowing) is BREAKING, a breaking changeset carries FROM → TO and exactly one ADR-0087 disposition marker; ADR-0087 §addendum — pre-GA a metadata-facing break ships minor carrying the banner and its disposition entry, and check-changeset-no-major refuses major; the registration gate's own fixture reads Clause-②: yes (narrowing) as "a diff that widens AND narrows", which this diff is (widening on text / multi-value rows; narrowing N1 / N2). driver-memory minor fits the functional QueryAST-arm change in its own source. service-analytics patch fits README plus comment-only source. driver-sql, driver-turso, formula and objectql publish comment-only edits from their own source and carry no bump; objectql's N1 refusal is the derived #5922 rule and the spec changeset carries its FROM → TO. The PR body's Clause-②: yes (narrowing) matches the changeset. Check Changeset: success.

③ Boundary flags

  • a. 5881367401 open question (A / B / C) — answered by ruling 5881406205: A, C refused, B not a prerequisite. Its out_of_scope_findings: [0] every text the flip falsifies — rewritten in the diff, file by file (scripts/check-driver-conformance.mjs carries no $empty sentence); [1] QueryAST arm — aligned; [2] the engine notice's "front door still refuses $empty" measured false — the seat's, for the engine lane; [3] STAGED's location — seat corrected; [4] two governed skills/** files list the special operators without $empty — a gap, not a false sentence, tier H, stays out (Governed Surface Queue Guard success), carried by the seat; [5] a model identifier in the dispatch's trailer block — seat corrected; the branch's commits carry the model-free pair.
  • b. 5881534257 open question (F1 / F2 / F3) — answered by ruling 5881556735: F3, premise corrected to four compositions. Its findings: the unmodeled-dialect case — measured on mssql this round (pinned); turso inherits registerExternalObject — code-read here too (turso-driver.ts:808), and sql-driver-20446 pins the SqlDriver federation path it inherits.
  • c. 5886160392 out_of_scope_findings: [0] the harness composition — answered by ruling 5886202626 (N3 class, edits stand, no FROM → TO line; process note recorded); [1] the refusals prescribe $null where the author wrote is_empty, and the analytics message says "no field metadata is wired" — wording; the changeset and the D3 replacement map to is_null / is_not_null and name sourceFieldMeta, the README names it; no refusal is wrong; [2] skills gap — as (a)[4]; [3] Closes vs Fixes (same closing class) and STALE TREE — merged since (bd85fdfc52, 1dca3e3ff4; the PR reads mergeable: clean). Its NOT MEASURED (skill-examples, dual-build-cjs-loads, type-check-debt, live PG / MySQL) — answered by the head's check-runs (Lint & Repo Gates, Build Core, Type Check · debt ledger, Temporal Conformance, all success).
  • d. 5887543217 deviations: merge commit 1dca3e3ff4 made by os-regen-merge.sh with git's default message — no model identifier, no card relation, check:commit-card-trailers green; acceptable. Its open question (guidance-pin red) — answered A, fixed in b4087e563e; the CLI pin run 3/3 (5888035539).
  • e. 5888625241: the spec --project repo cube inner-name red at b4087e563e — fixed at 395c53182a (two lines in the host test's fixture; its three pins unchanged); Test Core success on the head.
  • f. 5888973436 (the earlier record) — its three wording flags plus the fold-in cuts: all four cuts land in a1402a37ba and the fifth (5889347326's observation, "loudly and with the $null prescription" in the D3 reason) in c96e1feaca; read against the tree above, each edited sentence reads whole and no test asserts the cut fragments (the dev's fragment greps; migrations.test.ts asserts reason.length above 0 only; check:generated and check:migration-registry green in Lint & Repo Gates).
  • g. Residual NOT MEASURED, escalated, not blocking: driver-mongodb's federated composition on a live mongod (its code path is valueShapeFor undefined → refused, mongodb-driver.ts:839-843); turso remote on a federated object; callers of translateFilter / compileScopedFilterToSql / AnalyticsService outside this repository (cloud).
  • h. objectui, escalated to the seat — not this PR's edit, not verdict-bearing here. At the pinned .objectui-sha dd3f7e1b, packages/core/src/adapters/ValueDataSource.ts canonicalises an AST triple through the spec's canonicalAstOperator (:399) and switches on the result with arms is_null / is_not_null and no is_empty / is_not_empty arm (its comment at :506-508 still says the fold puts is_empty onto is_null); after this flip a spec release makes that switch fall to its default, which REFUSES ("filter operator 'is_empty' is not implemented by the in-memory matcher") — so the Console's in-memory data source will refuse a stored is_empty / is_not_empty rule, where it matched the null rows. Its test ValueDataSource.astFilterVocabulary.test.ts (:129-132, :238) pins the old fold and will red. The Console Pin Gate builds only (scripts/build-console.sh: turbo build of deps, then the console's own run build; no objectui test or typecheck), objectui declares no typed map over the FILTER_OPERATORS union, and nothing exported is removed or renamed, so main stays green and Post-Task 4 is not engaged. The card defers objectui convergence to objectui#10813 after the spec release, but [finding] Published READMEs' relative repo paths are read by nothing — packages/runtime/README.md names six targets that do not exist #10813 as filed names the three BUILDERS' meaning and not this canonical-switch consumer; the changeset's fold sentence is the consumer's notice. The seat should widen objectui#10813 (or file beside it) to add the is_empty / is_not_empty arms in ValueDataSource and move that test's spellings, before the spec release that carries this card is pinned.
  • i. Wording, for a text-only follow-up, not verdict-bearing (named in ①): the changeset's third composition and the D3 acceptanceCriteria's "the query now fails instead of answering" are over-broad for an analytics host on the ObjectQL strategy, whose execute forwards $empty to the engine and swallows the echo's refusal (objectql-strategy.ts:340-345); the refusal is on the NativeSQL strategy (both faces), the SQL echo and the read scope. Passing sourceFieldMeta is the right action on every face, so no author is misled into a wrong one.
  • j. Stale text outside the author-shown set, not this card's: objectql-strategy.ts:1278-1282 still says "Until driver-sql carries it, the engine refuses the operator" — false since 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 landed; a code comment, missed by both the 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 landing and this round's falsified-text list. Note for the services lane.
  • k. Docs drift advisory (5886919100): nine hand-written pages listed through SqlDriver / is_null anchors; none states the is_empty lowering (grep over content/docs outside releases: the only is_empty sentences are the regenerated filter reference and the direction-from-name describe on the UI references, both still true). Release-owned pages untouched.

Implemented-by: claude/issue-20446-empty-joins-filter-operators
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-09-29T12:09Z · rendered by the seat's at-tier review subagent on this head; its served tier family was read from the subagent transcript before posting. This is the final head: the five wording cuts folded in after the PASS 5888973436 at 395c53182a are here. ③ (h), objectui's ValueDataSource refusing a stored is_empty on the next spec release, is filed for triage as objectstack-ai/objectui#11094 with Blocked-by: objectstack-ai/objectstack#20446. ③ (i) stays as written: the prescribed fix is right on every face, and one more head would reopen the review for an over-broad clause while this week's usage reads at its warning level. The seat names it in its round report. ③ (j) is a pre-existing comment, not this card's. Landing waits for all checks green.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants