Skip to content

fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face - #19882

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19757-equality-slot-array-refused
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19757-equality-slot-array-refused

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #19757
Clause-②: no (narrowing)

This executes ruling 5793368540 (batch #217 item 3, letter 乙, 「217 同意」): an array in the implicit-equality slot is refused at the shared comparand-shape face, for every driver at once, with ⛔ no alias and ⛔ no grace window.

The Clause-② value is no, as the claim and the ruling state it. The (narrowing) arm is added because of AGENTS.md's changeset rule: a narrowing is BREAKING, and the changeset carries the PR's Clause-② line, which check:adr-0087-registration reads for the arm. Both changesets and this body therefore carry the same line.

Session session_013RDBh5DqXd2xnLwvHLgLFr, branch claude/issue-19757-equality-slot-array-refused. Every reading below was taken on origin/main @ 2548ba57de unless it says otherwise.

1. Measured first (before the change)

What parseFilterAST lowers each spelling to:

authored lowered to shape face type face
['tags','equals',['a']], and the same on =, ==, eq {"tags":["a"]} (implicit form) passes passes
['tags','ne',['a']], and the same on not_equals, !=, neq, notequals and the angle-bracket pair spelling {"tags":{"$ne":["a"]}} passes passes
{tags:{$eq:['a']}}, {tags:[]}, and {tags:['a']} nested under $and / $or / $not unchanged passes passes

@objectstack/objectql's delegating wrapper assertListComparandShapes('deal','find', …) also passed {tags:['a']} and {tags:{$eq:['a']}}. Through a recording driver, both engine doors handed the shape to the driver:

  • Door 2 carrying the FilterArray [['tags','equals',['a']]]
  • the object form, which is also Door 1's hand-off after isFilterAST and parseFilterAST
  • $eq
  • count with the shape under $or

How each backend answered it, on the lowered node, beside a scalar and an $in control. The rows were r1=['a'], r2='a', r3=['a','b'], r4=['b','a'], r5=[['a'],'x'], r6=[['a']], r7='b' and r8=[].

backend {tags:['a']} {tags:{$eq:['a']}} {tags:{$ne:['a']}} nested {$not:{tags:['a']}}
driver-sql, SQLite 400 INVALID_FILTER 400 400 500 DATABASE_ERROR; $and / $or nesting is the same 500
driver-memory 400 400 400 400
formula matchesFilterCondition no row, r1 included no row every row every row
driver-mongodb translateFilter emitted unchanged emitted unchanged emitted unchanged {"$nor":[{"tags":["a"]}]}
mingo 7.2.4, the named proxy for MongoDB r1,r5,r6 r1,r5,r6 r2,r3,r4,r7,r8 r2,r3,r4,r7,r8

Also measured: service-analytics' filter normalizer read [['stage','=',['won','lost']]] and {stage:['won','lost']} as stage IN (won, lost).

⚠️ NOT MEASURED: a live mongod (mingo is the proxy), MySQL, PostgreSQL and a live Turso server. driver-turso and driver-sqlite-wasm are built on driver-sql and were not run as backends.

Which operators the ruling's words cover. The ruling covers the implicit and explicit equality slots: {f:[...]} from any equality spelling, and $eq, at any depth under $and / $or / $not, the empty array included.

⛔ $ne is left out on purpose. It is equality's negation, not equality, and it measured the same split. It is reported for its own ruling and not absorbed here. This follows the face's own precedent for the $in { $field } member question, which it left to a separate card. An it.todo records it, with ⛔ no green pin. The other scalar operators carrying an array ($gt, $contains, $like, …) are not covered either.

Spellings, read at source. The ruling names FilterOperatorSchema. No such export exists on origin/main: it has zero hits under packages/spec/src, while the control FieldOperatorsSchema does resolve. So the two prescribed operators are read off FieldOperatorsSchema's keys and AST_OPERATOR_MAP's lowering:

  • $in, with authoring spelling in, is the declared list operator.
  • $contains, with authoring spelling contains, is the membership test the spec declares for a multiple: true / JSON-stored column.

A pin reconciles both against the schema and the vocabulary. The vocabulary declares no array-valued contains operator: $contains is z.string().

2. What changed

  • packages/spec/src/data/filter-comparand-shape.ts gains the equality-slot arm.
    • {field:[...]} and {field:{$eq:[...]}} are refused with the face's existing INVALID_FILTER / 400 envelope.
    • The leading sentence is driver-memory's arrayComparandError verbatim, so one condition keeps one wording.
    • The message names the field and the path, then prescribes {"$in": […]} (authoring in) and {"$contains": "…"} (authoring contains) on a multi-value field, with an $or of those for any-of.
    • It fits under the 500-char client bound, including a 60-char received-list preview, which the bound test pins at 498.
    • Scope stops at $eq: null, every scalar and a { $field } reference pass exactly as before.
    • The header's 「closes that door for every driver at once」 now lists both slots it is true of: the list-operator slot and this one. A new "Refused BY RULING, 2026-09-23" section records the ruling, the measured table and the scope.
  • Both engine doors reach the arm. The engine's object branch calls the wrapper, and its array branch calls parseFilterAST. The driver-mongodb pin measures both through the engine, as described in §3.
  • Conformance. FILTER_COMPARAND_TYPE_CASES gains three door-refusal rows: implicit, $eq, and nested under $or. This is the one door-refusal table every driver suite already runs through parseFilterAST (driver-sql, driver-memory, driver-mongodb, driver-sqlite-wasm, driver-turso). Its "What belongs here" note says why an array where ONE value belongs sits beside "a plain object in a scalar slot". FILTER_TEXT_CASES is untouched.
  • driver-mongodb pin — mongodb-equality-array-comparand-refusal.test.ts, 13 tests. ⛔ No driver source edit.
    • A direct caller composing parseFilterAST then translateFilter is refused, and translateFilter is never reached.
    • A recording engine whose only read path is translateFilter refuses both doors and $eq, and count nested under $or, with translateFilter called zero times.
    • Lit controls: a scalar, $in and $eq: null.
    • A reverse-direction pin shows that translateFilter handed the shape directly still emits it unchanged, so the face is the only guard.
    • mingo is the named proxy, and its readings are recorded in the docblock. mingo is not a dependency of this package, so the pin does not run it. A live mongod is stated as NOT MEASURED.
  • ADR-0087: the semantic entry 18.filter-equality-array-comparand-refused is added, following the 18.view-filter-rule-scalar-operator-array-refused precedent. registry.ts was regenerated with gen:migration-registry, and check:migration-registry is green.
  • Changesets:

3. Tests, firing control, and gates

Firing control: the refusal pins turn red on the face as it stood. The two new throws were disabled through scripts/ablation-replace.mjs. Each anchor hit once, and the blob moved from ef772a616c to f8fd530c31. An EXIT/INT/TERM trap restored the file.

suite on the ablated face after restore
filter-comparand-shape + filter-field-reference-lowering, spec source 14 red / 81 green; every lit control stays green 82 + 13 green
driver-mongodb pin, spec rebuilt 10 red / 3 green (the lit controls and the reverse pin) 13 / 13 green
driver-memory comparand-type conformance, spec rebuilt exactly the 3 new rows red / 20 green green

For the two spec-rebuilt rows, ablation-dist-preflight found both markers present in packages/spec/dist on the mutate leg. On the restore leg it found them absent from all 216 built files, with the tree clean. The restore was proven with a HEAD blob-hash match and an empty git diff HEAD.

Suites, run on bec8f4c737. 438d385af3 differs only by the prose-id baseline JSON.

  • @objectstack/spec: 525 files, 15510 passed, 2 todo
  • objectql: 304 files, 5069 passed
  • driver-memory: 52 files, 1248 passed
  • metadata-core: 16 files, 285 passed
  • driver-mongodb: 26 files passed and 5 live-mongod files skipped; 578 passed
  • metadata-protocol: 188 files, 2673 passed
  • service-queue: 5 files, 77 passed

Run on 723f254402, before the consumer fixes: driver-sql 2648 passed (168 skipped), formula 915, driver-turso 1302, driver-sqlite-wasm 521, service-analytics 2442, plugin-sharing 913, lint 4121.

Typecheck is clean for spec, driver-mongodb, driver-memory and metadata-core. --listFiles shows each touched test file inside its package's program.

Gates, run on the final head 438d385af3:

  • dispatch-gates --commands derived 95 families. 93 exit 0.
  • 2 are NOT MEASURED, both exit 3 with PREREQUISITE NOT MET because they need the whole workspace built: check:dual-build-cjs-loads and check:type-check-debt.
  • --ran reconciliation: 95 derived, 93 run, 2 NOT-MEASURED, 0 UNRUN.
  • Key verdict lines:
    • check-adr-0087-registration: [BREAKING+bang+clause-②-narrowing] registered filter-equality-array-comparand-refused (new here)
    • check:doc-authoring: clean, with the baseline shrink below
    • check:engine-double-contract and check:nul-bytes: green
  • @objectstack/spec check:generated: all 15 artifacts current.

4. Consumers that broke, and how each was re-judged

The repo was grepped (examples, seeds, docs, published skills, fixtures, tests) for the FilterArray triple on = / == / equals / eq carrying an array, for $eq carrying an array, and for filter / where objects with an array field value. No shipped example, seed, doc or skill authors the shape. The full suites above went red in exactly four places:

  1. filter-comparand-shape.test.ts pinned {tags:{$eq:['a','b']}} and {tags:['a','b']} as passing. It pinned the exact slot the ruling closes, so both rows are inverted.
  2. filter-field-reference-lowering.test.ts pinned ['stage','=',['a','b']] lowering to the implicit form. The row is re-judged, not dropped: it now asserts the refusal names the implicit slot and not $eq, which still proves that an array is not promoted the way a reference is.
  3. Two probe helpers passed ['a','b'] through every AST spelling. They are loweredOperatorOf in the spec suite and in driver-memory's memory-filter-ast-vocabulary.test.ts, and the latter turned 4 tests red. Each helper stated that the probe "never trips the shape door". The equality spellings now refuse it, so the helper reads that refusal as undefined, which is the answer it always gave them.
  4. @objectstack/metadata-core's engine-double dispatch tables carried three ARRAY where.id rows: delete array id, no multi, and update array id, no multi and SCALAR data.id beside an ARRAY where.id. The real engine now refuses that input at the face before the dispatch runs, and objectql's 测试替身比真实实现宽松:四个缺陷因此带着绿灯发布——需要一条把替身钉在真实契约上的闸门 #4550 harness requires the engine's words to equal the predicate's. That turned 4 tests red. The rows are retired with a note, and the predicates are unchanged. The $in rows keep the non-scalar-id coverage, and scalar*Id's array pins stay. The changeset is patch. check:doc-authoring's prose-id baseline shrank by one (#11230 6 → 5 in that file) through its own --census-ledger remedy.

⚠️ Conflict, stated rather than settled silently. The dispatch said "fix a fixture only if it is plainly an authoring mistake". Items 3 and 4 are not authoring mistakes. They were fixed because the dev contract's clause wins: a published surface this change made false must be fixed in the same PR. After this change, ENGINE_*_DISPATCH_CASES claimed a dispatch refusal the real engine no longer gives.

Two alternatives were weighed and not taken:

The seat may prefer another disposition, and the change is reversible.

Files beyond the claim's declared surface, each for the reason given:

  • filter-comparand-type.ts, one comment sentence. It said the equality-slot array is "answered per driver", which this change made false.
  • filter-comparand-type-conformance.ts, where the conformance rows live.
  • filter-field-reference-lowering.test.ts: item 2.
  • driver-memory/src/memory-filter-ast-vocabulary.test.ts: item 3, test-only.
  • metadata-core/src/engine-{delete,update}-dispatch.ts: item 4, table rows and notes only.
  • scripts/doc-authoring-prose-id.baseline.json: the shrink.

5. Compile surfaces, face by face (seat amendment after the at-tier FAIL 5808368753)

Written by the domain:spec seat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d), which took this card over on the maintainer's 「你接手派补丁轮」. The FAIL's one blocking item was the missing face-by-face declaration. Each face below gives the review's file:line evidence and one of three conclusions: changed, already compliant, or out of scope with the reason. No code changed for this amendment; the head is still 438d385af3.

