Skip to content

feat(spec): declare the staged $empty filter operator and its per-type expansion (#20311) - #20442

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20311-is-empty-per-type-lowering
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20311-is-empty-per-type-lowering

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20311
Clause-②: yes

Declares the emptiness operator $empty: boolean in @objectstack/spec. Its description is ruling B's per-type table. The one expansion every compile surface will call is exported beside it. The operator is staged the way $like was: it is declared, but deliberately absent from FILTER_OPERATORS, and the is_empty / is_not_empty lowering still emits $null.

Ruling-ref: 5861435168 (ruling B on #20311), 5865693155 (ruling A on #20399, the spelling), 5868169573 (the maintainer's amendment, 「照 $like 先例分阶段」).

What changed

  • FieldOperatorsSchema and SpecialOperatorSchema (the enforced copy and the documentation copy, one shared constant) gain $empty: z.boolean().optional(). The description is the ruled table: text-like (STRING_VALUE_TYPES) = null or ''; multi-value (isMultiValueField: multiselect, checkboxes, tags, and select / radio / lookup / user / file / image with multiple: true) = null or []; every other type = null only; false is the exact complement; a face with no field declaration judges by value. It also says in plain words that the operator is staged and that no face answers it yet.
  • New module packages/spec/src/data/filter-empty-operator.ts, published on the data entry: expandEmptyOperator(field) keys on the field DEFINITION (type plus multiple) and returns one of the frozen EMPTY_OPERATOR_ARMS rows { arm, emptyString, emptyList }. isEmptyFilterValue(value, expansion?) is the value-level half: with an expansion it applies the declared row; without one it applies the by-value reading for the formula matcher and having. The result is surface-neutral, deliberately not a FilterCondition, because [] stays refused as an equality comparand (ruling 乙 on [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — and driver-mongodb alone answers it, as an exact-array match #19757, untouched).
  • FILTER_OPERATORS is unchanged. Its docblock gains a $empty staging paragraph with the measured per-face table below. The flip card is named as the one that adds the operator. filter-operator-vocabulary.test.ts' STAGED_AHEAD_OF_BACKENDS becomes ['$empty', '$ilike', '$like'].
  • Two reconciliation pins that derive from FieldOperatorsSchema's keys, kept green the way $like's staging kept them:
    • the comparand-type face's scalar set (filter-comparand-type.ts) gains $empty;
    • the save door's boolean-flag set (filter-save-door-refusals.ts) gains $empty. A non-boolean $empty is then refused at save, as $null / $exists already are, with the flags' first sentence and an $empty-specific prescription.
  • Changeset @objectstack/spec minor, Clause-②: yes (widening). It says plainly that authoring $empty today is refused at query time.

Why the expansion is its own module, not in filter.zod.ts

The first version put the functions in filter.zod.ts, importing the sets from field-value.zod.ts. gen:skill-refs then rewrote skills/objectstack-query/references/_index.md and skills/objectstack-api/references/_index.md: that import pulled field-value.zod.ts, field.zod.ts and four shared modules into those skills' transitive reference lists. Any skills/** path would make this PR Tier H. The two files also meet in the field.zod import cycle. So the expansion sits in a sibling module, the precedent being filter-text-operator-declared-type.ts, which reads the same sets from the same position. filter.zod.ts takes no new import. At the final head check:skill-refs is green with zero skills/** changes. The description names its type lists literally, and filter-empty-operator.test.ts pins each list to the sets the function reads, so the two cannot drift.

A1: what every compile surface does with a hand-authored $empty (measured)

Probe: a scratch script run once and not committed. It drove each surface with { f: { $empty: true } }, { f: { $empty: false } } and { $and: [{ g: 'x' }, { f: { $empty: true } }] }, after this change was built. Two controls ran beside it: the declared { f: { $null: true } }, and an undeclared { f: { $bogus: true } }.

surface $empty (all three shapes) control $null control $bogus
(1) driver-sql applyFilterCondition, via SqlDriver.find on better-sqlite3 (driver-sqlite-wasm and driver-turso local inherit this compiler; not driven separately) REFUSED INVALID_FILTER / 400 answered ['2'] REFUSED INVALID_FILTER / 400
(2) driver-turso RemoteTransport.buildWhereSQL, via find REFUSED INVALID_FILTER / 400 compiled IS NULL REFUSED INVALID_FILTER / 400
(3) service-analytics compileScopedFilterToSql REFUSED READ_SCOPE_COMPILE_FAILED / 500 (fail-closed) compiled IS NULL same 500
(4) service-analytics lowerAnalyticsWhere passes the condition on unchanged (a lowering, not an executor); the compile right after it, normalizeAnalyticsFilterTree, REFUSES INVALID_FILTER / 400 lowered to notSet same 400
(5) formula matchesFilterCondition NOT LOUD: answers false for every record, with flag true and with flag false answered the null row same silent false
objectql applyHaving / matchesHaving REFUSED INVALID_FILTER / 400 answered REFUSED
driver-memory find and match (checkCondition) REFUSED INVALID_FILTER / 400 answered ['2'] REFUSED
driver-mongodb translateFilter (translateFieldOperators) REFUSED INVALID_FILTER / 400 { f: { $eq: null } } REFUSED

Conclusion per surface, for this card: explicitly out of scope; each gets its $empty arm from its lane card. No surface drops the predicate, so nothing widens. Two readings differ from premise A1, "the staging leaves every surface loud":

  • formula is not loud. It answers false for any operator it has no arm for: its decided fail-closed posture (JS 求值面全体拒收 $icontains(driver-memory 两面 / driver-mongodb / objectql having / formula)—— SQL 族已实现,同一 filter 在内存 double 上抛错 #6520), identical for the undeclared $bogus, and unchanged by this PR. It denies a write-side check rather than widening anything. But its own docblock says a DECLARED operator must not get that silent false ("the same defect under a new name"), and $empty is now declared. So that claim is stale until formula's lane card lands. $like got its formula arm in the PR that declared it; this card is barred from formula by the order and by the amendment's placement of arms in lane cards. Flagged in the report as an open question for the seat.
  • read-scope-sql refuses with READ_SCOPE_COMPILE_FAILED / 500, not INVALID_FILTER / 400. It is loud and fail-closed, and it does the same for every unknown operator. The description and the changeset say "refuse", not "400".

A2 to A4

  • A2. Two in-package pins derive from FieldOperatorsSchema's own keys and saw $empty: filter-comparand-type.test.ts (judged set) and filter-save-door-face-parity.test.ts (BOOLEAN_SLOTS). Both are updated, with the source sets they reconcile. filter-operator-vocabulary.test.ts records the staging. Suites that enumerate FILTER_OPERATORS (filter-view-operator-parity, page-component-filter-record-to-rule-array, service-analytics' echo coverage) are untouched, because the array is. No test outside packages/spec enumerates FieldOperatorsSchema's keys (repo grep: only prose mentions). No gate needed an edit outside packages/spec.
  • A3, re-measured at base 50e273fd7: STRING_VALUE_TYPES has the 14 text types; isMultiValueField = MULTI_OPTION_TYPES or a MULTI_CAPABLE_TYPES member with multiple: true. The sets are disjoint, a pin asserts it, and lookup vs lookup with multiple: true land on different rows.
  • A4: isEmptyFilterValue(value) with no expansion is the declaration-free reading (null, undefined, '', []). It differs from the declared table only on a non-text column holding '', and a pin shows exactly that divergence.

Pins (filter-empty-operator.test.ts)

  1. Both copies' description equals the ruled table; each type list it names equals the set the expansion reads.
  2. { tags: { $empty: true } } parses at FieldOperatorsSchema (kept, not stripped), at FilterConditionSchema (any depth) and through the normalized AST. { tags: [] } and { tags: { $eq: [] } } are still refused (issue at the slot; the query face answers INVALID_FILTER / 400). A non-boolean $empty is refused at the slot and at the save door.
  3. expandEmptyOperator returns the text, multi-value and null arms for the three kinds, and every FieldType lands on its value class's arm. isEmptyFilterValue answers each arm, plus the by-value reading.
  4. $empty is ABSENT from FILTER_OPERATORS; a comment names the flip card as the one that adds it. is_empty / isempty / is_not_empty / isnotempty still lower to $null.

Verification (final head 32e926db1)

  • packages/spec full local suite: vitest run --project local, 563 files, 16536 passed, 1 todo. pnpm --filter @objectstack/spec typecheck: exit 0 (tsc, scripts typecheck, test typecheck).
  • Ablation, run against the committed tree through scripts/ablation-replace.mjs. It added '$empty' to FILTER_OPERATORS: anchor count 1 to 0, blob 0912c2776e5e to 74b0e4d23fe0. The run went red on exactly the two staging pins (filter-empty-operator §4 and filter-operator-vocabulary), with 2 failed and 23 passed. The file was restored to blob == HEAD with git diff HEAD empty. The second ablation (dropping $empty from the save-door flag set) was not run: the verify-lock queue timed out.
  • Consumer suites (filter / operator files per package, positional vitest filters; spec and each package's upstream closure rebuilt from this branch first). Direction: consumers of @objectstack/spec, i.e. downstream.
package files tests
formula 16 570 passed
driver-memory 10 392 passed
driver-mongodb 6 (+1 skipped) 219 passed, 38 skipped
objectql (filter / operator / having) 16 624 passed
driver-sql 14 331 passed, 4 skipped
driver-turso 4 170 passed
metadata-protocol 4 51 passed
lint 2 76 passed
service-analytics (filter / operator / read-scope) 42 951 passed
rest 6 109 passed
plugin-sharing (criteria / sharing-rule) 6 146 passed
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 108 commands at this head (a superset of the dispatch list's 72). All were run, and --ran reconciled: 106 exited 0; 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET) because they need every package built. Those two are check:dual-build-cjs-loads and check:type-check-debt, and CI runs them. check:skill-examples first exited 3 (client-react unbuilt) and exited 0 after building it. pnpm --filter @objectstack/spec check:generated: every artifact current.
  • Lint, a declared narrowing: eslint --no-inline-config --format json over the 9 changed TypeScript files reported 9 files, 0 errors, 0 warnings. The population is eslint.config.mjs' **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} block. That config never enables type-aware linting (its own note says so), so this diff cannot move a verdict on any untouched file. The full pnpm lint is CI's.

Patch round: reconciled with #20414 (head d581aed71)

Stored sharing rules (ruling B, parameter 3)

  • objectstack 50e273fd7: the criteria sharing rules in examples/ that use emptiness = 0.
  • objectui b45d463a9: none either; the 22 emptiness hits in sharing-rule-related files are builder code and tests, not stored rules.
  • This PR changes no lowering, so no stored rule changes result.
  • Production rules are NOT MEASURED; the changeset says so.

Acceptance notes

The patch-round section and the first acceptance note were added by the domain:spec seat 1 (session_01B3TqpoQbTAfG7G74GMDWNW) from the dev’s round report.

…pe expansion

Declares `$empty: boolean` in FieldOperatorsSchema and SpecialOperatorSchema,
its description carrying the ruled per-type table, plus the exported
expandEmptyOperator / isEmptyFilterValue / EMPTY_OPERATOR_ARMS. Staged like
$like: absent from FILTER_OPERATORS; the is_empty lowering still emits $null.

Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW
Co-authored-by: Claude <noreply@anthropic.com>
filter.zod.ts takes no import from field-value.zod: the two meet in the
field.zod import cycle, and filter.zod.ts' import closure is what the
published query and api skill reference indexes walk.

Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW
Co-authored-by: Claude <noreply@anthropic.com>
…ference for $empty

Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

20 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 4 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/authorable-surface/data.json, packages/spec/export-origins/data.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/authorable-surface/data.json, packages/spec/export-origins/data.json, …) — pages documenting those are invisible to this run
  • 2 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 — 137 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 fbeb56e4b5e4f7b33336926353bcd0c77147ad7f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json fbeb56e4b5e4f7b33336926353bcd0c77147ad7f

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 32e926db1eabd0765dee8f1a1829489fea4dfae7
Local-runs: none

Inputs read: card #20311 (body; ruling B 5861435168; the release 5864963417; the unblock 5865751001; both claims; both dev reports; the staging amendment 5868169573; the seat's ACCEPT 5870558181), card #20399 (body; ruling A 5865693155; the pointer 5868183894), PR #20442 (body, file list of 14 files at +575 / -7, and the net diff against main at base 0fcb10184), the head's check-runs, and origin/main context at the base sha (filter.zod.ts, field-value.zod.ts, driver-memory filter-refusal.ts, formula matches-filter.ts). Not read as inputs: the dispatch order and the dispatching seat's conclusions.

Check-runs on this head, read at 2026-09-28T13:31Z: every gate-carrying context is success (Lint and Repo Gates, TypeScript Type Check, Test Core 1/6 through 6/6, Dogfood Regression Gate, Build Core, Temporal Conformance on live PG and MySQL, Governed Surface Queue Guard), as are Check Changeset, Spec property liveness, Check PR Size, Build Docs and the claim / single-writer / part-of guards; the skipped runs are opt-in or path-filtered (Console Pin Gate, Packed-tarball smoke). None red, none in progress. The PR is a same-repository draft with no governed path in its file list and 582 changed lines.

① Derived judgments

  1. $empty: z.boolean().optional() on FieldOperatorsSchema and on SpecialOperatorSchema, both describing with one shared EMPTY_PREDICATE_DESCRIPTION — right. The string is ruling B's table: text-like = null or ''; multi-value = null or []; every other type = null only; false the exact complement. It names each type list literally, and filter-empty-operator.test.ts §1 holds each list equal to the set the expansion reads (STRING_VALUE_TYPES; MULTI_OPTION_TYPES plus MULTI_CAPABLE_TYPES with multiple: true). Ruling B's "multi-select, tags, multi-value lookup" is spelled as the value contract's own definition of a list-valued field (isMultiValueField), which also covers checkboxes and the multiple: true forms of select, radio, user, file and image. That is the platform's one definition of a field emptied to [] (the stored-shape rule the record validator enforces) and exactly what ruling A's "keyed on the field definition (type plus multiple)" requires, so the set is a faithful spelling of the ruling, not a widening of it. The describe also says in its published text that the operator is STAGED and that the executors refuse it: declared, not advertised (Prime Directive chore: version packages #10). Declaring it on the documentation copy too, where the amendment names only FieldOperatorsSchema, keeps the two copies in parity the way $like sits on StringOperatorSchema; the pin holds both to one string.
  2. ABSENT from FILTER_OPERATORS — right, and the amendment's exact shape. At the base the array's Special row ends '$null', '$exists'. The only hunk in that region (@@ -3016,6 +3070,44 @@) is 38 added docblock lines against six context lines with no removal, so the array is byte-unchanged. Pinned twice: filter-empty-operator.test.ts §4 (not.toContain('$empty')) and the vocabulary equality STAGED_AHEAD_OF_BACKENDS = ['$empty', '$ilike', '$like']. driver-memory's gate at the base is SUPPORTED_FIELD_OPERATORS = new Set([...FILTER_OPERATORS, '$like', '$ilike']), so it keeps refusing $empty loudly, which is the mechanism the maintainer's 「照 $like 先例分阶段」 exists for. The new docblock paragraph names the flip card as the one change that adds the member.
  3. expandEmptyOperator(field), EMPTY_OPERATOR_ARMS and isEmptyFilterValue(value, expansion?) in the new module filter-empty-operator.ts, published from the data barrel — right against ruling A's "one expansion function keyed on the field definition (type plus multiple)". The expansion tests isMultiValueField(field) first, then STRING_VALUE_TYPES.has(field.type), else the null-only row; the two classes are disjoint (pinned), and lookup against lookup with multiple: true land on different rows (pinned). Its result is surface-neutral ({ arm, emptyString, emptyList }) and deliberately not a FilterCondition, because the multi-value row cannot be spelled as an equality comparand under ruling 乙. isEmptyFilterValue is not a second table: with an expansion it applies the declared row; without one it applies the by-value reading ruling A gives the declaration-free faces (the formula matcher, objectql having), whose single divergence, a non-text column holding '', is named and pinned. The sets are read at call time rather than module scope, so the field.zod cycle note in the module header holds.
  4. Lowering unchanged — right. The convertComparison hunk is five comment lines; the is_null || isnull || is_empty || isempty branch still returns { $null: true }, and §4 pins is_empty / isempty to { $null: true } and is_not_empty / isnotempty to { $null: false }. This is ruling A's "keeps emitting $null until the flip card".
  5. Ruling 乙 untouched — right. filter-comparand-shape.ts is not in the file list; §2 pins { tags: [] } and { tags: { $eq: [] } } refused at FilterConditionSchema, and parseFilterAST({ tags: [] }) refused with INVALID_FILTER / 400.
  6. The save door's boolean-flag set gains $empty (BOOLEAN_FLAG_OPERATORS), with its own prescription — right, and forced. filter-save-door-face-parity.test.ts derives BOOLEAN_SLOTS from the schema's z.boolean() slots and asserts door-versus-face equality per cell, so a declared boolean flag the door did not hold would red that pin. The change is the Prime Directive Add comprehensive test suite for Zod schema validation #12 direction: refuse at authoring what every query face refuses anyway. The $empty sentence keeps driver-sql's first sentence and drops the opposite-directions history, correctly, since no backend reads the flag yet. Likewise SCALAR_COMPARAND_OPERATORS gains $empty in the comparand-type door, forced by that door's reconciliation pin against FieldOperatorsSchema's keys.
  7. Placement in a new module rather than in filter.zod.ts — right. The claim's file surface named filter.zod.ts; the dev's reason (importing field-value.zod into filter.zod.ts made gen:skill-refs rewrite two skills/** reference indexes, which would have made the PR Tier H, and the two files meet in the field.zod import cycle) is structurally verifiable: filter-text-operator-declared-type.ts is the existing sibling reading the same sets from the same barrel position. The rulings mandate the function and its keying, not its file. The file list carries no governed path and the Governed Surface Queue Guard is green.
  8. Generated artefacts (api-surface/data.json, authorable-surface/data.json, export-origins/data.json, content/docs/references/data/filter.mdx) carry the five exports and the $empty key; their gates run in the green TypeScript Type Check job, so they are current at this head and none is hand-edited.
  9. Stored sharing rules (ruling B, parameter 3): the changeset states that 0 criteria rules in either repository's examples and fixtures use emptiness, that no lowering changes so none changes result, and that production rules are NOT MEASURED. Right.

② Semver level

@objectstack/spec minor — matches what the diff publishes. Widening: a declared operator slot on the enforced and the documentation copies (FieldOperatorsSchema.parse({ $empty: true }) used to strip the key and now keeps it; FilterConditionSchema and NormalizedFilterSchema keep it at any depth), plus five new exports from @objectstack/spec/data (expandEmptyOperator, isEmptyFilterValue, EMPTY_OPERATOR_ARMS, and the types EmptyOperatorArm, EmptyOperatorExpansion). Nothing removed or renamed; FILTER_OPERATORS and the lowering are unchanged; no ADR-0087 entry is owed, and the registration gate is green under the widening arm.

The one incidental narrowing is confined to a never-declared spelling: a NON-boolean $empty was stripped at the operator slot and accepted at the save door before, and is now refused at both, as $null / $exists are. No executor ever answered that shape (every one refuses $empty outright at query time) and both repositories carry 0 occurrences, so no released behaviour a consumer could rely on is removed; the changeset says so in words. (widening) is the right arm and minor the right level.

Clause-②: yes (widening) — the changeset carries exactly that line. The PR body carries Clause-②: yes, the claim's copy without the arm; the two are one declaration, and the body could carry the arm for symmetry, which is cosmetic.

③ Boundary flags

  1. formula's silent false for the declared-but-staged $empty (dev open_questions[0]; option A, land as is, arm on lane card 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) — answered: A stands. matches-filter.ts at the base answers any operator without an arm from default: return false, and the dev measured $empty answered false for every record with flag true and false alike. The direction is fail-closed on a write-side check; nothing widens, and no read-versus-write disagreement exists because NO face answers $empty (unlike $like, which the SQL faces answered, so its formula arm had to ride the declaring PR). The amendment places every arm in a lane card and the claim barred the dev from formula. What goes stale is formula's own docblock claim that a declared operator never gets the silent false; 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 carries the arm and that docblock correction, and must land before the flip card. The vocabulary test's clearing instruction already lists formula among the faces the flip requires.
  2. The read-scope SQL face answers READ_SCOPE_COMPILE_FAILED / 500 for $empty, as for every unknown operator — pre-existing, loud, fail-closed, not this diff's; the changeset says "refuse" and names the 500. Carried by 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 (the 400-versus-500 question under ADR-0112). Nothing owed here.
  3. The describe carries a staging sentence ("Until each face has its arm, the query executors refuse it and the write-side check matcher matches no record"). True at this head. It drifts as each lane card lands, since an executor with an arm answers the operator, until the flip card removes the sentence. Escalated to the seat: the lane cards and the flip card's Blocked-by chain own keeping that published sentence true; the flip card is already named as the one that removes it.
  4. The description is pinned verbatim. The role file discourages prose pins; ruling A's pin list demands exactly this one. The ruling governs.
  5. Two gates NOT MEASURED locally (check:dual-build-cjs-loads, check:type-check-debt) and the second ablation not run (dropping $empty from the save-door flag set): CI's Lint and Repo Gates and Type Check debt ledger are green, and the ablation's direction is covered by construction (filter-empty-operator.test.ts §2 requires exactly one issue at tags.$empty for a non-boolean, and the parity test's derived BOOLEAN_SLOTS equality would red). Nothing owed.
  6. The dev's gate derivation ran 7 commits behind origin/main; CI ran on the merge of this head into base 0fcb10184, and the head's check-runs are the verdict. Nothing owed.
  7. view-grouping-query.ts (the empty-group predicate agreeing with the view filter's is_empty) stays true while the lowering is $null; carried by the flip card filter: flip is_empty / is_not_empty to $empty and add it to FILTER_OPERATORS, once every compile surface answers it (the last step of ruling A on #20399) #20446.
  8. mergeable_state: blocked is the draft state plus this record's absence, not a conflict; the landing sequence (ready first, then the queue) is the owning seat's.

Implemented-by: claude/issue-20311-is-empty-per-type-lowering
Reviewed-by: session_01B3TqpoQbTAfG7G74GMDWNW

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 13:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as draft September 28, 2026 14:02
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Pulled from the merge queue by the domain:spec seat 1 (session_01B3TqpoQbTAfG7G74GMDWNW) at 2026-09-28T14:04Z: converted to draft and auto-merge disabled.

Signature. In the merge group pr-20442-1f2f49fa3, which carries PR #20414 (#20336) ahead of this PR, Test Core (1/6) failed (job 108959030863). The failing test is src/data/filter-number-comparand-declared-type.test.ts > [#20336] the judged positions > partition FieldOperatorsSchema's keys with the text operators and the two flag operators. It reports expected [ '$between', '$contains', …(16) ] to deeply equal [ '$between', '$contains', …(17) ]: PR #20414's pin partitions FieldOperatorsSchema's keys into its judged positions, the text operators and $null / $exists, and this PR adds a seventeenth key, $empty. The same group also carried PR #20420's pin-citation red (see that PR).

Why this is not a flake, and whose it is. It is a semantic conflict between two PRs that are each green alone, and it is deterministic. This PR is the later lander, so the reconciliation is this PR's.

Fix (patch round, after PR #20414 lands).


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 36431602315 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:  FAIL   local  src/data/filter-number-comparand-declared-type.test.ts > [#20336] the judged positions > partition FieldOperatorsSchema's keys with the text operators and the tw
      ↳ 失败原因: @objectstack/spec:test: AssertionError: expected [ '$between', '$contains', …(16) ] to deeply equal [ '$between', '$contains', …(17) ]
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/data/filter-number-comparand-declared-type.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 0 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 36434107738 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:  FAIL   local  src/data/filter-number-comparand-declared-type.test.ts > [#20336] the judged positions > partition FieldOperatorsSchema's keys with the text operators and the tw
      ↳ 失败原因: @objectstack/spec:test: AssertionError: expected [ '$between', '$contains', …(16) ] to deeply equal [ '$between', '$contains', …(17) ]
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 4 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 28, 2026
…ference over the merged tree

Both sides' exports: #20336's number-comparand door and this branch's
$empty expansion; filter.mdx carries main's frontmatter and the $empty rows.

Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW
Co-authored-by: Claude <noreply@anthropic.com>
…and door's partition

The #20336 pin partitions FieldOperatorsSchema's keys into the judged
positions, the text operators and the boolean flags; $empty is a flag
(a boolean, not a value of the field), so it joins $null / $exists there,
in the module's "Not judged" docblock and as an unjudged case row.
The door's verdict logic is unchanged.

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

This is the patch-round record. It re-judges the whole net diff on this head, not only the three patch-round commits. Inputs read: card #20311 (body; ruling B 5861435168; both claims; both dev reports; the release 5864963417; the unblock 5865751001; the staging amendment 5868169573; the seat's ACCEPT 5870558181), card #20399 (body; ruling A 5865693155; the pointer 5868183894), the flip card #20446 (body), #20455 (body; the diagnosis 5871960141; the triage 5873717035), PR #20442 (body with its patch-round section and acceptance notes; the earlier record 5870895711 as an input and not a verdict; the audit 5871530372; both queue-triage comments; the docs-drift comment; the file list of 16 files at +583 / -12; the net diff against main; the patch round's own diff 43801c9ed..d581aed71), the check-runs on this head, and origin/main context by git show (filter.zod.ts, filter-number-comparand-declared-type.ts and its test as landed by #20414, filter-comparand-type.ts, filter-save-door-refusals.ts, clause2-line.mjs). Not read as inputs: the dispatch order and the dispatching seat's conclusions. The context files are byte-identical between the merge base b28550818 and the current origin/main 75b216924, so the net diff reads the same against either.

Check-runs on this head, read at 2026-09-28T16:40Z: 42 runs, all completed, 38 success and 4 skipped, none red, none in progress. The seat's body edit re-triggered the claim, single-writer, part-of, Check Changeset, Auto Label and Check PR Size runs, and each completed green or skipped. Every gate-carrying context is success: Lint and Repo Gates, TypeScript Type Check and its three sub-gates plus the debt ledger, Test Core 1/6 through 6/6 and the aggregate (this is test:repo, the run the dev could only partly measure locally), Build Core, Build Docs, Dogfood Regression Gate 1/3 through 3/3, Dogfood Verify CLI, Temporal Conformance on live PG and MySQL, Governed Surface Queue Guard, Spec property liveness, Check Changeset, Check Documentation Links, and the filter context. Skipped are the opt-in or path-filtered runs (Console Pin Gate, Packed-tarball smoke, and the re-triggered Auto Label and Check PR Size, whose first runs are green).

① Derived judgments

  1. $empty: z.boolean().optional() on FieldOperatorsSchema and on SpecialOperatorSchema, one shared EMPTY_PREDICATE_DESCRIPTION — right. The string is ruling B's table (text-like = null or ''; multi-value = null or []; every other type = null only; false the exact complement), names each type list literally, and filter-empty-operator.test.ts §1 holds each list equal to the set the expansion reads. Its closing sentences say the operator is STAGED and that no face answers it: declared, not advertised. The five $empty rows in the regenerated filter.mdx carry the same text. Unchanged by the patch round.
  2. ABSENT from FILTER_OPERATORS — right, and the amendment's exact shape. The array's Special row still ends '$null', '$exists' on this head; the only hunk in that region is docblock (the $empty staging paragraph with the measured per-face table). Pinned by §4 (not.toContain('$empty')) and by STAGED_AHEAD_OF_BACKENDS = ['$empty', '$ilike', '$like']. driver-memory's gate derives its accepted set from this array, so it keeps refusing $empty loudly, which is what 「照 $like 先例分阶段」 exists for. Unchanged by the patch round.
  3. The expansion module filter-empty-operator.ts and its five exports (expandEmptyOperator, isEmptyFilterValue, EMPTY_OPERATOR_ARMS, EmptyOperatorArm, EmptyOperatorExpansion), published from the data barrel — right against ruling A's "one expansion function keyed on the field definition (type plus multiple)". Multi-value is tested first, then STRING_VALUE_TYPES, else the null-only row; the two classes are disjoint (pinned); lookup and lookup with multiple: true land on different rows (pinned). The result is surface-neutral by design, since the multi-value row cannot be spelled as an equality comparand under ruling 乙. isEmptyFilterValue without an expansion is the by-value reading ruling A gives the declaration-free faces, with its single divergence (a non-text column holding '') pinned. Nothing outside packages/spec references $empty on this head. Unchanged by the patch round.
  4. Lowering unchanged — right. The convertComparison hunk is comment-only; is_empty / isempty still return { $null: true } and is_not_empty / isnotempty { $null: false }, both pinned in §4. This is ruling A's "keeps emitting $null until the flip card". The patch round touches no lowering site.
  5. Ruling 乙 untouched — right. filter-comparand-shape.ts is not in the file list; §2 pins { tags: [] } and { tags: { $eq: [] } } refused at the save door and parseFilterAST({ tags: [] }) refused with INVALID_FILTER / 400.
  6. The save-door and comparand-door sets — right, and forced by their own reconciliation pins. BOOLEAN_FLAG_OPERATORS gains $empty with an $empty-specific prescription, so a non-boolean $empty is refused at authoring as $null / $exists are (the parity test derives BOOLEAN_SLOTS from the schema's z.boolean() slots and would red otherwise). SCALAR_COMPARAND_OPERATORS gains $empty in the comparand-type door, whose pin holds the union of its two sets equal to FieldOperatorsSchema's keys. I checked the behaviour that set implies, not only the pin: that face judges the comparand's TYPE, a boolean is inside its accepted superset and has no narrowing arm, so $empty: true passes it untouched exactly as $null: true does, and the pin's Symbol battery now covers $empty too. No coercion is introduced.
  7. The generated artefacts after the merge — right. 43801c9ed is a clean two-parent merge of 32e926db1 and b28550818 with no conflict-resolution hunks; the os-regen merge attribute took this PR's side of the generated files (which dropped feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414's entries at the merge commit), and 859d9bd82 regenerated them. At this head each of the five $empty exports and each of feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414's exports (numberComparandDoorVerdict, NUMBER_COMPARAND_DOOR_CASES, parseNumericString, and the rest) appears exactly once in api-surface/data.json and export-origins/data.json; the three JSON artefacts parse; filter.mdx carries main's frontmatter (title and navTitle) plus the five $empty rows; authorable-surface/data.json carries data/FieldOperators:$empty and data/SpecialOperator:$empty. check:generated runs in the green TypeScript Type Check job, so none is stale or hand-edited.
  8. The reconciliation of feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414's pin, module docblock and case table — right. The pin partitions FieldOperatorsSchema's keys into the door's judged positions (6 scalar + 3 list), the 7 text operators and the flag operators; with $empty the partition is 19 keys against 19 keys (on main it was 18 against 18; the audit's "seventeenth" reads vitest's truncated tail count, not the key count). $empty is correctly a non-judged flag for the number-comparand door: the door judges the COMPARAND at a value position of a filter on a numeric field, and a boolean flag is not a value of the field, the same reason $null / $exists are unjudged; the module's "Not judged" sentence now says so. The added case row caseFor('unjudged', f_number, { $empty }, true, …) agrees with the verdict function by construction (caseFor derives its verdict from numberComparandDoorVerdict) and by reading (f_number is judged, true is not a string, so the verdict is passes, the same path the $null: true and $exists: false rows take). The row also makes the module's own docblock item 4 ("the unjudged positions … pass") true as edited. No count pin on NUMBER_COMPARAND_DOOR_CASES exists; the position count pin reads only [position] rows; the name-uniqueness, verdict-agreement, four-verdicts and syntax-door pins all hold for the row (the syntax door accepts $empty because the slot is declared). The door's verdict logic, grammar, fixture and refusal words are byte-unchanged: the .ts diff is two docblock hunks and the one row.
  9. Stored sharing rules (ruling B, parameter 3): the changeset states 0 criteria rules in either repository's examples and fixtures use emptiness, that no lowering changes so none changes result, and that production rules are NOT MEASURED. Right, and still true on this head.

② Semver level

@objectstack/spec minor — matches what the diff publishes. Widening: a declared boolean slot on the enforced and the documentation copies (FieldOperatorsSchema.parse({ $empty: true }) used to strip the key and now keeps it; FilterConditionSchema and NormalizedFilterSchema keep it at any depth); five new exports from @objectstack/spec/data; and, from the patch round, one more row in the published conformance table NUMBER_COMPARAND_DOOR_CASES, which has no consumer on main or on this head. Nothing removed or renamed; FILTER_OPERATORS and the lowering are unchanged; no ADR-0087 entry is owed under the widening arm, and the registration and no-major gates in Lint and Repo Gates are green.

The one incidental narrowing stays confined to a never-declared spelling: a NON-boolean $empty was stripped at the slot and accepted at the save door before and is refused at both now, as $null / $exists are. No executor ever answered that shape and both repositories carry 0 occurrences; the changeset says so in words.

Clause-② — the changeset carries Clause-②: yes (widening), the body and the claim Clause-②: yes. clause2-line.mjs reads the two as one declaration (yes (widening) is yes with its arm named), so the three are consistent. (widening) is the right arm and minor the right level.

③ Boundary flags

  1. formula's silent false for the declared-but-staged $empty (the earlier record's flag 1) — answered, A stands on this head. The patch round does not touch formula or the lowering; the window is still fail-closed on a write-side check, $empty still has 0 producers, and 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 carries the arm and the docblock correction ahead of the flip.
  2. read-scope SQL answers READ_SCOPE_COMPILE_FAILED / 500 — pre-existing, loud, fail-closed, not this diff's; carried by 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. Nothing owed.
  3. The describe's staging sentence ("the query executors refuse it and the write-side check matcher matches no record") — true on this head; it drifts as each lane card lands and the flip card removes it. Escalated to the seat as before: the Blocked-by chain of 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 and filter: flip is_empty / is_not_empty to $empty and add it to FILTER_OPERATORS, once every compile surface answers it (the last step of ruling A on #20399) #20446 owns keeping that published sentence true.
  4. view-grouping-query.ts (the empty-group predicate agreeing with the view filter's is_empty) — stays true while the lowering is $null, which this head keeps; carried by filter: flip is_empty / is_not_empty to $empty and add it to FILTER_OPERATORS, once every compile surface answers it (the last step of ruling A on #20399) #20446. The same holds for feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414's own module docblock, which says is_empty lowers to the null test on a number field: true now, and after the flip $empty on a number field expands to the null-only arm, so the flip card should re-read that sentence when it retires the staging.
  5. Dev deviation: the sharded test:repo (8 of 9 local shards passed, one cut by the time cap and NOT MEASURED locally) — answered by the head's check-runs: Test Core 1/6 through 6/6 and the aggregate are success on this head, and their conclusions are the gate verdict. Nothing owed.
  6. Dev deviation: the added case row (the audit prescribed the partition pin and the "Not judged" sentence, not the case table) — judged right on the merits in ① 8: correct for the door's contract, derived not hand-answered, consistent with the docblock item it completes, and with no consumer today. One sequencing note for the seat: $empty is the only passes row whose filter no executor answers yet, so the engine suite that driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field — where { amount: { $gt: "abc" } } is DATABASE_ERROR / 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336's changeset says the table is "published for" must pin the door alone, or land after 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 gives the engine faces their arm; driven end-to-end through a driver before that, this row would red for a reason that is not the door's. Not a FAIL input.
  7. Dev deviation: the re-created worktree — process only; nothing in the diff depends on it, and the head plus its check-runs are what this record judges. Nothing owed.
  8. Touching driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field — where { amount: { $gt: "abc" } } is DATABASE_ERROR / 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336's files as the later lander — within scope. The audit assigns the reconciliation to this PR by the landing order; the change is the same class as the A2 reconciliations already accepted (a pin that enumerates FieldOperatorsSchema's keys moves with the PR that adds a key); both files sit in packages/spec/src/data/, inside the claim's directory bound; no compile surface is touched; the door's verdict logic is unchanged. Queue-flake anchor: src/data/filter-number-comparand-declared-type.test.ts #20455 names this PR as the carrier and closes on its landing, which the triage there records.
  9. Fixes #20311 — right. Ruling B's execution parameter 5 makes [Decision] what 「is empty」 means on a text column and on a multi-value column: null only (the spec's lowering today), or null OR '' / [] — three objectui builders disagree, and a stored sharing rule's rows depend on the answer #20311 the spec-lane work card that "closes by Fixes with the spec PR"; ruling A keeps [Decision] what 「is empty」 means on a text column and on a multi-value column: null only (the spec's lowering today), or null OR '' / [] — three objectui builders disagree, and a stored sharing rule's rows depend on the answer #20311 as the spec card whose PR the lane cards are Blocked-by; the amendment scopes [Decision] what 「is empty」 means on a text column and on a multi-value column: null only (the spec's lowering today), or null OR '' / [] — three objectui builders disagree, and a stored sharing rule's rows depend on the answer #20311 to exactly what this diff delivers (the declaration on FieldOperatorsSchema, absent from FILTER_OPERATORS, plus the exported expansion); the flip is filter: flip is_empty / is_not_empty to $empty and add it to FILTER_OPERATORS, once every compile surface answers it (the last step of ruling A on #20399) #20446's own card, already filed. The user-facing gap staying open until the flip is by design of the staging and is named in the body's acceptance notes. The "card this PR closes must claim this branch" and "part-of must not also close" guards are green on this head.
  10. Two gates NOT MEASURED locally (check:dual-build-cjs-loads, check:type-check-debt) and the second ablation not run — as before, CI's Lint and Repo Gates and the Type Check debt ledger are green on this head, and the ablation's direction is covered by the §2 non-boolean pin and the parity test's derived BOOLEAN_SLOTS. Nothing owed.
  11. The PR is a draft with mergeable_state: clean; readying it and re-queueing are the owning seat's acts, after this record.

Implemented-by: claude/issue-20311-is-empty-per-type-lowering
Reviewed-by: session_01B3TqpoQbTAfG7G74GMDWNW

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 16:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit b810ddb Sep 28, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20311-is-empty-per-type-lowering branch September 28, 2026 17:07
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 protocol:data size/l tests tooling

Projects

None yet

2 participants