# face conclusion evidence
1 driver-sql changed — behind the shared face on both engine doors §2 above. ⚠️ "nested → 500" describes the base 2548ba57de; origin/main has since carried #19885, whose nested leaf takes assertCompilableComparand
2 driver-turso RemoteTransport (remote mode) already compliant, and now also behind the shared face remote-transport.ts:487 classifies a bare array as requireValue, :612 names it for refusal, :660 sets INVALID_FILTER. "driver-turso is built on driver-sql" above is true of local mode only; remote mode is its own compiler
3 read-scope-sql compileScopedFilterToSql out of scope — policy scopes never pass the shared face Neither it nor its callers (native-sql-strategy.ts:654, objectql-strategy.ts:559) call parseFilterAST. A bare array fails closed as READ_SCOPE_COMPILE_FAILED / 500 (:755, :436-440). $eq: [...] binds the array (:1273). Filed as #19975
4 analytics filter-normalizer changed for the FilterArray form; the OBJECT form is out of scope FilterArray refused through parseFilterAST (:1521). The OBJECT form, read as IN, is #19888
5 formula already compliant at origin/main; this PR changes no formula file Since #19946 (#19886 phase 2a), matches-filter.ts:196-255 refuses the bare array and $eq / $ne arrays
6 objectql having (half-face) out of scope — still answers by JS coercion engine.ts gates where (:866, :939) and aggregations[i].filter (:14776) but hands ast.having to applyHaving ungated (:14885, :14929). having-filter.ts:357-363: having: { total: [5] } is true. A cross-lane objectql edit outside this claim; filed as #19974
7 driver-memory / driver-mongodb changed (memory: behind the face) / pinned (mongodb: mongodb-equality-array-comparand-refusal.test.ts, 13 tests) §2–§3 above

Correction to §2: "Both changesets and this body therefore carry the same line" is not exact. The metadata-core changeset (a patch with no BREAKING) carries no Clause-② line; the spec changeset and this body do.

Acceptance notes

These are observed and not fixed here. The report carries each one.

  • driver-sql answers the equality-slot array nested under $and / $or / $not with a 500 DATABASE_ERROR on a direct SqlDriver.find: SQLite cannot bind the list. The top-level form gets its own 400. Every platform door now refuses the shape first, so only a direct-driver caller still reaches the 500. This is reported as a finding.
  • service-analytics' normalizer does not route the OBJECT form {field:[...]} through the shared face, and still charts it as IN. Its FilterArray form is now refused. Reported as a finding.
  • FilterConditionSchema still parses {field:[...]}, because FieldOperatorsSchema.$eq is z.any(). A stored filter carrying the shape therefore publishes clean and is refused at query time. That schema-door twin is not in the ruling and is reported as a finding.
  • $ne carrying an array measured the same cross-backend split and is reported for its own ruling. The ViewFilterRule schema door already refuses not_equals plus an array.
  • Two texts are now stale for the equality slot only, and neither is edited: ⛔ there is no driver source edit, and the test is not in the claim. driver-memory's arrayComparandError still says the spec "comparand door leaves this position to the driver", and filter-comparand-type.test.ts's test title says "their semantics are per-driver today". Carrier: none.
  • origin/main has moved 6 commits past the base. The branch is not merged with it. A driver-less git merge-tree --write-tree against origin/main is clean, and the only overlapping path is the generated registry.ts, which is a sorted union. None of the upstream-added lines authors the shape. CI's merge-ref run and the queue validate the merged tree.

Generated by Claude Code

…and-shape face

The comparand-shape face gains its equality-slot arm: an array as an
implicit-equality comparand ({ field: [...] }, what the equality spellings
lower an array to) or under $eq is refused with the face's INVALID_FILTER /
400 envelope, naming $in and $contains as the remedies. $ne and the other
scalar operators are not judged. Adds the conformance rows, re-judges the two
fixtures that pinned the old accept set, and registers the ADR-0087 semantic
entry filter-equality-array-comparand-refused.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…rray before translateFilter

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…w refuses before dispatch

The engine-double dispatch tables carried three rows whose where.id is an
array. The shared comparand-shape face now refuses that input at the engine
lowering seam before the dispatch runs, so the real engine answered them with
the face's refusal and the objectql harness went red. The rows are retired
with a note; the predicates are unchanged. Also adapts driver-memory's
vocabulary probe helper, which fed an array through every AST spelling, and
records the analytics FilterArray reading in the migration entry.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-core, @objectstack/spec, touching 10 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/filter-comparand-type.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/data-api.mdx (via INVALID_FILTER (literal, a string literal in FILTER_COMPARAND_TYPE_CASES))
  • content/docs/api/error-catalog.mdx (via INVALID_FILTER (literal, a string literal in FILTER_COMPARAND_TYPE_CASES))
  • content/docs/protocol/objectql/query-syntax.mdx (via INVALID_FILTER (literal, a string literal in FILTER_COMPARAND_TYPE_CASES))

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

  • content/docs/releases/v17/17-1.mdx (via INVALID_FILTER (literal, a string literal in FILTER_COMPARAND_TYPE_CASES))
  • content/docs/releases/v17/17-4.mdx (via INVALID_FILTER (literal, a string literal in FILTER_COMPARAND_TYPE_CASES))

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
  • 1 changed file(s) yielded no anchor (packages/spec/src/data/filter-comparand-type.ts) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 136 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 1f89ba0d704a797225ab8cf306e4cdec89edae8e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1f89ba0d704a797225ab8cf306e4cdec89edae8e

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

This was referenced Sep 23, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 57/57 CONTRACT_REVIEW_TIER
Head-sha: 438d385af3913f136ebb71f031da4a48ce0066c1

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session on the maintainer's instruction in that session (「帮我处理」, with the landing route chosen there); every one of its 57 transcript turns served at the tier the constant names. Adopted by the seat 2026-09-24T05:38Z. The record below is the reviewer's, unedited except the two header lines.

Inputs read: card #19757 body plus all 6 comments (ruling 5793368540 included); PR #19882 body, pulls/19882/files (14 rows, +844/-20), the application/vnd.github.diff diff, 1 issue comment (docs-drift bot), 0 reviews, 0 review threads; commits/438d385af3913f136ebb71f031da4a48ce0066c1/check-runs → 35 rows: 32 success, 3 skipped (Packed-tarball smoke opt-in, Console Pin Gate, Build Docs), 0 failure (jq -r '.check_runs[]|"\(.status)\t\(.conclusion)"' | sort | uniq -c); no gate family re-run locally. Git: origin/main = c1641868a3da71537c9c6c572d2bd2044103236b; PR ref = 5 commits atop merge-base 2548ba57deb83062cc94ea91a644309092251a0f (git log --oneline refs/review/pr-19882 ^origin/main: 798c877, edff57a, 723f254, bec8f4c, 438d385); git merge-tree --write-tree origin/main refs/review/pr-19882 → CLEAN.

① Derived judgments

  1. Shared-face arm — RIGHT against 乙 item 1. packages/spec/src/data/filter-comparand-shape.ts at refs/review/pr-19882: assertFieldListComparands throws arrayEqualityComparandError when Array.isArray(spec) (implicit slot; diff lines 806-808) and when op === '$eq' with an array comparand (diff 819-823); the existing walker recurses under $and / $or / $not, so depth and the empty array are covered (spec test rows, diff 430-447). No alias, no grace window anywhere in the arm. Envelope: invalidFilterComparandError → code: 'INVALID_FILTER', status: 400, asserted in the spec test (diff 444-445), in the mongo pin (diff 262-263, 299-300) and carried by the three conformance rows (code: 'INVALID_FILTER', diff 856-881). Scalars, null / $eq: null, { $field }, list operators incl. $in: [] / $nin: [], and no-$-key maps pass exactly as before (LIT CONTROL tests, diff 519-564).
  2. Remedy spellings — RIGHT. The ruling names FilterOperatorSchema; git grep -n FilterOperatorSchema origin/main -- packages/spec/src → only a comment naming ODataFilterOperatorSchema (api/odata.zod.ts:265), no such export. The PR reads $in / in and $contains / contains off FieldOperatorsSchema and AST_OPERATOR_MAP and pins both (test diff 501-517). $contains as MEMBERSHIP on a multiple: true / JSON column is the spec's declared reading (filter.zod.ts:901-912 at origin/main, driver-sql: the $contains MEMBERSHIP spelling on any multi-valued / JSON column is a DATABASE_ERROR 500 on live PostgreSQL (SQLSTATE 42883, operator does not exist: json ~~ text) — it has only ever been executed on SQLite #17590). Same docblock :942 records driver-memory "DOES NOT ANSWER IT YET" (driver-memory: the stored-ARRAY value axis is still unrepaired outside the equality arm — $in/$nin, the text family and the ordering family answer one filter two ways, and the two exclusion arms answer it in the WIDENING direction #17286) — the prescription follows the spec as the ruling asks; the delivery gap is pre-existing and not this PR's.
  3. "Leading sentence is driver-memory's verbatim" — TRUE: packages/drivers/driver-memory/src/filter-refusal.ts:729-734 at origin/main vs face diff 762-769 share the first sentence to the word.
  4. Both engine doors reach the arm — RIGHT. packages/objectql/src/engine.ts at PR ref: lowerWhereFilterArray calls the delegating wrapper on the object form (:866) and parseFilterAST with the context prefix on the array form (:939); it runs for find :10075, findOne :10365, update :11683, delete :14142, count :14659, aggregate :14763, and aggregations[i].filter is gated at :14776. The wrapper packages/objectql/src/filter-comparand-shape.ts:103-109 delegates and is unchanged.
  5. driver-mongodb pin — RIGHT against item 2. New mongodb-equality-array-comparand-refusal.test.ts, 13 tests: a recording double whose only read path is translateFilter gets 0 calls through Door 2, the object form, $eq and count under $or; direct-composition pin; reverse-direction pin keeps the driver's own passthrough visible; mingo 7.2.4 named, "A live mongod is NOT MEASURED" stated (docblock lines 145-153). No driver source edit (files list). @objectstack/objectql is an existing devDependency (driver-mongodb/package.json:30 at PR ref; not in the PR's file list).
  6. Header sentence and conformance — RIGHT against item 3. The header now lists both slots the sentence is true of (diff 631-641). FILTER_COMPARAND_TYPE_CASES gains 3 door-refusal rows; git grep -l FILTER_COMPARAND_TYPE_CASES origin/main shows the table consumed by driver-sql, driver-memory, driver-mongodb, driver-sqlite-wasm, driver-turso and objectql suites, and driver-sql's harness runs door-refusal rows through parseFilterAST once (sql-driver-comparand-type-conformance.test.ts:37-42,99-102).
  7. ADR-0087 — RIGHT. New 18.filter-equality-array-comparand-refused.ts; registry.ts +69 is the same text (generated); no D2 conversion, hand migration only, matching the sibling 18.view-filter-rule-scalar-operator-array-refused shape.
  8. Follow-ups not absorbed — RIGHT. [finding] FilterConditionSchema still PARSES { field: [...] } and { field: { $eq: [...] } }, so a stored dataset or widget filter publishes clean and is refused at query time on every backend #19889: filter.zod.ts untouched, FieldOperatorsSchema.$eq remains z.any() (origin/main :266). [finding] $ne with an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886: $ne is skipped at the face (diff 815-819) and carried only as it.todo (diff 566-571), no green pin.
  9. Consumer re-judgments — acceptable: two spec pins that asserted the exact slot the ruling closes are inverted; the field-reference row is re-judged to the refusal; two probe helpers now catch; metadata-core's three ARRAY where.id rows retired (see ③).
  10. MISSING — the compile-surface declaration. .claude/skills/pm-dispatch/references/execution-duties.md:137 at origin/main: filter/predicate semantics ⇒ declare face by face against the compile-surface list, ⛔ never silently skip. Against compile-surfaces.md at origin/main, the PR body concludes on these faces:
  • Face 1 driver-sql: declared (400 top-level / 500 nested before; behind the door after). OK. Note origin/main since the base carries [finding] driver-sql answers an equality-slot array NESTED under $and / $or / $not with 500 DATABASE_ERROR, while the same shape at top level gets 400 INVALID_FILTER #19885 (sql-driver.ts +46: the nested leaf now takes assertCompilableComparand), so the PR's "nested → 500" describes the base only.
  • Face 2 turso RemoteTransport: NOT NAMED — grep -c of the PR body for RemoteTransport, remote-transport, buildWhereSQL → 0, 0, 0. The body calls driver-turso "built on driver-sql", true of local mode only; row 2 of the list says the remote compiler does not inherit face 1. Source at origin/main: remote-transport.ts:487 classifies a bare array as requireValue, :612 names it an array for refusal, :660 sets INVALID_FILTER; so it refuses on its own and now sits behind the shared face. Compliant, silently skipped.
  • Face 3 read-scope-sql compileScopedFilterToSql: NOT NAMED (0 hits for read-scope, compileScopedFilterToSql). Neither read-scope-sql.ts nor its callers (native-sql-strategy.ts:654, objectql-strategy.ts:559) call parseFilterAST / assertListComparandShapes. Bare array → readScopeCompileError = READ_SCOPE_COMPILE_FAILED / 500 fail-closed (:755, :436-440); $eq: [...] → col = ? with the array bound (:1273) — a source reading, not executed; whether a policy scope crosses the face further upstream is not traced here. Silently skipped.
  • Face 4 analytics filter-normalizer: declared — FilterArray form now refused through parseFilterAST (:1521), OBJECT form still read as IN and explicitly reported out of scope. OK.
  • Face 5 formula: the before-state is declared ("no row"); the after-state is not. origin/main since the base carries fix(formula): refuse an array comparand under $ne and in the equality slot (write-check bypass) #19946 ([finding] $ne with an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 phase 2a): matches-filter.ts:196-255 refuses the bare array and $eq / $ne arrays (0 hits for 19886 at base 2548ba57de). Seat-5 ruling 5805921577 left fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882's scope unchanged. No conclusion in the PR body.
  • Half-face objectql having-filter: NOT NAMED (0 hits for having, applyHaving, matchesHaving). Measured from source: engine.ts at PR ref gates where (:866, :939) and aggregations[i].filter (:14776) but hands ast.having to applyHaving ungated (:14885, :14929); having-filter.ts:357-363 at origin/main sends an array condition into the implicit-equality arm value == condition, and the $eq arm is value != target (:396); node -e → 5 == [5] true, 'a' == ['a'] true. So having: { total: [5] } still ANSWERS, by JS coercion, on every driver — the slot the ruling closes stays open on the one face the list marks as having no conformance table, so no gate goes red for it. Silently skipped, with a live open slot.
  • driver-memory / driver-mongodb: declared. OK.
    Context, not excuse: the dispatch claim 5794711842 did not copy the face list into the order (dispatch-runbook.md:231 says the dispatcher does); the PR-side duty at execution-duties.md:137 stands regardless.

② Semver level

  • .changeset/19757-equality-slot-array-refused.md: "@objectstack/spec": minor; summary fix(spec)!:; **BREAKING** banner; Clause-②: no (narrowing) — … line; exactly one HTML-comment marker reading adr-0087: registered filter-equality-array-comparand-refused. AGENTS.md at origin/main :1084-1085 ((narrowing) is BREAKING; no (widening) malformed, no (narrowing) is the valid arm) and :1095-1096 (one marker; the changeset carries the PR's Clause-② line) are met. scripts/check-changeset-no-major.mjs:63 and :1463-1467: an accept-set narrowing ships minor in the launch window. Marker spelling matches scripts/check-adr-0087-registration.mjs:62 and the registered reader at :1931; the id resolves through :1968 (id: 'filter-equality-array-comparand-refused', in the new entry) and is new in the diff, as registered requires. The arm is read by scripts/pm/clause2-line.mjs (:243-247 reads the arm with trailing prose after it). Check Changeset and Lint & Repo Gates are success at head. Level and declaration: CORRECT.
  • .changeset/19757-dispatch-cases-array-where-id-retired.md: "@objectstack/metadata-core": patch, no BREAKING, no marker — consistent: three rows leave an exported conformance table, predicates and exports unchanged, nothing a consumer compiles against moves.
  • PR body sentence "Both changesets and this body therefore carry the same line" is false: the metadata-core changeset carries no Clause-② line (diff lines 7-23). Prose only, non-blocking.

③ Boundary flags

Implemented-by: claude/issue-19757-equality-slot-array-refused
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: FAIL — one blocking item: the face-by-face compile-surface declaration required by execution-duties.md:137 is missing for turso RemoteTransport, read-scope-sql and the having half-face, and absent as an after-state for formula; the having half-face measurably still answers an array in the equality slot. Everything else, including the shared-face arm, the envelope, the mongo pin, the changeset level and the ADR-0087 registration, is correct against ruling 乙.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 68/68 CONTRACT_REVIEW_TIER
Head-sha: 438d385af3913f136ebb71f031da4a48ce0066c1

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session; every one of its 68 transcript turns served at the tier the constant names. Re-review after the takeover patch round (the maintainer's 「你接手派补丁轮」); it supersedes the FAIL record 5808368753. Adopted by the seat 2026-09-24T14:11Z. The record below is the reviewer's, unedited except the two header lines.

Re-review on the same head after record 5808368753 (FAIL, one blocking item). Inputs read: card #19757 body plus all 7 comments (ruling 5793368540, takeover 5814344181, dev report 5796984894, seat-5 note 5805928574); PR #19882 body as amended at 2026-09-24T13:59:04Z, pulls/19882/files (14 rows, +844/-20), 2 issue comments (docs-drift bot, prior record), 0 reviews, 0 review threads; issues #19974, #19975 (both open, 0 comments, no labels, no assignee, filed 13:57:26Z and 13:57:54Z), #19886, #19889, #19888; commits/438d385af3/check-runs → 42 rows: 37 success, 5 skipped (Auto Label, Check PR Size, Console Pin Gate, Build Docs, Packed-tarball smoke opt-in), 0 failure; the 7 rows after 13:59Z are the re-runs the body edit triggered (the four claim / part-of checks and Check Changeset all success); no gate family re-run locally. Git: refs/review/pr-19882 = 438d385af3 = the same 5 commits atop merge-base 2548ba57de; origin/main = 2c1011b01b, 36 commits past the PR base 1f89ba0d70; git merge-tree --write-tree origin/main refs/review/pr-19882 exit 0 (clean). No file in the diff changed since the prior record, so every code-level judgment there holds by identity; each was spot-checked again below.

① Derived judgments

  1. Head and diff unchanged — the prior record's items 1 to 9 stand. Spot-checks at the PR ref: the arm throws arrayEqualityComparandError on Array.isArray(spec) (filter-comparand-shape.ts:779-780) and on op === '$eq' with an array (:792-794); $ne is explicitly not judged (:258); the face runs inside parseFilterAST (filter.zod.ts:4 import, :2438 call) and behind the objectql wrapper (objectql/src/filter-comparand-shape.ts:43 re-export, :109); the header now names both slots the sentence is true of (:73-83); it.todo for $ne at test :400 and :623; ADR-0087 id at entries/semantic/18.filter-equality-array-comparand-refused.ts:14 and generated registry.ts:8711; three door-refusal conformance rows; FILTER_COMPARAND_TYPE_CASES consumed by the driver-sql, driver-memory, driver-mongodb, driver-sqlite-wasm, driver-turso and objectql suites at origin/main.
  2. Section 5 row 1 (driver-sql, changed) — TRUE. #19885 is the issue; it landed as PR fix(driver-sql): a bare comparand nested under $and/$or/$not gets the top-level refusal and compilation #19908, commit beac79802 on origin/main (Fixes #19885), and sql-driver.ts:15673-15684 now calls assertCompilableComparand(field, '=', value, condition) on the nested leaf, so the "nested → 500" cell is indeed a base-only reading, as the row says.
  3. Row 2 (turso RemoteTransport, already compliant) — TRUE at origin/main. remote-transport.ts:487 classifies Array.isArray(spec) as requireValue under the comment "A bare array is REFUSED by serializeComparand"; serializeComparand (:3946-3959) admits only null, a bindable Date and isAcceptedFilterComparand values and throws uncompilableComparand for the rest; :612 names the form an array; :658-661 sets INVALID_FILTER / 400. Pinned at remote-transport-comparand-refusal.test.ts:213-224 for both $eq and the implicit slot. compile-surfaces.md row 2 confirms remote mode is its own compiler; "now also behind the shared face" is true for engine-routed queries (engine.ts:866 / :939 gate before any driver).
  4. Row 3 (read-scope-sql, out of scope, filed) — TRUE as a source reading. compileScopedFilterToSql is exported at read-scope-sql.ts:503; its only two non-test callers are native-sql-strategy.ts:654 and objectql-strategy.ts:559; none of the three files calls parseFilterAST or assertListComparandShapes (the only hits are comments at native-sql-strategy.ts:218, :278, objectql-strategy.ts:1791). A bare array throws readScopeCompileError at :755-756, whose envelope is READ_SCOPE_COMPILE_FAILED / 500 (:436-440); $eq compiles to col = ? via bind (:1273), and bind (:774-777) pushes any value unchecked, so an array is bound. read-scope-sql compiles $eq: [...] in a policy scope as col = ? with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975 records exactly this, with measurement as its step 1. A policy-scope compiler is not a driver, the claim's surface excluded it, and the ruling's letter names the shared face, the mongodb pin, the header sentence and the conformance suite — "out of scope with reason" is the right conclusion.
  5. Row 4 (analytics filter-normalizer, FilterArray changed, OBJECT form out) — TRUE. :1521 runs parseFilterAST, which now carries the arm; the OBJECT form at :1078-1080 ("Implicit equality / array → in") is untouched and is [finding] service-analytics' filter normalizer reads an implicit-equality ARRAY as IN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888 (open, pm:blocked, blocked-by [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).
  6. Row 5 (formula, already compliant at origin/main) — TRUE. fix(formula): refuse an array comparand under $ne and in the equality slot (write-check bypass) #19946 is commit c1641868a on origin/main; matches-filter.ts:202 declares ARRAY_REFUSED_OPERATORS = ['$eq', '$ne'], :248 refuses the bare array, :250-253 refuse $eq / $ne arrays, all inside the up-front walk assertFilterShape called from matchesFilterCondition (:221). No formula file is in this PR's 14 rows.
  7. Row 6 (objectql having half-face, out of scope, filed) — TRUE and the slot is measurably open. At the PR ref engine.ts:866 and :939 gate where, :14776 gates aggregations[i].filter, and :14885 / :14929 hand ast.having to applyHaving ungated; the same holds at origin/main at :870, :943, :15059, :15168, :15212, and this PR does not touch engine.ts. having-filter.ts:279-282 filters through matchesHaving (:292-322, no shape gate; a non-$ key goes to checkCondition), and :356-364 sends an array condition to value == condition; node -e gives 5 == [5] true and 'a' == ['a'] true. No driver consumes ast.having: git grep -i having origin/main -- packages/drivers (non-test) hits only comments in driver-memory. objectql having answers an array in the equality slot by JS coercion (having: { total: [5] } is true) — the one face ruling 乙 closes "for every driver at once" that no gate reaches #19974 records the seam and a remedy shape.
  8. Row 7 (driver-memory behind the face; driver-mongodb pinned) — TRUE. The pin has 13 tests: 6 it.each rows composing parseFilterAST then translateFilter (:126-141), 1 lit control (:143), 4 it.each rows through both engine doors with translateFilter called zero times (:163-177), 1 lit control (:179), 1 reverse-direction pin (:186); mingo 7.2.4 is named (:19) and "A live mongod is NOT MEASURED" is stated (:24); the only file under driver-mongodb/src in the diff is the test. driver-memory runs the table through memory-comparand-type-conformance.test.ts.
  9. Coverage of compile-surfaces.md at origin/main: faces 1 to 5, the half-face, and the 已解冻 pair map onto rows 1 to 7 with no face missing, each with one of the three allowed conclusions and file:line evidence. The duty at execution-duties.md:137 ("编译面清单逐面申报,⛔ 静默略过") is now met. The "Correction to §2" is also true: the metadata-core changeset (17 lines) carries no Clause-② line.
  10. Ruling 乙 versus the having half-face — filing objectql having answers an array in the equality slot by JS coercion (having: { total: [5] } is true) — the one face ruling 乙 closes "for every driver at once" that no gate reaches #19974 is an acceptable discharge for THIS PR. The ruling's three numbered items name the shared-face arm, the driver-mongodb pin, and the header sentence plus the conformance suite; all three are delivered at this head. "For every driver at once" is a sentence about drivers, and every driver is reached only through parseFilterAST or the engine's where / aggregations[i].filter gates, both of which now carry the arm, with every driver conformance suite running the three new rows. having is not a driver face: no driver reads ast.having, applyHaving runs engine-side after aggregation, and it gives one answer on every driver — so it is not the cross-driver divergence the ruling closes but a same-package inconsistency between the where slot and the having slot. compile-surfaces.md marks it a half-face whose DECLARATION duty is undiminished, which is what the amendment satisfies; a gate there is an objectql source edit outside the claim 5794711842 (spec plus one mongodb pin, "⛔ No driver source edit"), and the prior record's remedy (a) offered exactly this filing. Correctly carried by objectql having answers an array in the equality slot by JS coercion (having: { total: [5] } is true) — the one face ruling 乙 closes "for every driver at once" that no gate reaches #19974.
  11. Dedupe of the two new cards checked against the open set: [finding] service-analytics' filter normalizer reads an implicit-equality ARRAY as IN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888 is the analytics OBJECT form (a different face), [finding] $ne with an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 is $ne (a different slot, with its formula half already landed as fix(formula): refuse an array comparand under $ne and in the equality slot (write-check bypass) #19946), [finding] FilterConditionSchema still PARSES { field: [...] } and { field: { $eq: [...] } }, so a stored dataset or widget filter publishes clean and is refused at query time on every backend #19889 is the schema door; neither new card duplicates them.

② Semver level

Unchanged and confirmed. .changeset/19757-equality-slot-array-refused.md at the PR ref: "@objectstack/spec": minor (:2), summary fix(spec)!: (:5), **BREAKING** banner (:7), Clause-②: no (narrowing) — … (:61), and exactly one HTML-comment marker reading adr-0087: registered filter-equality-array-comparand-refused (:63). AGENTS.md at origin/main :1084-1085 ((narrowing) is BREAKING; no (widening) malformed) and :1095-1096 (one marker; the changeset carries the PR's Clause-② line) are met; scripts/check-changeset-no-major.mjs:1463-1467 and :1502 ship a declared narrowing as minor in the launch window; scripts/check-adr-0087-registration.mjs:62 is the marker spelling. Check Changeset re-ran at 2026-09-24T13:59:11Z → success. .changeset/19757-dispatch-cases-array-where-id-retired.md: "@objectstack/metadata-core": patch, no BREAKING, no marker, no Clause-② line — consistent, since three rows leave an exported table and no export or predicate moves. Level and declarations: CORRECT.

③ Boundary flags

Implemented-by: claude/issue-19757-equality-slot-array-refused
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 14:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 7536721 Sep 24, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19757-equality-slot-array-refused branch September 24, 2026 14:44
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…$eq instead of binding it (objectstack-ai#19994)

Fixes objectstack-ai#19975

Clause-②: no (narrowing)

## What this changes

`compileScopedFilterToSql`
(`packages/services/service-analytics/src/read-scope-sql.ts`) lowers a
row-level read scope into the SQL that the analytics NativeSQL path
executes and the `/analytics/sql` echo prints. It already refused a list
in the implicit equality slot (`{ f: [...] }`) with
`READ_SCOPE_COMPILE_FAILED` / 500. The explicit spelling, `{ f: { $eq:
[...] } }`, compiled to an equality with the whole list bound as one
parameter, which left the meaning of the predicate to the executing
database.

A new gate, `assertNoListInEqualitySlot`, refuses that spelling in
`compileField`, at any depth under `$and` / `$or` / `$not`, in this
module's own envelope. It runs before the member gates, so a list is
reported as a list and not by one of its members. This applies ruling 乙
(objectstack-ai#19757, record `5793368540`: 「an array in the implicit-equality slot is
refused at the shared face, for every driver at once」) to a compiler
that never reaches the shared face.

## Declaration

**BREAKING**: this narrows what `compileScopedFilterToSql`, exported
from `@objectstack/service-analytics`, accepts. A read scope carrying `{
f: { $eq: [...] } }` compiled before this change and is refused after
it. The remedy is `{ f: { $in: [...] } }`. The changeset ships the
narrowing as `minor` under the launch-window convention for accept-set
narrowings, with a `!` on its headline, the `Clause-②: no (narrowing)`
line and an ADR-0087 `not-required (no-migration-prescription)`
disposition: no authorable key, spelling or stored shape moves, and an
authored policy never emits this spelling.

## Measured first

The measurement was recorded on this branch as `c647adb6cc`, before any
source change. This is an abstract summary; the tests are the pins.

- **Authoring door.** The published RLS policy schema and the RLS
authoring lint's decision procedure both admit an equality predicate
whose comparand is a list, whether a list literal or a membership
variable.
- **Lowering.** That predicate lowers to the implicit spelling. The CEL
lowering emits `$eq` only around a `{ $field }` reference, so no
authored policy produces a list under `$eq`. The tenant layer, the
sharing read filter and the controlled-by-parent filter do not emit
`$eq` at all.
- **This compiler.** The implicit spelling reaches it through the
security service's read filter and is refused (500). A list under `$eq`
reaches it only from a host-supplied `getReadScope` or from a direct
caller of the export, and it compiled.
- **Engines.** On the NativeSQL execute path the bound list got four
different answers depending on the engine: a driver error, zero rows,
rows the scope never named, and every row when negated. Measured on
better-sqlite3 and sql.js through the drivers, and on a local PostgreSQL
16 through `driver-sql` and through a plain `pg` pool. MySQL is NOT
MEASURED: there is no server in the container.

## Deliberate choices

- **500, not the shared face's 400.** The objectstack-ai#5367 ruling, re-affirmed as
objectstack-ai#7598 Q2 = A and recorded in this module's header, keeps every refusal
of this compiler at `READ_SCOPE_COMPILE_FAILED` / 500 with the message
withheld. The scope is a policy the caller cannot author, and a 4xx
would echo it back to them. The card's 400 belongs at the policy's
authoring door, which is not this file.
- **The module's own wording, not a call into the shared face.**
`assertListComparandShapes` throws `INVALID_FILTER` / 400. It also
judges more than the equality slot: list-operator shapes, null members,
null ordering comparands and `$between` bounds. Calling it here would
change other refusals of this compiler, and each of those has its own
ruling on this door. The new sentence follows this module's bare-array
refusal, so both spellings of the one condition read the same way in the
operator's log.
- **`$ne` with a list is not judged.** Ruling 乙 names equality only.
`$ne` falls under ruling A of objectstack-ai#19886 and is handled on that card.

## Compile surfaces (a list in the equality slot)

| surface | verdict |
|:--|:--|
| `compileScopedFilterToSql` (service-analytics read scope) |
**changed.** A list under `$eq` is refused. The implicit list was
already refused and is now pinned at every depth. |
| `assertListComparandShapes` (spec shared face) | **already
compliant.** This is ruling 乙's own face (objectstack-ai#19882, landed). Measured:
`INVALID_FILTER` / 400 for the implicit list, for `$eq`, and under
`$not`. |
| `matchesFilterCondition` (formula) | **already compliant.** Measured:
`INVALID_FILTER` / 400 for the same three shapes (objectstack-ai#19886 stage 2a). |
| `applyFilterCondition` (driver-sql) | **already compliant.** It
refuses with 400 at the driver and behind the engine's shared-face seam
(table in the objectstack-ai#19882 changeset; not re-measured here). |
| `buildWhereSQL` (driver-turso RemoteTransport) | **already
compliant.** 400 according to the compile-face table in the objectstack-ai#19886
stage-2a report; not re-measured here. |
| `checkCondition` (driver-memory) | **already compliant.** 400 at every
depth (table in the objectstack-ai#19882 changeset). |
| `translateFieldOperators` (driver-mongodb) | **out of scope.** The
driver answers with MongoDB array equality. Platform doors reach it only
through the shared face, which refuses (the declared scope of objectstack-ai#19882). |
| `lowerAnalyticsWhere` (analytics caller `where`) | **out of scope.**
This is the caller-authored filter door (the `INVALID_FILTER` / 400
family), not a read scope. Its object-form `$eq` list cell still reads
`accept` in the frozen comparand matrix. The claim records objectstack-ai#19888
against this file. |
| `applyHaving` / `matchesHaving` (objectql HAVING) | **out of scope.**
A caller-authored filter applied after aggregation, not a read scope.
Its answers are recorded in the objectstack-ai#19886 stage-2a report. |

The analytics ObjectQL execute route never calls this compiler. It hands
the scope to `engine.aggregate`, and the engine's shared-face seam
refuses the list with `INVALID_FILTER` / 400 (measured). See the
acceptance notes.

## Tests and evidence (head `feb810c3d4`, after merging `origin/main` at
`276d96dd23`)

- New `src/__tests__/read-scope-eq-array-refusal.test.ts`, 29 tests:
- `$eq` lists at every depth, including negation, and beside another
operator in either key order;
  - list-before-member precedence (`[undefined]`, `[{ $field }]`);
  - the implicit list at every depth;
  - seven neighbouring shapes that must compile unchanged;
- the NativeSQL execute face and the echo face over a real sql.js
engine. Both refuse, and no statement reaches the engine. The prescribed
`$in` serves exactly the rows it names.
- `read-scope-refusal-envelope.test.ts`: inventory row ⑯ added, and the
ratchet moves to 16 rows over 14 sites.
- `comparand-door-single-source.test.ts`: the frozen matrix's read-scope
`$eq` array cell changes from `accept` to the refusal, with a note. It
pinned exactly the bind this PR removes.
- `pnpm --filter @objectstack/service-analytics test`: 116 files and
2484 tests passed. `typecheck` exited 0, and `tsc --listFiles` includes
all three touched test files.
- Ablations. Each was run from the committed fix. The mutation went
through `scripts/ablation-replace.mjs`, and each restore was proven by
the blob hash matching HEAD.
- **A:** removing the gate call turned 18 tests red. These include every
`$eq` pin, both real-engine faces (zero rows served, and every row
served under the negation), and inventory ⑯.
  - **B:** making the bare-array arm bind turned 11 tests red.
- Gates. `dispatch-gates` derives the same 61 at `feb810c3d4` as at
`11c11c7dc3`, and all 61 were re-run on `feb810c3d4`: 59 exited 0. Two
are NOT MEASURED because their prerequisite was not met
(`check:dual-build-cjs-loads` and `check:type-check-debt` need the whole
workspace built, and CI builds it). Among the 59:
`check-adr-0087-registration --base origin/main` (1 declared-breaking
changeset, carrying its disposition), `check-changeset-no-major --base
origin/main`, `check:changeset-gate-self-tests` and
`check-issue-citations` in its board-probing mode, all exit 0.
- Lint, narrowed to the change. `eslint --no-inline-config --format
json` over the four touched TypeScript files: 4 files, 0 errors, 0
warnings. `eslint --print-config` resolves a config for each of them.
`eslint.config.mjs` never enables type-aware linting (its own note, near
line 326), so this diff cannot change the verdict on any untouched file.

## Acceptance notes

- **Authoring door, implicit spelling.** The authoring-door half of the
card for the implicit spelling is work in the objectstack-ai#19886 lane: draft PR
objectstack-ai#19947 refuses `==` against a list at the CEL lowering and in the lint.
objectstack-ai#19975 needs nothing more from it.
- **Adjacent finding, filed by the seat, not addressed here.** The
analytics ObjectQL execute route answers a read-scope list with the
engine's `INVALID_FILTER` / 400, not this compiler's 500.
- **Premise correction.** PR objectstack-ai#19882, ruling 乙's shared face, merged at
2026-09-24T14:44Z, before this branch was cut from `ae7a35a63b`. The
dispatch described it as in flight; it was not.

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… the equality slot instead of reading it as `IN` (objectstack-ai#19888) (objectstack-ai#20008)

Fixes objectstack-ai#19888

Clause-②: no (narrowing)

## What this changes

The analytics `where` door (`lowerAnalyticsWhere` in
`packages/services/service-analytics/src/strategies/filter-normalizer.ts`)
now refuses a list in the equality slot of an object-form filter, `{ f:
[...] }` and `{ f: { $eq: [...] } }`, with `INVALID_FILTER` / 400. This
applies ruling 乙 of objectstack-ai#19757 (record `5793368540`: 「an array in the
implicit-equality slot is refused at the shared face, for every driver
at once」) to the one analytics spelling that never reached the shared
face.

The new gate, `assertNoListInEqualitySlot`, walks the object-form
condition the way the shared face does: `$and` / `$or`, `$not` and field
entries, from the same `where` path seed. It hands each equality-slot
list to `assertListComparandShapes` (`@objectstack/spec/data`) as a
one-entry node. That node holds nothing but the list, so only the face's
equality arm can fire, and the refusal is the face's own: its envelope,
wording, path and `$in` prescription. The `FilterArray` spelling of the
same condition was already refused inside `parseFilterAST`. Both
spellings now produce the same bytes, and a test pins this.

Every filter source of this door passes through the gate: the caller
`where`, a dataset's scope `filter` and a measure's `filter`. That holds
on the NativeSQL execute path, the `/analytics/sql` echo and the
ObjectQL engine path. The draft-data preview (`preview-evaluator.ts`)
calls the same exported gate, so a drafted chart refuses what the
published chart refuses. The `in` reading is deleted from `fieldLeaves`.
So are the two arms that existed only because of it: the bare-array
member sweep in `assertDefinedComparands` and the bare-array guard in
`nullGuardForFieldSpec`.

Two details:

- **Nested relations.** A nested-relation object (`{ acct: { region:
[...] } }`) is descended too. The shared face leaves such an object
alone, because a driver reads it as a deep-equality comparand or another
object's condition. This compiler flattens it to the dotted member
`acct.region`, and the list is in that member's equality slot.
- **Precedence.** The list is diagnosed before any member gate. `{ d:
[1, undefined] }` gets the list refusal, not the undefined-comparand
one. The shared face and `read-scope-sql.ts` use the same order.

`$ne` with a list is not judged, because ruling 乙 names equality only.
The list operators keep their lists, including `$in: []` and `$nin: []`.
Every scalar compiles as before, including `null`.

## Measured first (recorded on this branch as `cca9717240`, before any
source change)

**In-repo stored filters.** An AST scan (TypeScript compiler API)
covered every git-tracked `.ts` / `.js` / `.json` file, plus the fenced
ts/js/json blocks of md/mdx files. It used four detectors:

- D1: `$eq` with an array literal.
- D2: a field entry with an array literal under a filter-bearing key
(`filter`, `filters`, `where`, `runtimeFilter`, `relatedListFilter`,
`having` and five more). The object is walked as a FilterCondition,
descending `$and` / `$or` / `$not` and nested relations.
- D3: a FilterArray triple on `=` / `==` / `equals` / `eq` carrying an
array.
- D4: a filter rule `{ field, operator: EQUALITY_SPELLING, value: [...]
}`.

The positive controls ran on the same walkers:

- a synthetic fixture with one case per detector (4/4 hits, in both TS
and JSON);
- a count of `$in` array literals (C1);
- a count of the filter-bearing objects walked (C2).

| scope | files | C1 | C2 | hits |
|:--|--:|--:|--:|:--|
| `examples/**` | 228 (3 tsconfig JSONC unparsed) | 1 (matches the text
grep) | 91 | **0** |
| `packages/**` | 6910 | 522 | 4636 | 5 in non-test files, **all false
positives**: 2 realtime-subscription `eventTypes` lists, 3 MongoDB
aggregation-expression `$eq: [a, b]` operands. 65 in tests, all
deliberate refusal fixtures or pins. |
| md/mdx fenced blocks (content, skills, docs, examples, packages,
.changeset) | 1545 files, 3162 blocks | 12 | 176 | 3, **all false
positives** (a `nin` rule in a design note; a plugin-permission
`filter`) |

A text grep for a `$eq` list outside `packages/` and `examples/` found 7
hits, all in changesets that describe the refusal itself. The control
(`$in` list) found 140. **No in-repo artefact carries the shape, so
nothing needed converting.** Deployed `sys_metadata` rows: NOT MEASURED
(there is no deployment data here).

**The analytics faces at base**, over a real sql.js engine. The rows are
d1 `won`, d2 `lost`, d3 `open`, d4 NULL, d5 the text `'won,lost'`:

| `where` | native execute / echo | ObjectQL engine path | draft preview
|
|:--|:--|:--|:--|
| `{ stage: ['won', 'lost'] }` | `stage IN (?, ?)`, rows d1, d2 | the
engine received `{ stage: { $in: [...] } }`, so the engine's own
shared-face check never saw the list | string-compared the row against
`'won,lost'`: d5 |
| `{ stage: { $eq: ['won', 'lost'] } }` | `stage = ?` bound to `'won'`:
d1, and `'lost'` was dropped without a word | `{ stage: 'won' }` | d5 |
| `{ stage: { $eq: [] } }` | **no WHERE at all: every row** | `{}` | no
row |
| `{ stage: [] }` | the FALSE constant | | |
| `['stage', '=' / 'equals' / '==' / 'eq', [...]]` | refused
`INVALID_FILTER` / 400 | refused | no row (the preview does not lower an
array) |
| control `{ stage: { $in: ['won', 'lost'] } }` | d1, d2 | d1, d2 | d1,
d2 |

After this change, every object-form cell above is refused with
`INVALID_FILTER` / 400, carrying the face's message (re-measured at
`896e1e70f3`).

## Compile surfaces (a list in the equality slot)

| surface | verdict |
|:--|:--|
| `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree` (analytics
caller `where`, dataset scope `filter`, measure `filter`; NativeSQL
execute, `/analytics/sql` echo, ObjectQL engine path) | **changed.**
Both spellings are refused with `INVALID_FILTER` / 400 at any depth,
measured over sql.js before and after. |
| `evaluateAnalyticsQueryOverRows` / `matchesWhere` (analytics
draft-data preview) | **changed**, as a bounded in-place fix (see
Deviations). It used to string-compare the row against the list and now
runs the same gate. |
| `assertListComparandShapes` (spec shared face) | **already
compliant.** This is ruling 乙's own face (objectstack-ai#19882). This PR calls it and
does not change it. |
| `compileScopedFilterToSql` (service-analytics read scope) | **already
compliant.** Since objectstack-ai#19975 (landed `e8f163fc3a`) it refuses both
spellings with `READ_SCOPE_COMPILE_FAILED` / 500. Its matrix cells and
`read-scope-eq-array-refusal.test.ts` are green in this PR's package
run. Not touched. |
| `matchesFilterCondition` (formula) | **already compliant**, per the
table in PR objectstack-ai#19994 (400 for the implicit list, `$eq` and `$not`). Not
re-measured here. |
| `applyFilterCondition` (driver-sql) | **already compliant**, per the
table in the objectstack-ai#19882 changeset. Not re-measured here. |
| `buildWhereSQL` (driver-turso RemoteTransport) | **already
compliant**, per the table in PR objectstack-ai#19994. Not re-measured here. |
| `checkCondition` (driver-memory) | **already compliant**: 400 at every
depth, per the table in the objectstack-ai#19882 changeset. Not re-measured here. |
| `translateFieldOperators` (driver-mongodb) | **out of scope.** The
driver answers with MongoDB array equality. A platform door reaches it
only through the shared face, which refuses the list (the declared scope
of objectstack-ai#19882). |
| `applyHaving` / `matchesHaving` / `checkCondition` (objectql HAVING) |
**out of scope.** This is a caller-authored filter over aggregated rows,
a different door from this card's. Its answers are recorded in the
objectstack-ai#19886 stage-2a report. |

## Tests and evidence (head `896e1e70f3`)

- **New `src/__tests__/where-equality-slot-list-refusal.test.ts`, 59
tests.** Every refusal asserts `code` + `status`.
- The `$eq` list at 9 positions: every depth, the empty list, and beside
another operator in either key order.
- The implicit list at 7 positions, including the empty list and a
nested relation.
- Byte identity (4): the object spelling equals the FilterArray
spelling, which equals the face's own message.
  - List before member (4).
- 12 neighbouring shapes that must compile as before: a scalar, a
`Date`, the null predicate, the `$in` remedy, the `$in: []` / `$nin: []`
constants, a nested scalar, a `$field` reference, `$between`, and `$ne`
(not judged).
- Four faces over a real sql.js engine (native execute, echo, ObjectQL
engine path, draft preview) × 4 spellings, plus a control. Each is
refused before any statement runs and before any `engine.aggregate`
call.
- A stored dataset through the service doors (6): the dashboard door and
the draft preview, for a scope filter and a measure filter; the
registered cube on the ObjectQL door; and a control.
- **`comparand-door-single-source.test.ts`:** the `array` row's
`whereEq` cell is re-judged from `accept` to `INVALID_FILTER/400`, with
a note. It had pinned `qty = 'al'` with `'be'` dropped.
- **Two existing pins re-judged, not rewritten by rote:**
- `filter-normalizer-not-null-safe.test.ts`: the bare `[]` is now
refused, and `$in: []` keeps its FALSE constant.
- `filter-normalizer-undefined-comparand.test.ts`: the `{ d: [1,
undefined] }` row leaves the undefined table, and the `{ d: [1, null] }`
row leaves the null control group. Each carries a note: its enclosing
shape is now refused whole.
- **Package run.** `pnpm --filter @objectstack/service-analytics test`:
117 files and 2541 tests passed (base: 116 files, 2484 tests).
`typecheck` exited 0, and `tsc --listFiles` includes all four touched
test files.
- **Ablations.** Each was run from the committed fix through
`scripts/ablation-replace.mjs`, with the red/green count predicted
before running. The tests import the source by relative path, so no
build is on the path. Each restore was proven by the blob hash matching
HEAD and by an empty `git diff HEAD`.
- **A: the `in` reading put back.** The gate call in
`lowerAnalyticsWhere` was deleted and the old bare-array arm restored in
`fieldLeaves`. Predicted 41 red; measured **41 red / 161 green** over
the four touched test files:
    - 9 `$eq`, 7 implicit, 4 byte identity, 4 list-first;
- 12 faces: native, echo and engine × 4 spellings. The preview cells
stayed green, because the preview runs the gate itself;
    - 3 stored, 1 matrix `array` where row, 1 bare `[]`.
- Sample failures: `native execute: expected a refusal, got rows:
expected [ 'd1', 'd2' ] to be undefined` and `expected 'accept' to be
'INVALID_FILTER/400'`.
- **B: the preview's gate call deleted.** Predicted 6; measured **6 red
/ 53 green**: the four preview face cells and the two stored-dataset
preview-door cells. Sample: `draft preview: expected a refusal, got
rows: expected [ 'd5' ] to be undefined`.
- **Gates.** `dispatch-gates` derived 60 at `896e1e70f3`. The run also
covered the dispatch-time list's `check:dispatcher-error-vocabulary`,
for 61 in total. 59 exited 0.
- **NOT MEASURED (2):** `check:dual-build-cjs-loads` and
`check:type-check-debt` exited 3 (PREREQUISITE NOT MET). They need the
whole workspace built, which CI does.
- `check:lean-entry-closure` exited 3 until objectql's closure was
built, then 0.
- `--ran` reconciliation: 60 derived, 58 run, 2 NOT-MEASURED (derived
from the recorded exit 3), 0 unrun.
- Among the passes: `check-adr-0087-registration --base origin/main` (1
declared-breaking changeset, `not-required (already-registered)`),
`check-changeset-no-major`, `check:changeset-gate-self-tests`,
`check:nul-bytes`, and `check-issue-citations` in its board-probing mode
(19 citations, all of which resolve).
- **Lint, narrowed to the change.** `eslint --no-inline-config --format
json` over the six touched TypeScript files: 6 files, 0 errors, 0
warnings. `eslint --print-config` resolves a config for each of them.
`eslint.config.mjs` never enables type-aware linting (its note near line
327), so this diff cannot change the verdict on any untouched file.
- **Dependents.** `pnpm --filter '...@objectstack/service-analytics'`
names 19 downstream packages. The AST scan above found no filter
carrying the shape in their sources or tests. Their suites were not run
here; CI's affected set runs them.

## Deviations from the dispatch, stated

1. **File surface.** The claim declared `filter-normalizer.ts`, the
matrix's `where*` cells, new test files and the changeset. This PR also
touches three more files:
- `src/preview-evaluator.ts`: one import, one call and comments. This is
a bounded in-place fix, and all four conditions hold: it is the same
defect class; it is a mechanical call of the same gate, whose shape
ruling 乙 pins; no open PR touches this package, per the claim's own
reading; and it is the same gate family, with no new verification
surface. The claim's file surface needs this path added.
- `filter-normalizer-not-null-safe.test.ts` and
`filter-normalizer-undefined-comparand.test.ts`: three pins asserted the
reading this ruling removes. They are re-judged, with notes (above).
2. **The changeset's ADR-0087 disposition** is `not-required
(already-registered filter-equality-array-comparand-refused)`, not the
dispatched `not-required (no-migration-prescription)`. A stored dataset,
widget or measure filter can carry this shape, because the authoring
schema admits it (measured). So the changeset carries a FROM → TO table.
The gate refuses `no-migration-prescription` on a body that carries one,
and that disposition would also claim that no author has to rewrite
anything. The transition itself has been on the ledger since objectstack-ai#19757, and
that entry's surface and prescription cover this door verbatim. PR
objectstack-ai#19374 used the same disposition for the same situation. The gate
accepts it.

## Acceptance notes

- **A registry sentence this PR makes false.** The registered entry
`filter-equality-array-comparand-refused`
(`packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts`,
and its copy in `registry.ts`) says that the analytics normalizer's
OBJECT form "still reads as membership". That is no longer true. Editing
it is a `packages/spec` change, outside this dispatch, so the carrier is
the seat's call.
- **`$ne` with a list** is not judged (ruling A of objectstack-ai#19886). Measured at
`896e1e70f3`: `{ stage: { $ne: ['won', 'lost'] } }` compiles to `stage
IS NULL OR stage != 'won'`, so `'lost'` rows are served. Reported to the
seat.
- **The shared face's other arms.** The object-form door still runs none
of them: the null list member, the null ordering comparand, the null or
blank `$between` bound, and the scalar `$in`. The FilterArray spelling
of each is refused on the same door. This package's own pins hold
several of the object-form answers. Reported, not addressed.
- **The authoring door.** It still admits the list: `DatasetSchema` with
`filter: { stage: ['won', 'lost'] }` parses. The objectstack-ai#19757 changeset
declared this. Reported.
- **The draft preview does not lower a FilterArray `where`.** It
answered no row for `['stage', '=', 'won']` in the probe. Reachability
through the dataset door is not established, so this is an observation
only.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…in the comparand face's own words (objectstack-ai#19889) (objectstack-ai#20047)

Fixes objectstack-ai#19889
Clause-②: no

This carries out ruling **5805248669** (letter **A**): the schema door
refuses an array in the equality slot, with the compile face's own
words. `Clause-②: no` is the claim's line, copied as it stands. The
changeset carries `Clause-②: no (narrowing)` because AGENTS.md makes a
narrowing BREAKING and `check:adr-0087-registration` reads the arm
there. Both lines give the same value.

Session `session_019c3Hi6ZMU1p6m6aA6Bz45d`, branch
`claude/issue-19889-filter-schema-door-array-equality`, base
`a0920b42dc`. Every reading below was taken at head `46e1c81727` unless
it says otherwise.

## 1. Reproduction (before the change, on `origin/main` `a0920b42dc`)

| input | answer |
|:--|:--|
| `DatasetSchema.safeParse`, control `filter: { stage: 'won' }` |
`success: true` |
| `DatasetSchema.safeParse`, `filter: { stage: ['won','lost'] }` |
**`success: true`** |
| `DatasetSchema.safeParse`, measure `filter: { stage: { $eq:
['won','lost'] } }` | **`success: true`** |
| `FilterConditionSchema.safeParse` on both shapes;
`FieldOperatorsSchema.safeParse({ $eq: [...] })` | **`success: true`**
(all three) |
| `assertListComparandShapes({ stage: ['won','lost'] })` (the compile
face) | `INVALID_FILTER` / 400 |
| `assertListComparandShapes({ stage: { $eq: [...] } })` |
`INVALID_FILTER` / 400 |

The face's refusal text read, verbatim: `The implicit-equality comparand
on field "stage" requires a single comparable value, but received an
array (["won","lost"]) at where.stage. For "one of these values" use
{"$in": […]} (authoring: in); for "the stored list holds a value" on a
multi-value field, {"$contains": "…"} (authoring: contains), an $or of
those for any-of. The filter was NOT applied, and an unapplied filter
would have returned the UNFILTERED result set.`

⚠️ **The ruling calls it "one constant", but no remedy constant
existed.** On `main` the face built the text inline in
`arrayEqualityComparandError`. This PR extracts it rather than copying
it (§2).

## 2. The change

- **`packages/spec/src/data/filter-comparand-refusal-text.ts` (new,
internal).** It holds the ONE remedy constant
`ARRAY_EQUALITY_COMPARAND_REMEDY` and the one message builder
`arrayEqualityComparandMessage`, plus `shapePreview`, the `$contains`
operator and the `in` spelling row. Those three moved out of the face so
both doors render the value and the prescription from one source.
- Both doors import this module. The face cannot import `filter.zod.ts`,
because of the import cycle the face already documents.
- ⛔ **It is deliberately NOT in the `data` barrel**, following the
`currency-fraction-digits.ts` precedent. Exporting the text would widen
`@objectstack/spec/data` for no reader, which a `Clause-②: no` card must
not do. `check:api-surface` stays green.
- **The face (`filter-comparand-shape.ts`)** now calls the builder. Its
message is byte-for-byte what it was: every existing face pin, and the
analytics / mongodb / memory suites, pass unchanged.
- **`FilterConditionSchema`** refuses both arms inside its existing
refinement walk:
  - `{ field: [...] }` at the issue path `field`;
  - `{ field: { $eq: [...] } }` at `field.$eq`;
- the empty array too, and inside `$and` / `$or` / `$not` members
through their own re-parse.
- **`FieldOperatorsSchema.$eq`** refuses an array comparand. So does its
documentation copy **`EqualityOperatorSchema.$eq`**, through one shared
`equalityComparandSchema` factory, following the file's own pairing rule
(`orderingComparandSchema`). `NormalizedFilterSchema` validates against
it and refuses too.
- **Reach = the face's, no wider.** A field spec with no `$` key (a
nested-relation or deep-equality condition) is not judged at this door,
because the face never descends one. A pin holds both doors to the same
answer on that shape (§4, control rows).
- **Not dropped.** A refused document fails its parse and nothing is
stripped. The pin `CONTROL — the same documents with $in publish, and
keep their filter` proves that accepted means kept.

### "The parse-door message equals the compile-face message": what
equality holds

⚠️ Stated rather than settled silently. A zod refinement cannot see its
absolute location: in zod 4.6.1 the refinement context is the parse
payload, with no path. So the schema door does not print the face's `at
where.FIELD` clause. Its issue carries the location as its `path`
instead (`filter.stage`, `measures.0.filter.stage.$eq`).

Pin §3 asserts **character equality after removing exactly that one
clause**, and asserts the clause is present exactly once, so the removal
is not vacuous. That holds on 8 shapes: implicit and `$eq`, empty, and
under `$and` / `$or` / `$not`.

Printing `at where.stage` at save time would name a location that does
not exist in the saved document, and the wrong one for a combinator
member. The `$eq` operator slot cannot see its field either (the key
belongs to the enclosing record). Its pin asserts equality after
removing both the ` on field "stage"` clause and the location clause. If
the seat reads ruling item 4 as strict byte-equality, the alternative is
to print a fixed `where.` location; I recommend against it for the
reason above.

## 3. Carrier census (ruling item 3)

**Schemas that embed `FilterConditionSchema` directly.** Each one
refuses now; the right-hand column is measured at head:

| schema · key | refuses? |
|:--|:--|
| `DatasetSchema.filter`, `DatasetMeasureSchema.filter` | yes (pinned) |
| `DashboardWidgetSchema.filter`, `GlobalFilterOptionsFromSchema.filter`
| yes (widget pinned) |
| `ReportSchema.runtimeFilter`, `JoinedReportBlockSchema.runtimeFilter`
| yes (report pinned) |
| `FieldSchema.relatedListFilter`,
`FieldSchema.summaryOperations.filter` | yes, as a
`FilterConditionSchema` carrier |
| `BlueprintSummaryOperationsSchema.filter` | yes, as a carrier |
| `AnalyticsQuerySchema.where`, and through it
`AnalyticsQueryRequestSchema.where` | yes (measured) |
| `DatasetSelectionSchema.runtimeFilter` | yes (measured) |
| `QuerySchema.where` / `.having`, `AggregationNodeSchema.filter`,
`QueryFilterSchema.where` | yes (`where` / `having` measured) |
| `EngineAggregateOptionsSchema.having` | yes (measured) |
| `Engine{Query,Update,Delete,Aggregate,Count}OptionsSchema.where`,
`DataEngineVectorFindRequestSchema.where` | **no**: the union's first
arm is an open record, so it parses (measured). The face refuses it at
execution. |

**Schemas that embed `FieldOperatorsSchema`:** `NormalizedFilterSchema`
(through its field-condition record). `EqualityOperatorSchema` is the
documentation copy.

**Where the refusal is met.** Every one of these was measured at head:

| door | answer |
|:--|:--|
| `defineStack` (strict) | `STACK_SCHEMA_INVALID`,
`datasets.0.filter.stage: …` |
| `os validate` | parses `ObjectStackDefinitionSchema`, read at source |
| metadata-protocol `saveMetaItem`, dataset | **`INVALID_METADATA` /
422, issue path `filter.status`**; the `$in` control saved. This was a
throwaway probe on the `protocol.dashboard-dataset-publish-gate` harness
and was deleted, tree clean. |
| REST `datasetSelectionRefusal` | **`VALIDATION_FAILED` / 400**,
`selection.runtimeFilter.stage:` followed by the sentence; previously
the analytics compiler's `INVALID_FILTER` / 400 |
| runtime analytics body (`handleAnalyticsRequest`) |
**`VALIDATION_FAILED`**, `where.stage: …`; the bridge maps it to 400,
pinned in `dispatcher-validation-error.real.test.ts` |

The read path does not re-validate stored rows, so a stored document
keeps loading and its next save is refused. ⛔ Nothing is rewritten or
dropped.

**Stored and shipped instances carrying the shape: 0.**

- **Text scan.** A brace-matched scan of every `filter` / `where` /
`runtimeFilter` / `having` / `relatedListFilter` literal in
`packages/**`, `examples/**`, `apps/**`, `content/docs/**` and
`skills/**` read 7785 files and 4709 carrier literals. It found 20 field
entries whose value opens an array. 16 are test fixtures; they are its
positive control, including the mongodb refusal pin rows. The other 4
are not `FilterCondition` carriers: `client/realtime-api.ts`
`eventTypes` and `core/PHASE2_IMPLEMENTATION.md` plugin-permission
`filter`. There is no YAML-authored carrier block.
- **`$eq` grep.** A grep for `$eq` followed by an array found 24 lines:
prose, MongoDB aggregation `$eq` expressions, and one `door-refusal`
conformance row.
- **Runtime census.**
- `app-crm`, `app-todo` and `app-multi-package` load through strict
`defineStack` / `composeStacks`: 0 hits.
- `app-showcase`'s config imports connector plugins with no `dist/`
here. Its barrels were parsed with the registered type schemas instead:
22 objects, 4 datasets, 3 dashboards, 4 reports. That gave 0 hits and 0
other failures.
- A planted `{ stage: ['won','lost'] }` dataset fired once at
`filter.stage`.
- Deployed datasets, dashboards and reports are **NOT MEASURED**.

## 4. Pins, each proven able to fail

`filter-equality-array-schema-door.test.ts` has 46 tests: §1
FilterConditionSchema refusals, §2 operator slot, §3 same words, §4
controls at both doors, §5 carriers. Each ablation used
`scripts/ablation-replace.mjs`: the anchor hit exactly once, and each
leg printed its blob change. After every leg, restore was proven by blob
== HEAD `be90963c90` and an empty `git diff HEAD`. The spec tests import
from `src`, so no `dist/` leg was needed.

| leg | mutation | result |
|:--|:--|:--|
| A | implicit arm off | **14 red** / 32 green (implicit refusals,
same-words rows, `DatasetSchema filter.stage`, widget) |
| B | `$eq` arm off | **9 red** (`$eq` refusals, same-words rows,
`measures.0.filter.stage.$eq`, report) |
| C | operator-slot refinement off | **4 red** (`FieldOperatorsSchema`,
`EqualityOperatorSchema`, `NormalizedFilter`, slot same-words) |
| D | schema door prints a location of its own | **5 red** (the §3
equality rows) |
| E | reach widened into nested-relation specs | **1 red** (the
nested-relation control) |
| F | operator slot also refuses `null` | **1 red** (the scalar/null
control) |
| control | restored tree | 46 / 46 green; `git status` clean at
`46e1c81727` |

Re-judged, not dropped: `filter-comparand-shape.test.ts` pinned
`FieldOperatorsSchema`'s array-accepting set as
`['$between','$eq','$in','$ne','$nin']`, "`$eq` … only because both are
`z.any()`". `$eq` has left that set, so the expectation now reads
`['$between','$in','$ne','$nin']` and the comment says why.

## 5. ADR-0087

- **New semantic entry
`filter-equality-array-comparand-refused-at-save`.** The changeset
marker is `registered filter-equality-array-comparand-refused-at-save`.
The gate read it back as `[BREAKING+bang+clause-②-narrowing] registered
… (new here …)`. Every factual claim in it was measured at head (§3).
- **Corrected the unreleased entry
`filter-equality-array-comparand-refused`.** At `:46-49` it said the
analytics normalizer's OBJECT form "still reads as membership" because
it "does not route through the shared face". That has been false since
objectstack-ai#20008. It now states which doors refuse the shape at this release, and
with what:
- the face (`parseFilterAST`, the engine seam): `INVALID_FILTER` / 400;
- the analytics `where` door, in both spellings: the same, and that door
alone also refuses a nested-relation list;
  - the schema door on save: the same sentence, as a parse issue.
The object form's four old readings are named, and its acceptance
criteria point to the new sibling entry.
- `gen:migration-registry` regenerated `registry.ts`, and
`check:migration-registry` is green. `gen:upgrade-guide` and
`gen:spec-changes` were run and wrote **zero diff**. Both project steps
up to `PROTOCOL_MAJOR` (17, `PROTOCOL_VERSION` `17.0.0`), so no step-18
entry appears in them yet, this one included. `check:upgrade-guide` and
`check:spec-changes` are green.

## 6. Changesets, and two DELIBERATE CORRECTIONS for the seat to confirm

- New: `.changeset/19889-filter-schema-door-array-equality.md`. It
declares `@objectstack/spec: minor`, the **BREAKING** banner,
`fix(spec)!:`, a FROM → TO table and the measured census. The grade
follows AGENTS.md: a `(narrowing)` is BREAKING, and the launch-window
convention ships it `minor`. `check-changeset-no-major` is green.
- ⚠️ **DELIBERATE CORRECTION 1**,
`.changeset/19757-equality-slot-array-refused.md` (pending). Its bullet
said: "The schema doors are not touched. `FilterConditionSchema` still
parses `{ field: [...] }` … A document carrying the shape therefore
still publishes". That is false in the release both ship in. It now says
this change did not touch the schema doors, and that a separate change
in the same release does.
- ⚠️ **DELIBERATE CORRECTION 2**,
`.changeset/19888-analytics-implicit-array.md` (pending, services lane).
Its sentence said: "The authoring schema still admits the shape, so such
a document still publishes, and it is refused when it is charted". That
is false for the same reason. It now says the schema refuses the shape
on save, except for a list inside a nested relation, which only the
analytics door judges.
- `check-empty-changeset` is therefore **red by design** ("DELIBERATE
CORRECTION … say so on the PR … get it confirmed"). Neither file is
restored from base, because that would republish a false sentence.

## 7. Tests (at `46e1c81727`)

| package | result |
|:--|:--|
| `@objectstack/spec` (full) | 564 files · **16273 passed** · 2 todo |
| `@objectstack/spec` typecheck (`tsc`, scripts, test layer) | green;
`check:test-typecheck` OK |
| `@objectstack/service-analytics` | 117 files · 2541 passed |
| `@objectstack/objectql` | 312 files · 5243 passed |
| `@objectstack/rest` | 195 files · 3273 passed · 1 skipped |
| `@objectstack/metadata-protocol` | 188 files (3 skipped) · 2676 passed
· 19 skipped |
| `@objectstack/runtime` | 278 files · 3962 passed · 1 skipped |
| `@objectstack/lint` | 108 files · 4138 passed |
| `@objectstack/cli`, unit layer | 224 files · 3158 passed |

Notes on the table:

- **`cli`.** The first run's two `published-subpath-*` pins refused on
their prerequisite, "packages/cli is not built". After `cli` was built
they ran: 2 files, 29 passed. The integration layer is declared to CI,
since the diff touches no spawn entry or integration file.
- **Build.** The build ran through turbo for the closures of the
packages above: 56 + 57 tasks, all successful.
- **Lint, narrowed.** The 8 touched `.ts` files were linted with `eslint
--no-inline-config --format json`: 8 files counted from the JSON, **0
errors / 0 warnings**, and `isPathIgnored` is false for each. The config
enables no type-aware linting (no `parserOptions.project`, per its own
comment at `eslint.config.mjs:328`), so this diff cannot move a verdict
on an untouched file. The repo-wide `pnpm lint` belongs to CI.

## 8. Gates (at `46e1c81727`)

`dispatch-gates --commands --repo objectstack-ai/objectstack` derived
**87** commands. The `--ran` reconciliation, with an exit code per line,
reads: **87 derived, 85 run, 2 NOT-MEASURED, 0 UNRUN**.

- **84 exited 0.** These include `check:api-surface`,
`check:authorable-surface`, `check:docs`, `check:migration-registry`,
`check:spec-changes`, `check:upgrade-guide`, `check:doc-authoring`
("16302 customer-facing string(s) … clean"; sibling prose ids "no
growth"), `check:nul-bytes` ("scanned 9435 … no raw ASCII control
bytes"), `check:engine-double-contract` and
`check:cross-package-test-inputs`.
- **1 exited 1:** `check-empty-changeset`. This is the
deliberate-correction refusal in §6.
- **NOT MEASURED: `check:dual-build-cjs-loads` and
`check:type-check-debt`.** Reason: both exit 3, `PREREQUISITE NOT MET`,
because they read the whole workspace's `dist/`. PR objectstack-ai#19882 recorded the
same two.
- `@objectstack/spec check:generated` reports all 15 artifacts current
after the build.

## Acceptance notes

- **Residual (finding, not fixed here).** A list inside a
nested-relation condition on a dataset or measure filter, `{ account: {
region: ['a'] } }`, still passes `DatasetSchema`. Since objectstack-ai#20008 the
analytics `where` door refuses it (`INVALID_FILTER` / 400, measured).
Refusing it at the shared schema door would refuse a deep-equality
comparand the shared face and the engine accept. That is a different
decision, and it is left to the seat.
- **Error code at two request doors.** An analytics request carrying the
shape now answers `VALIDATION_FAILED` / 400 at the request door (REST
dataset selection, runtime analytics body) instead of `INVALID_FILTER` /
400 at the compiler. Both are class-1 400s carrying the same sentence.
No test pinned the old code for this input: the `rest`, `runtime` and
`service-analytics` suites are green.
- The published JSON Schema cannot state the check (`z.toJSONSchema()`
has no custom-check projection). Three sites are declared in
`dropped-refinements.baseline.json`: `data/FieldOperators` `$eq`,
`data/EqualityOperator` `$eq` and `data/NormalizedFilter`
`…valueType.$eq`, and its `measured` counts moved 206→207 schemas and
571→574 sites.
- `$ne` carrying a list is untouched. objectstack-ai#19886 and the face's `it.todo`
carry it. objectstack-ai#19886 remains open.

---
_Generated by [Claude
Code](https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…t the CEL lowering and $ne arrays at the mongodb face (objectstack-ai#19947)

Refs objectstack-ai#19886

Clause-②: no (narrowing)

<sub>Rewritten short by the `domain:spec#5` seat (2026-09-24T07:02Z;
round 4 after record `5808690296`). Stage 2c of objectstack-ai#19886. Seat rulings:
`5806391955`, `5806943154`, `5807743099`, `5808108434`. Dev reports on
the card: `5806886759`, `5807587023`, `5808082032`, `5808419047`,
`5809358163`.</sub>

Some row-level or sharing CEL predicates no longer lower: those
comparing a field with `==` / `!=` against a list, whether a list
literal or a `current_user` membership array. Every consumer fails
closed:
- the RLS compiler drops the policy;
- the sharing bootstrap skips the rule;
- the authoring lint reports the literal forms.

driver-mongodb's `translateFilter` refuses `$ne` with an array comparand
(`INVALID_FILTER` / 400).

## What changed

- `packages/formula/src/cel-to-filter.ts` adds the refusal, covering
list literals and resolved arrays, both orientations, and forms under
`!`.
- `packages/drivers/driver-mongodb/src/mongodb-filter.ts` adds the `$ne`
array refusal at any depth.
- `packages/lint/src/validate-rls-predicate-enforceability.ts`: the
reference pass probes each kernel `current_user` key with its runtime
type (scalar keys as scalars, membership keys as arrays).
- Brought in line with the merged stage 2a (PR objectstack-ai#19946): a CEL-authored
`check` carrying the shape is now dropped at compile (403 when no other
policy applies), so `matchesFilterCondition`'s 400 remains for a filter
passed to it directly. Its ADR-0087 entry, its plugin-security pin and
the docblock spans made false by this are corrected by cuts. So are
three spans of its PENDING changeset: a objectstack-ai#17712 DELIBERATE CORRECTION, so
`Check Changeset` is red by design and landing waits on the maintainer's
confirmation (see the gate comment).
- Changeset: BREAKING, at `minor` for formula, driver-mongodb,
plugin-security, plugin-sharing and lint, and at `patch` for spec, which
carries the ADR-0087 entry `cel-predicate-list-comparand-refused`.

## Compile faces

This PR changes one compile face, driver-mongodb `translateFilter`
(`$ne` with an array → 400). The equality-slot array is left to the
shared face (PR objectstack-ai#19882). For the other faces, see PR objectstack-ai#19946's table.

## Verification (the dev's, at `3bf405b501`; round 4 re-measured at the
merged head)

- The suites of every touched package are green, and CI is green.
- End to end on driver-mongodb (mingo proxy; live `mongod` NOT MEASURED)
and driver-sql, every probe fails closed: reads return no rows, and a
`check` gets 403 with nothing stored. That includes `!(record.x ==
current_user.org_user_ids)`, which read every row on driver-mongodb
before.
- The controls are unchanged: `in`, `not in`, scalar `==` / `!=`, `null`
and `{ $field }`.
- Ablating the lowering refusal turns the refusal pins red, including
the re-judged 2a pin (which then receives 2a's 400).

## Not in this PR

- objectstack-ai#19951: the lint is silent on `==` / `!=` against a membership key.
- objectstack-ai#19949: driver-mongodb `{ $field }`.
- Stage 2b: the shared face, after PR objectstack-ai#19882.
- Stage 2d.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

1 participant