Skip to content

fix(formula): refuse an array comparand under $ne and in the equality slot (write-check bypass) - #19946

Merged
os-justin merged 7 commits into
mainfrom
claude/issue-19886-formula-face-refusal
Sep 24, 2026
Merged

os-justin merged 7 commits into
mainfrom
claude/issue-19886-formula-face-refusal

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Refs #19886

Clause-②: no (narrowing)

Rewritten short by the domain:spec#5 seat (2026-09-24T03:43Z; round 2 after record 5806589396). Stage 2a of ruling 5805254639 (A) under seat ruling 5805921577; stage 2b closes the card. The dev reports are on #19886 (5805865119 stage 1, 5806337850 stage 2a, 5807134185 round 2).

matchesFilterCondition (@objectstack/formula, compile face 5) now refuses an array comparand under $ne and in the equality slot (a bare-array field spec, or $eq). It throws INVALID_FILTER / 400 before any record is judged, at any depth under $and / $or / $not. This face is the one plugin-security runs a row-level check on. Stage 1 measured checks written record.f != [list], != current_user.<membership array> and !(record.f == [list]) admitting the forbidden writes and storing them. $in / $nin, scalars, null, Date and { $field } references are unchanged.

Compile faces, both shapes (measured at this head; locations re-verified with the compile-surfaces grep)

face $ne: [..] { f: [..] } / $eq: [..] $not { f: [..] }
1 driver-sql applyFilterCondition (sql-driver.ts:15311; sqlite-wasm and turso local inherit) 400 INVALID_FILTER 400 / 400 400
2 turso RemoteTransport.buildWhereSQL (remote-transport.ts:2632) 400 INVALID_FILTER 400 / 400 400
3 service-analytics compileScopedFilterToSql (read-scope-sql.ts:503) answers: emits a NULL-or-not-equal comparison and binds the array as its one parameter 500 READ_SCOPE_COMPILE_FAILED / answers an equality comparison with the array bound 500 READ_SCOPE_COMPILE_FAILED
4 service-analytics lowerAnalyticsWhere (filter-normalizer.ts:1508) passes through unchanged passes through / passes through passes through
5 formula matchesFilterCondition (matches-filter.ts:212), this PR 400 INVALID_FILTER (was: true for every record) 400 / 400 (was: false) 400 (was: true for every record)
half: objectql having matchesHaving (having-filter.ts:292) answers true for every row answers false / false answers true for every row
driver-memory: live find (mingo) and memory-matcher.ts match 400 INVALID_FILTER 400 / 400 400
driver-mongodb translateFilter (mongodb-filter.ts:568) passes $ne: [..] through; mingo proxy selects every scalar row passes through lowers to $nor; mingo proxy selects every row

A live mongod was NOT MEASURED; mingo is named as the proxy. Faces 3, 4, the half face and driver-mongodb are outside 2a's surface: they are reported on the card, not changed here.

Verification (the dev's, at 58ce4f1b16)

  • formula, plugin-security and lint suites green; typecheck green for formula and plugin-security.
  • Ablating the refusal turns the refusal pins and both plugin-security write-check pins red; the neighbour pins stay green.
  • A policy carrying either shape now refuses every write it governs, allowed values included, until it is rewritten with in (fail-closed). The positive record.f == [list] keeps its verdict (every write refused); its envelope moves from 403 to 400.
  • BREAKING: @objectstack/formula and @objectstack/plugin-security at minor, @objectstack/spec at patch for the ADR-0087 entry rls-predicate-array-comparand-refused (step 18).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1

… slot

matchesFilterCondition compares strictly, and no stored scalar equals an
array, so `$ne: [...]` matched every record and a negated equality
(`$not` around `{ f: [...] }`) did too. On a row-level write check that
admitted every write the check was written to refuse. Both shapes, and
the positive equality spelling with them, are now refused before any
record is judged with INVALID_FILTER / 400, the envelope driver-sql and
driver-memory already give the same shape. The message withholds the
field and the comparand.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
Both asserted `{ f: [...] }` answers false. That answer is gone: the
shape is refused. matches-filter.test.ts now asserts the refusal
envelope; the empty-field-constraint suite's "still returns false"
group drops the case and points at the new suite.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
Two spellings of "not one of these values" written wrongly in a
row-level check, `!=` against a list and a negated `==` against one,
each refused on insert with INVALID_FILTER / 400 through the real
plugin and engine, with nothing stored.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
… the equality slot

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

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 4 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 — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json aeaaa4429208ddd9dec06c5fcca89823f6a20bf4 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 58ce4f1b1659660c25099a71390f1654a1637379

Reviewed and posted 2026-09-24T02:50Z by the at-tier review subagent the domain:spec#5 seat spawned, in a detached worktree at this head, since removed (read: the diff, body, 5 commits and 45 check runs; #19886 with all 9 comments; #19757 with ruling 乙; #19942; AGENTS.md; contract-review.md; compile-surfaces.md; matches-filter.ts, cel-to-filter.ts, security-plugin.ts 2900–3215, engine.ts 11453–11461, explain-engine.ts 840–860 and 1530 · ran: the PR's 79 pins; a 33-shape evaluator probe and 10 CEL lowerings against the built dist; 7 check spellings through the real SecurityPlugin + ObjectQL on driver-sql; the compile-face table, every row; an ablation of both throw sites with the pins, restore proved by blob hash · NOT MEASURED: live mongod, PG, MySQL, the face-3 bind TypeError, and the real gate on driver-sqlite-wasm / driver-memory — the evaluator is in-process and driver-independent by construction).

① Derived judgments

Closure — reachable paths. matchesFilterCondition has three production callers, all in plugin-security, none inside a catch: security-plugin.ts:2981 satisfiesCheck over checkParts (the caller's check and the ADR-0090 D10 delegator check, AND-ed; insert through the engine seam engine.ts:11461, awaited bare; single-id update at :3020), :3204 tenantParts (platform-derived { organization_id } / $in / deny sentinel — never carries either shape), and explain-engine.ts:854 (awaited bare at :1530, only with a recordId). A throw from the evaluator is therefore the operation's failure; no caller turns it into a pass. Measured through the real gate on driver-sql at this head: record.status != ['closed', 'archived'] refuses the forbidden AND the allowed insert with INVALID_FILTER/400 and stores nothing; the scalar != 'closed' and the remedy !(record.status in [...]) refuse 403 and admit the allowed row, unchanged. Every ruled spelling refuses at the face: $ne, bare, $eq, $not{bare}, $not{$ne}, empty arrays, $ne beside $in or $gt, a dotted path, four combinators deep. CEL lowering re-read: != emits { f: { $ne: v } }, == a bare { f: v } ($eq for a field ref), and current_user.org_user_ids is substituted as a literal array — so all three stage-1 spellings are authored-shape at the face and are closed.

Closure — escapes still open, measured through the same real gate (forbidden insert ADMITTED and stored, allowed insert admitted, no warning logged): (a) record.status != record.tags and !(record.status == record.tags) — a { $field } reference resolving to an array column (tags a json field here; a multiple lookup behaves the same): the evaluator answers $ne true / $eq false for every record, the refused shape one comparand-kind over. The docblock and changeset declare this untouched by design, so no shipped sentence is false — but it is the same bypass. (b) !(record.status in [['closed', 'archived']]) — a nested-array member under the list operator. (c) record.status > ['a'] — the ordering coercion the dev flagged. None is in ruling A's letter (an authored array under $ne / in the equality slot); all three are the class. Three JSON-only shapes pre-date 2a and stay fail-open under $not ({ $not: { $ne: [..] } } with no field, { $not: { $or: [] } }, { $not: { account: { region: [..] } } }) — unreachable from CEL, noted only.

Regression. Every shipped check/using string enumerated at this head — default-permission-sets.ts (54), examples/app-showcase (3), content/docs (19), skills/objectstack-data (3), the hotcrm fixture (4), the qa fixtures — is a scalar ==, != null, or in; the platform ownership/tenant policies derive theirs from constants; nothing shipped carries a refused shape, so nothing that loads or answers today breaks. The 79 shipped pins are green at the head. The retired answer ({ f: [..] } → false) had two pins; both are re-judged in the diff.

The message. A constant string: no field, operator or value from the filter is echoed (pinned with a secret field and two secret ids). Its "$in" / "$nin" are FieldOperatorsSchema spellings (filter.zod.ts:1438–1439). "No stored value ever equals an array" and "$ne matched EVERY record": looseEq is === outside Dates, so both hold for every post-image a door can produce (the one counter-case, a record holding the identical array reference, is reachable from nowhere). The docblock's "measured through the real plugin-security on three drivers" is the dev's claim; re-measured on driver-sql here.

Pins and ablation (re-measured). Removing both throw sites (blob d6efb91bac0b; the rebuilt dist answers $ne [..] true) turns 27 formula pins red (26 in the refusal describe, 1 in matches-filter.test.ts) and both plugin-security pins red with "expected the insert to be refused, but it was admitted"; the six neighbour pins stay green; restore proved blob == HEAD and the rebuilt dist throws again. What the pins leave uncovered: the escapes (a)–(c) above; the update middleware, the delegator leg and explain are dev-measured only (the delegator leg shares checkParts, so it is covered by reading).

Every shipping sentence. The body's compile-face table re-measured cell by cell at this head: faces 1, 2, 5 and driver-memory (live find + matcher) 400 on all four shapes; face 3 emits a NULL-or-not-equal / an equality comparison binding the array as the one parameter and 500 READ_SCOPE_COMPILE_FAILED on the two bare shapes; face 4 passes all four through; having answers true,true / false / false / true,true; mongodb translateFilter passes through and mingo 7.2.4 selects every scalar row for $ne and for the $nor lowering. Locations :15311 :2632 :503 :1508 :212 :292 :568 all hold; SqliteWasmDriver and TursoDriver do extends SqlDriver. Two nits: the mongodb equality cell "selects none" holds for scalar rows only — a row storing the exact array is selected; the "better-sqlite3 throws a TypeError at bind" clause is NOT MEASURED here (compile output only). The sentence "Faces 3, 4, the half face and driver-mongodb are outside 2a…" appears twice in a row. The changeset's "explain … refuses too" and "the positive == [list] … now 400" hold by reading (explain-engine.ts:1530 bare await; a bare array throws before evaluation). "No ADR-0087 entry: the changeset is a non-breaking patch" — see ②. Commits: 5, model-free trailer pair; the diff, body, changeset and commit messages were swept for every model-identifier spelling: 0 hits. Cross-lane (packages/formula = domain:engine) is declared on the claim; the 2a site beyond the ruling's named sites is listed for director ratification, which is not this review's to grant.

② Semver level

"@objectstack/formula": patch, Clause-②: no with no arm, body "non-breaking patch" — wrong in kind. matchesFilterCondition is a published accept set (packages/formula/src/index.ts:96; dist/index.d.ts:1113, addressable through the package entry), and the diff narrows it: { f: [..] } answered false and $ne: [..] answered true, both now throw. AGENTS.md Post-Task 3: the declaration takes (narrowing) and (narrowing) is BREAKING. The same face's previous refusal, #5240 { field: {} }, shipped fix(driver-sql,driver-memory,formula)!: under formula's Minor Changes; the sibling slot of this ruling family (#19882, ruling 乙) declared no (narrowing), BREAKING, minor, with the ADR-0087 entry registered; ten changesets in stock declare Clause-②: no (narrowing) + **BREAKING** at minor (17499, 18124, 18239, …); ruling A item 3 names an ADR-0087 semantic entry. The consumer fact the body itself states — a policy that admitted writes now 400s until rewritten — is exactly what the arm exists to declare, and the FROM → TO prescription ("What to change") is already written. Required: Clause-②: no (narrowing), minor under the launch-window convention with the **BREAKING** banner, and the one ADR-0087 disposition marker check:adr-0087-registration then demands (it prints the category set). Non-blocking: @objectstack/plugin-security's shipped behaviour moves too (admit → 400; the positive form 403 → 400) with no changeset naming it — the fixed group versions it, but its CHANGELOG will carry no sentence for the 400 an upgrading agent greps; add it to the same changeset.

③ Boundary flags

Blocking: ② — the changeset declaration (arm, level, banner, ADR-0087 marker), measured above; one changeset edit.

Non-blocking:

  • Not carried at all (measured admitted-and-stored, ① above): (a) != / !(==) against a { $field } reference whose column is multi-valued — outside 2b (the shape faces judge authored shapes) and outside 2c as scoped (「字面列表,或解析出来的成员数组」; a field ref is neither), though 2c's lowering already holds the field guard's schema and could refuse !=/== against a multiple/json column at authoring; (b) a nested-array member under in ($in, $nin), same faces; (c) ordering operators with an array — the seat wrote 「本席下一轮立卡」, no card number yet. Each needs a card or a fold into 2c before the p1 "every reachable path" scope can close.
  • Carried by 2b (seat 5806391955): the having half face, faces 3 and 4 (answer / 500 / pass-through, as re-measured).
  • Carried by 2c (claim 5806396564, branch claude/issue-19886-cel-mongodb-refusal): the CEL-lowering refusal, driver-mongodb's own face, the using read widening on mongodb. Live mongod stays NOT MEASURED everywhere.
  • Carried by security: RowLevelSecurityPolicySchema.check is published as "defaults to USING clause if not specified", but the write check runs only policies that declare check, so a USING-only policy never gates an INSERT #19942 (p1, pm:queue, domain:services): a USING-only policy never gates a write.
  • Deferred by the seat, no carrier: the lint text rls-predicate-unknown-user-variable (direction right, envelope 403 vs 400) — after 2c.
  • Dev open questions: Q1 (positive form 403 → 400) ruled A by the seat, consistent with 乙 and with the pin; Q2 ruled C → 2c. Both answered.
  • Pre-existing, outside 2a: a bulk update by where is never post-image checked (security-plugin.ts "log and skip"); its using goes to the driver, which refuses the shape.
  • Director ratification of the 2a site beyond ruling A's named sites is pending on the card (seat 5805921577 「列请董事席追认」).

CI at this head: 45 check runs, 34 names after de-duplication on the latest start; none in progress or queued; no failure. All seven required contexts success (Lint & Repo Gates, TypeScript Type Check, Test Core with 6 shards, Dogfood Regression Gate with 3 shards, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard). Skips, each with its reason: Auto Label and Check PR Size on the two edited re-runs (their if: excludes github.event.action == 'edited'; the seat's body rewrite triggered them, and the original runs succeeded); Packed-tarball smoke (opt-in, needs the needs:pack-smoke label); Console Pin Gate and Build Docs (needs.filter.outputs.console / docs false — the diff touches neither).

Implemented-by: claude/issue-19886-formula-face-refusal
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1 — at-tier review subagent spawned by the domain:spec#5 seat

VERDICT: FAIL

…semantic migration

Adds the step-18 semantic entry rls-predicate-array-comparand-refused for the
formula face's refusal of an array comparand under $ne and in the equality
slot, as the RLS check author meets it, and regenerates registry.ts with
gen:migration-registry. spec-changes.json and the upgrade guide read up to
date under check:generated.

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

Clause-② no (narrowing) with the BREAKING banner and the registered ADR-0087
disposition; formula and plugin-security graded minor under the launch-window
convention for accept-set narrowings, spec patch for the ledger entry.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ba90ddb578d5d221a1bc2a79518b5be6338b011a

Reviewed and posted 2026-09-24T04:04Z by the at-tier review subagent the domain:spec#5 seat spawned, round 2 after record 5806589396, in a detached worktree at this head, since removed (read: the diff (8 files), the body as rewritten by the seat, 7 commits, 46 check runs; #19886 with all 14 comments; #19757 with ruling 乙; #19942; AGENTS.md; contract-review.md; compile-surfaces.md; matches-filter.ts whole, cel-to-filter.ts 284–445, security-plugin.ts 2940–3225 and 6480–6540, explain-engine.ts 830–870 and 1470–1545, engine.ts 11395–11480, the two changeset gate headers, build-upgrade-guide.ts, the semantic entry and the registry diff · ran: the PR's 79 pins (77 formula, 2 plugin-security); a 30-shape evaluator probe and 18 CEL lowerings on the built formula dist; 9 check spellings, explain, a multi: true update and 10 read-side envelopes through the real SecurityPlugin + ObjectQL on driver-sql, the first spelling and the envelopes on driver-memory too; the compile-face table, every cell, faces 2/3/4/5, the half face, the memory matcher and driver-mongodb + mingo 7.2.4 on src via tsx, face 1 and the memory live find through the engine and driver doors; an ablation of both throw sites with the pins and the gate probe, restore proved by blob hash and a rebuilt dist · NOT MEASURED: live mongod, PG, MySQL, driver-sqlite-wasm through the gate (same in-process evaluator by construction), the delegator leg (by reading only: it shares checkParts), and the derived gate family, which is read from CI per the brief).

① Derived judgments

Closure — reachable paths, re-derived. matchesFilterCondition has three production callers, all in plugin-security and none inside a catch: security-plugin.ts:2981 satisfiesCheck over checkParts (the caller's check AND the ADR-0090 D10 delegator check; insert through the engine seam engine.ts:11461, a bare await; single-id update at :3020 in the middleware), :3204 tenantParts (platform-derived { organization_id } / $in / deny sentinel, never an authored shape), and explain-engine.ts:854 (bare await at :1530; the catches at :1485–1497 wrap computeRlsFilter only). Every RLS check/using is a CEL string; the lowering at this head (unchanged by 2a) emits != as { f: { $ne: v } }, == as a bare { f: v }, ! as $not, a list on either side the same way, and substitutes current_user.<membership key> as a literal array, so all three stage-1 spellings and both negations are authored-shape at the face. Measured through the real gate on driver-sql at this head: record.status != ['closed','archived'], !(record.status == [..]) and the positive record.status == ['open','pending'] each refuse the forbidden insert, the allowed insert and the update-by-id with INVALID_FILTER/400 and store nothing; the same first spelling refuses on driver-memory; explain with that using refuses 400 instead of answering visible: true; the scalar != 'closed' and the remedy !(record.status in [..]) refuse 403 and admit the allowed row, unchanged. Under the ablation the same probe reproduces the defect I was told about: the first two spellings admit and store on both drivers, the positive form refuses 403, explain answers visible: true. At the face, 14 spellings refuse ($ne, bare, $eq, $not{bare}, $not{$ne}, both empty arrays, $ne beside $in or $gt, a dotted path, four combinators deep, under a satisfied $or branch, after a failing sibling, a null-prototype operator map), for every record including one holding the identical array. Nothing turns the throw into a pass. A multi: true update by where never reaches the evaluator (pre-existing "log and skip"): see ③.

Closure — same class, still admitting (measured admitted-and-stored through the same gate). (a) record.status != record.tags with tags a json column: a { $field } reference resolving to an array, declared untouched by the docblock and changeset; (b) !(record.status in [['closed','archived']]); (c) the ordering coercion record.priority > ['low'] (denied for high by string order, $gt: ['a'] answers true on the dist). None is in ruling A's letter; all three are the seat's 2d. !(record.status in 'closed'), in current_user.<scalar> and a map literal fail at the lowering (policy dropped, 403): closed. Three JSON-only shapes stay fail-open under $not ({ $not: { $ne: [..] } } with no field, { $not: { $or: [] } }, { $not: [ {..} ] }), plus a non-array comparand kind under $ne (array-like object, Set, class-instance operator map) answers true: unreachable from CEL, no security caller passes JSON, noted only.

Regression. Callers enumerated repo-wide at this head: the three above, the index.ts:96 re-export, 35 test files, objectui one comment. Shipped carriers: default-permission-sets.ts (54 check/using strings, all scalar ==, != null), examples/app-showcase (3), content/docs (21), skills/objectstack-data (3), the hotcrm and qa fixture artifacts; a grep for !=/== against a list literal over packages, examples, content, skills, apps and templates returns 0. The explain engine's other filters ({ id: { $in } } sharing, { organization_id } tenant) carry neither shape. Nothing that loads or answers today breaks; the two retired-answer pins are re-judged in the diff.

The message. A constant: no field, operator or value from the filter is echoed (re-measured with a secret column and two ids), no tracker number, "$in"/"$nin" are FieldOperatorsSchema spellings. "No stored value ever equals an array" and "$ne matched EVERY record" hold for every post-image a door produces (looseEq is === outside Dates). The read-side envelope claim holds: driver-sql and driver-memory answer 400 INVALID_FILTER on all five read shapes through the engine door and the driver door alike.

Pins and ablation (re-measured). Removing both throw sites (blob d6efb91bac0b → 03775e85fe85, the rebuilt dist answers $ne [..] true) turns 27 of 77 formula pins red (26 in the refusal describe, the re-judged matches-filter.test.ts case) and both plugin-security pins red; the six neighbour pins stay green; restore proved blob == HEAD, porcelain empty, the rebuilt dist throws again, 79 green. Uncovered by the pins: (a)–(c) above; the delegator leg and the update middleware are covered by reading and by my probe, not by a committed pin; explain is uncovered.

Every shipping sentence. Body compile-face table re-measured cell by cell at this head: faces 1, 2, 5 and driver-memory (matcher and live find) 400 on all four shapes; face 3 emits (status IS NULL OR status <> ?) / status = ? binding the array as the one parameter and 500 READ_SCOPE_COMPILE_FAILED on the two bare shapes; face 4 passes all four through; the half face answers true,true / false / false / true,true on scalar rows; mongodb translateFilter passes through ($not → $nor) and mingo 7.2.4 selects every scalar row for $ne and for $nor. All eight locations hold; SqliteWasmDriver and TursoDriver extends SqlDriver. Nit: the "every row" cells for the half face and mongodb exclude a row storing the identical array. The changeset, docblocks, error text and ADR-0087 entry (surface, replacement, reason, acceptanceCriteria) were each read against the measurements: every behaviour claim holds; the entry's "the explain engine's record attribution refuses too" and "the positive form refused every write (403)" are both measured here. Two dev-only claims stand as claims: "three drivers" (I measured two). The registry carries the entry (regenerated, in the built spec dist); docs/protocol-upgrade-guide.md and spec-changes.json carry nothing from it today, correctly: the guide renders majors 17..PROTOCOL_MAJOR = 17 and step 18 is not yet current, spec-changes.json projects conversions only; check:generated is green at the head. The body's Clause-②: no (narrowing) and BREAKING bullet now match the changeset; the earlier "non-breaking patch" sentence is gone. Commits: 7; 6 carry the model-free trailer pair, the merge commit 58ce4f1b16 carries an auto-generated message with none. The diff, body, commit messages, changeset and entry were swept for every model-identifier spelling: 0 hits.

② Semver level

Consistent and correctly declared. matchesFilterCondition is a published accept set (formula/src/index.ts:96) and the diff narrows it; the changeset declares Clause-②: no (narrowing) with the **BREAKING** banner and <!-- adr-0087: registered rls-predicate-array-comparand-refused -->, the entry is new in the diff and resolves in registry.ts (the gate's registered rule); Check Changeset and Lint & Repo Gates are green at this head. Levels: @objectstack/formula minor under the launch-window convention (check-changeset-no-major.mjs header; 23 stock (narrowing) changesets all at minor); @objectstack/plugin-security minor although its source is unchanged, because its shipped behaviour narrows through the dependency (admit → 400, positive form 403 → 400) and the fixed group versions it anyway, so the grade only chooses which CHANGELOG section carries the sentence — acceptable; @objectstack/spec patch for a ledger-only addition, precedent .changeset/17594-step18-element-node-todo.md. ADR-0087 disposition: the registered entry covers the change an author meets (the RLS predicate) and names the evaluator; the programmatic accept set of the exported function is carried by the changeset's first sentence, which is the CHANGELOG text a direct caller greps. Adequate. Nit, non-blocking: acceptanceCriteria says "refuses every write it governs" — in the runtime's own definition a check governs single-id writes only (③).

③ Boundary flags

Blocking: none.

Non-blocking:

  • Carried by 2b (seat 5806391955): the shared comparand-shape face and FieldOperatorsSchema.$ne (ruling A item 1), the having half face, faces 3 and 4, as re-measured.
  • Carried by 2c (PR fix(formula, driver-mongodb): refuse == / != against a list literal at the CEL lowering and $ne arrays at the mongodb face #19947, round 2 dispatched 5806950431): the CEL-lowering refusal of list literals and resolved arrays, driver-mongodb's $ne face, the residual !(record.x == current_user.<array>) read widening on mongodb. Envelope consequence the seat should decide before landing order: once 2c lands, a CEL check carrying a list literal is dropped at compile (RLS_DENY_FILTER, 403) and never reaches this refusal, so the "INVALID_FILTER / 400" this changeset and entry publish for the authored predicate becomes true only for a direct filter caller and for the explain path; two changesets in one release will describe two envelopes for one predicate unless one is worded relative to the other. Live mongod stays NOT MEASURED everywhere.
  • Carried as 2d (seat 5806608550, scope after 2c, director ratification pending): (a) !=/!(==) against a { $field } reference to a multi-valued column, (b) a nested list under in, (c) ordering operators with a list — each re-measured admitted-and-stored above.
  • Carried by security: RowLevelSecurityPolicySchema.check is published as "defaults to USING clause if not specified", but the write check runs only policies that declare check, so a USING-only policy never gates an INSERT #19942 (domain:services, claimed): a USING-only policy never gates a write. By driver-mongodb: translateFilter passes a { $field } cross-field reference through as a literal document, so record.s != record.t matches every row (an RLS using read widens) #19949: driver-mongodb does not lower { $field }.
  • Not carried at all (measured at this head, pre-existing, outside ruling A): under a check-only policy, update(…, { where, multi: true }) as the caller is admitted and stored (b1: open → closed); the middleware logs "not post-image validated (governed by the using-scoped where)" and no using exists to scope it. The evaluator is never reached, so 2a cannot close it; an author reading the entry's "every write it governs" would expect otherwise. Also not carried: the JSON-only $not fail-open shapes and the non-array comparand kinds (unreachable from CEL); the stale driver-memory comment (memory-matcher.ts:371, "the same answer @objectstack/formula's matcher gives") now names a retired answer.
  • Deferred by the seat, no carrier: the lint text rls-predicate-unknown-user-variable (after 2c).
  • Dev round-2 open questions: Q1 (body contradiction) resolved by the seat's rewrite; Q2 (2b registers its own entry) is 2b's; Q3 (levels) judged above; Q4 (2c claim arm) already amended by the seat.

CI at this head: 46 check runs, 35 names after de-duplication on the latest started_at; none in progress or queued; no failure. All seven required contexts success (Lint & Repo Gates, TypeScript Type Check, Test Core with 6 shards, Dogfood Regression Gate with 3 shards, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard). Skips, each with its reason: Auto Label and Check PR Size on the edited re-runs the seat's body rewrite triggered (if: github.event.action != 'edited'; the original runs at this head succeeded); Packed-tarball smoke (opt-in, needs the needs:pack-smoke label); Console Pin Gate and Build Docs (needs.filter.outputs.console / docs false — the diff touches neither tree).

Implemented-by: claude/issue-19886-formula-face-refusal
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1 — at-tier review subagent spawned by the domain:spec#5 seat

VERDICT: PASS

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…and-shape face (objectstack-ai#19882)

Fixes objectstack-ai#19757
Clause-②: no (narrowing)

This executes ruling **5793368540** (batch objectstack-ai#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**:
- `@objectstack/spec: minor`, with the `**BREAKING**` banner,
`fix(spec)!:`, `Clause-②: no (narrowing)` and the `adr-0087 registered
filter-equality-array-comparand-refused` disposition marker. The level
is `minor` because AGENTS.md makes a `(narrowing)` BREAKING while
`check-changeset-no-major` forbids `major`. The launch-window convention
ships an accept-set narrowing as `minor`, and the objectstack-ai#19514 changeset is
the sibling precedent. `check-changeset-no-major` itself prints
"narrowing — a BREAKING change; during the launch window it ships
`minor`".
- `@objectstack/metadata-core: patch`, for the retired dispatch rows
below.

## 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 objectstack-ai#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 (`objectstack-ai#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:

- Make `objectql`'s harness accept the face's refusal. That would carve
an exception into the objectstack-ai#11009 contract that "both halves must refuse with
the SAME words".
- Teach the predicate the face. That is impossible byte-for-byte,
because the predicate has no object name for the face's
`update('task'):` prefix.

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 objectstack-ai#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 **objectstack-ai#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 objectstack-ai#19888 |
| 5 | `formula` | **already compliant at `origin/main`**; this PR
changes no formula file | Since objectstack-ai#19946 (objectstack-ai#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 **objectstack-ai#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](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_

---------

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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ace and at FieldOperatorsSchema.$ne (objectstack-ai#20204)

Part of objectstack-ai#19886
Clause-②: no

Stage **2b** of objectstack-ai#19886, and only 2b: ruling A item 1 (record
`5805254639`), at its two named positions. The shared comparand-shape
face refuses an array under `$ne` for every driver, and
`FieldOperatorsSchema.$ne` refuses it at parse. Both print one remedy
text, which names `$nin`, the list-negation operator
`FieldOperatorsSchema` declares. objectstack-ai#19886 stays open for stage 2d (the
three same-class leaks recorded in `5806608550`), which this PR does not
touch.

`Clause-②: no` above is the claim's line (`5853529590`), 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 give the value `no`.

Dispatched by the `domain:spec` seat 1 PM loop, session
`session_01Rjy9MeetSfq34PKn81CRiN`, branch
`claude/issue-19886-ne-array-shared-face-and-schema-door`, base
`9e7824a4`, then `origin/main` `560b724c` merged in (`90b9d496`). Every
reading below names the tree it was taken on.

## 1. Measured first (before the change, `origin/main` `9e7824a4`)

The ruling quoted, verbatim: 「The shared comparand-shape face refuses an
array under `$ne` for every driver, and `FieldOperatorsSchema.$ne`
(`filter.zod.ts:269`) refuses it at parse — one remedy text, naming the
declared list-negation operator by its spec spelling (the dev reads it
off `FieldOperatorsSchema`; ⛔ not invented here). ⛔ No alias, ⛔ no
window.」 The line number was `:269` at ruling time. On `9e7824a4` the
documentation copy `EqualityOperatorSchema.$ne` sits at `:300` and the
enforced `FieldOperatorsSchema.$ne` at `:1483`. Both were `z.any()`.

Stages 2a (PR objectstack-ai#19946) and 2c (PR objectstack-ai#19947) had already closed the two
faces that ANSWERED the shape: the formula evaluator and
`driver-mongodb`'s walk. What still accepted an array under `$ne`, read
on `9e7824a4` with a `tsx` probe against `src/`:

| door | `{ tags: { $ne: ['a'] } }` (and `[]`, and under `$or` / `$not`)
|
|:--|:--|
| `assertListComparandShapes` (the shared face) | **PASS**, at every
depth |
| `parseFilterAST([['tags', 'ne', ['a']]])` | **lowered** to
`{"tags":{"$ne":["a"]}}` and passed |
| `FieldOperatorsSchema` / `EqualityOperatorSchema`, `{ $ne: ['a'] }`
and `{ $ne: [] }` | **`success: true`** |
| `NormalizedFilterSchema`, `{ $and: [{ s: { $ne: ['a'] } }] }` |
**`success: true`** |
| `FilterConditionSchema` / `DatasetSchema` `filter: { stage: { $ne:
[...] } }` | `success: true` (not a named position; see section 7) |
| `ViewFilterRuleSchema`, `not_equals` with `['a']` | already refused at
authoring |
| control: the face on `$eq: ['a']` (the landed equality arm) | refused,
`INVALID_FILTER` / 400 |
| controls: `$ne: 'a'`, `$ne: null`, `$ne: { $field: 'budget' }` | pass
at every door |

`objectql`'s engine binds the face through its delegating wrapper
(`packages/objectql/src/filter-comparand-shape.ts`), so it passed too.

The analytics `where` door runs the face, so on `main` it **compiled**
the shape instead of refusing it. `normalizeAnalyticsFilterTree({ where:
{ stage: { $ne: ['won', 'lost'] } } })` returned `stage` not-set OR
`stage` not-equals `["won","lost"]`. Both analytics strategies render a
not-equals member from its first value (`values[0]` in the native SQL
strategy, `v0` in the ObjectQL strategy; read at source, not executed).
So `'lost'` was dropped in silence, and a chart counted rows its filter
named. This tree was measured with the face's `$ne` arm removed (the
ablation in section 4), which is `main`'s face. The analytics door's own
code is unchanged by this PR.

## 2. What changed

Three source files in `packages/spec/src/data/`. Nothing in any driver,
in formula, or in objectql.

- **`filter-comparand-refusal-text.ts`**: the module both doors import,
which already carried the equality-slot sentence. It gains
`NIN_OPERATOR_SPELLINGS`, the `$ne` remedy
`ARRAY_INEQUALITY_COMPARAND_REMEDY`, and
`arrayInequalityComparandMessage`. The `$eq` and `$ne` sentences now
share one private template, and the equality sentence is byte-identical,
as its existing pins prove.
- **`filter-comparand-shape.ts`**: a `$ne` arm in the existing walk (⛔
no second walk), beside the `$eq` arm, with its own error builder. The
`$nin` row of `LIST_COMPARAND_OPERATORS` now reads its spellings from
the shared module, as the `$in` row already did. The module docblock
gains the ruling's section, and the equality section's bullet that said
`$ne` was not judged is corrected.
- **`filter.zod.ts`**: `inequalityComparandSchema()`, one factory shared
by `FieldOperatorsSchema.$ne` and its documentation copy
`EqualityOperatorSchema.$ne`. It mirrors `equalityComparandSchema()`
exactly: `z.any()` except an array, and the describe `NE_DESCRIPTION` is
unchanged.

The face's refusal, verbatim:

```text
Operator "$ne" on field "tags" requires a single comparable value, but received an array (["a"]) at where.tags.$ne. For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin). The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.
```

The operator slot's issue (code `custom`, path `$ne`), verbatim:

```text
Operator "$ne" requires a single comparable value, but received an array (["won","lost"]). For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin). The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.
```

The first sentence is `driver-memory`'s `arrayComparandError` for `$ne`,
word for word. The remedy is ONE operator, as the ruling says: `$nin`,
read off `FieldOperatorsSchema` ("Not in list"), with the authoring
spellings that lower to it. `$notContains` is not offered, because it is
declared on a STRING comparand and is not a list operator. The equality
slot's `$in` / `$contains` are not offered either, because they answer a
different question. `$nin` is also the remedy the formula and
`driver-mongodb` faces already name for this shape.

Also in the diff: one ADR-0087 semantic entry
(`18.filter-ne-array-comparand-refused.ts`) and the regenerated
`registry.ts`. The `dropped-refinements.baseline.json` ledger gains the
three `$ne` sites the build named (`data/EqualityOperator` `$ne`,
`data/FieldOperators` `$ne`, `data/NormalizedFilter`
`lazy.$not.options[0].valueType.$ne`; sites 574 to 577). The changeset
is `@objectstack/spec` minor. It carries the BREAKING banner, `Clause-②:
no (narrowing)`, and the ADR-0087 `registered` marker for
`filter-ne-array-comparand-refused`. `check:generated` reads all 15
artifacts current on `90b9d496`. `spec-changes.json` and the upgrade
guide do not list major-18 entries (the sibling equality entries are
absent too), so they did not move.

## 3. Pins

Every accept/refuse change is pinned, and every refusal case asserts its
envelope.

- `packages/spec/src/data/filter-comparand-shape.test.ts` (the face).
The `it.todo` that stood for this arm is replaced by real pins:
- 9 refusal rows: the lowered `ne`, `not_equals` and `!=`; the object
passthrough; `[]`; nested under `$and` (lowered), `$or` and `$not`; and
beside a legal `$eq`. Each asserts `code` `INVALID_FILTER`, `status`
400, the path, and the `$ne` leading sentence.
- The full sentence and the single `$nin` remedy, including that `$in`,
`$contains` and `$notContains` are absent. The context prefix is kept.
- Every AST spelling that lowers to `$ne` refuses an array. The
spellings are derived with a scalar probe, and a guard pins exactly six:
`!=`, the angle-bracket pair, `ne`, `neq`, `not_equals`, `notequals`.
- `$nin` is a key of `FieldOperatorsSchema`, and the spellings the
message lists are exactly those that lower to it.
- Controls: `$ne: null` (both spellings), scalars, `0`, `false`, `''`, a
`Date`, and a `{ $field }` reference.
  - The no-`$`-key boundary.
  - Three rows in the 500-char client-bound test.
- The array-valued `LIT CONTROL` set is now exactly `$between`, `$in`,
`$nin`.
- `packages/spec/src/data/filter-ne-array-schema-door.test.ts` (new; the
operator slot and the one-text rule):
- §1: both copies refuse `$ne: [...]` and `$ne: []`, asserting the issue
code `custom`, the path `$ne` and the full prescription. A `$ne` array
beside a legal `$eq` raises one issue, at `$ne`.
`NormalizedFilterSchema` refuses at `$and.0.stage.$ne`, with a scalar
control.
  - §2: the face's envelope at four positions.
- §3: the slot's message equals the face's, character for character,
after removing only ` on field "stage"` and ` at where.stage.$ne` (both
proved present first). This is checked over four lists. Every
FilterArray spelling gives the face's sentence, and the remedy is
declared and single.
  - §4: controls at BOTH doors, accepted and KEPT.
- §5: one `it.todo` for the stored-carrier walk (section 7), ⛔
deliberately not pinned green.

## 4. Proof the pins can fail (ablation, two legs, each position alone)

Both legs ran from committed state (`3757d64a`), with
`scripts/ablation-replace.mjs` (anchor must hit; blob hash asserted), a
`trap` restore on `EXIT INT TERM` against an absolute path, and restore
proven by `git diff HEAD` = 0 bytes.

- **M1, the face arm cut** (`throw` guarded by an impossible field
name): on disk marker 1 and anchor 0. Spec rebuilt, and
`ablation-dist-preflight` found the marker in 6 dist files. Results:
- Spec: **24 failed** / 143 passed. Every face `$ne` pin, the §2 / §3
two-door parity, and the equality door's re-judged `$ne` test went red.
The slot's §1 pins stayed **green**, which is correct, because only the
face was cut.
- `service-analytics`: **1 failed** (the flipped pin), and its parity
file stayed green by design.
  - The probe printed the pre-fix analytics tree quoted in section 1.
- Restore: 0 diff bytes. Rebuilt, preflight `--absent` read the marker
absent from all 222 dist files, the tree was clean, and both suites were
green again (167 passed / 2 todo; 160 passed).
- **M2, the operator slot cut** (the `addIssue` guarded by an impossible
length; spec tests read `src/`): on disk marker 1 and anchor 0. Spec:
**12 failed** / 155 passed. Every slot pin in §1 and §3 went red, plus
the `LIT CONTROL` set, which read `$ne` as array-valued again. The face
pins stayed **green**. Restore: 0 diff bytes, and the tree was clean.

The two red sets are disjoint where they should be, so each pin is tied
to the position it names.

## 5. Pin sweep and consumer suites

Pin sweep. Error code and message were grepped repo-wide: `$ne` followed
by an array literal, the FilterArray `ne`-family triples carrying an
array, and `not_equals` view rules. Pins the new refusal made FALSE,
flipped in one round, each to assert the new substance:

- `filter-comparand-shape.test.ts`: the `it.todo` and the `LIT CONTROL`
set.
- `filter-equality-array-schema-door.test.ts` §4, the row `$ne carrying
an array — not this ruling`. It asserted that the face PASSES the shape,
and that half is false now. It is re-judged into its own test, which
asserts the face's `$ne` refusal (envelope, the `$nin` remedy, not the
`$in` one). It keeps the row's real guard: the carrier walk's EQUALITY
arm raises nothing for `$ne`. That is asserted on the equality
sentences, not on `success`, so no ruling-less acceptance is pinned
green.
- `service-analytics` `where-equality-slot-list-refusal.test.ts`:
`.not.toThrow(/requires a single comparable value/)` was false. It now
asserts the envelope, byte equality with the face, the `$ne` sentence
and the `$nin` remedy, and that the words are not this door's
equality-slot sentences.

Pins that move by construction and were read, not edited:

- `objectql` `engine-aggregate-having-comparand-shape.test.ts` (the
`$ne: [500]` row, through the engine's wrapper) is written as parity
with the face. It now takes its refusal branch, asserting the envelope
and the face's message on both doors.
- `service-analytics` `where-face-arms-refusal.test.ts` (the parity
block) now compares two refusals.
- `driver-memory`'s AST-vocabulary probe helper now reads `undefined`
for the `ne` spellings and hands them the same scalar it always did.

Consumer suites. 

Every suite below ran through `scripts/pm/os-verify-lock.sh`, with its
exit code captured before any pipe. Two heads:

| suite | `3757d64a` (before the merge of `main`) | `90b9d496` (after
it) |
|:--|:--|:--|
| `@objectstack/spec`, the whole `local` project (3 shards) | 541 files
passed, 0 failed (15874 tests, 2 todo) | 542 files passed, 0 failed
(15935 tests, 2 todo) |
| `@objectstack/service-analytics` | full: 128 files, 3017 tests passed
| the 2 pin files: 160 passed |
| `@objectstack/objectql`, the 29 files touching the face,
`parseFilterAST`, comparands or `$ne` | 1053 passed | not re-run
(untouched by the merge) |
| `@objectstack/formula` | full: 36 files, 1002 passed | full: 37 files,
1027 passed |
| `@objectstack/lint` | full: 108 files, 4156 passed | not re-run |
| `@objectstack/driver-memory` | full: 53 files, 1269 passed | not
re-run |
| `@objectstack/driver-mongodb` | full: 27 passed, 5 skipped (live
`mongod`) | full: 28 passed, 5 skipped |
| `@objectstack/driver-sql`, 44 filter / comparand files | 42 passed, 2
skipped (live PG / MySQL) | not re-run |
| `@objectstack/plugin-security`, the RLS / write-check files | 33
files, 1123 passed | 34 files, 1138 passed |

The post-merge re-run is a declared narrowing. It covers the packages
the merged commits touched (`spec`, `formula`, `driver-mongodb`,
`plugin-security`) and my two analytics pin files. The rest were green
on `3757d64a`, and the merge changed neither them nor this diff.

Typecheck on `3757d64a`: `pnpm --filter @objectstack/spec run typecheck`
exited 0; its test layer held `test-typecheck-debt.json` unchanged.
`pnpm --filter @objectstack/service-analytics run typecheck` exited 0,
and `tsc --listFiles` confirms the edited test file is in that program.
On the merged head `90b9d496` the spec typecheck is NOT MEASURED at the
time this PR opened. Two lock acquisitions returned 99 across about 20
minutes, behind a single holder of more than 17 minutes. The merged-in
commits touch no file this diff imports, and CI's required `TypeScript
Type Check` lane measures this head. The report comment on objectstack-ai#19886
carries the reading if it lands before the report.

eslint (`--no-inline-config --format json`) on the 9 touched `.ts` files
at `90b9d496`: 9 files linted, 0 errors, 0 warnings. The population is
read from `eslint.config.mjs`: every file sits in its `**/*.{ts,…}` and
`packages/**/*.{ts,…}` objects. The config never enables type-aware
linting (no `parserOptions.project`, no typed rules), so this diff
cannot move the verdict on any untouched file.

## 6. Census (Zone 2 item 5): no producer

- This repository, `9e7824a4`. `$ne` followed by an array literal in
`packages/**`, `examples/**`, `apps/**`, `scripts/**`, `content/**` and
`skills/**`: 20 hits, all tests, refusal code, comments or migration
prose. Control: `$in` followed by an array in `packages/**` and
`examples/**` has 901 hits. The FilterArray `ne`-family with an array
has 0 hits. A CEL `!=` against a list literal in platform objects,
examples and `packages/qa/**` (non-test) has 0 hits. `$ne` fed by a
variable in non-test source has 11 sites, and none builds a list.
- objectui at the `.objectui-sha` pin `f8a9d0fb05`: 2 hits, both its own
refusal test. Its dataset builder's `notEquals` is scalar-arity (the
list-arity set is `in`, `not_in`), and the summary editor lists only
`in` / `notIn`. The control has 46 hits.
- cloud `main` `48d7066`: 0 hits. The control has 33 hits.

A real producer would have changed what ADR-0087 owes. None exists, so
the entry carries no D2 conversion.

## 7. What this does NOT do (acceptance notes)

- **The stored-filter carrier walk.** `FilterConditionSchema`, which
every stored carrier (dataset, widget, report, rollup, and the rest)
parses through, does not route a field's operator map through
`FieldOperatorsSchema`. Its walk judges `$eq` and not `$ne`. So
`DatasetSchema` with `filter: { stage: { $ne: ['won', 'lost'] } }` still
parses green on this head, and every query that uses it is refused at
the face. Ruling A names the face and the operator slot, not that walk,
and objectstack-ai#19889's ruling had to name `FilterConditionSchema` explicitly for
the equality slot. Extending the walk narrows every carrier's accept
set, so it is ⛔ not taken here. It is raised in the report for the seat,
and pinned only as an `it.todo`. This is not a new split: on `main`
every backend already refused the shape at query time.
- Stage 2d (the ordering operators with an array, a nested array inside
`$in`, a `{ $field }` referent to a multi-valued field) is untouched,
and so are all driver and formula sources.
- Sentences elsewhere that this change makes stale, noted and ⛔ not
edited here because they are outside this claim's file surface:
- `driver-memory`'s `arrayComparandError` text says the spec's comparand
door "leaves this position to the driver". That has been untrue for the
equality slot since objectstack-ai#19757, and is now untrue for `$ne`. It is reachable
only by a caller that hands a raw filter to the driver.
- The unreleased entry `filter-equality-array-comparand-refused-at-save`
describes `$ne` as a position "which no ruling has decided".
- The unreleased changeset `19889-filter-schema-door-array-equality.md`
lists "`$ne` carrying an array is not judged" among what that change did
not do.
- The published JSON Schema still reads `{}` at `$ne`, which is declared
in the dropped-refinements ledger.

## 8. Gates

Derived on the actual change set (`node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack`, merge base `560b724c`,
head `90b9d496`): 89 commands. That is the dispatch lead's 77 plus 12
this diff adds (the changeset families, `check:where-matcher`,
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:type-check-coverage`, `check:type-check-debt`, and others). None
of the lead's commands dropped out. Every line was run with its exit
code written to disk, then reconciled with `--ran` using `command ::
exit N` records: **89 derived, 87 run with exit 0, 2 NOT MEASURED, 0
unrun.**

- ⊘ NOT MEASURED: `pnpm check:dual-build-cjs-loads` and `pnpm
check:type-check-debt`. Both exited 3, `PREREQUISITE NOT MET`: each
reads every workspace package's built `dist/`, and this worktree built
only the closures of the suites above. They are whole-tree families that
CI runs after its full build. This diff changes no package's exports or
build shape.
- Among the 87: `check:adr-0087-registration --base origin/main` (it
reads the changeset's `(narrowing)` arm and the `registered` marker),
`check:changeset-no-major`, the `check:changeset-gate-self-tests`,
`check:nul-bytes`, `check:doc-authoring`, `check:where-matcher`, and the
spec families `check:api-surface`, `check:authorable-surface`,
`check:docs`, `check:spec-changes`, `check:migration-registry`,
`check:upgrade-guide`, `check:liveness` and `check:exported-any`.

Local runs are not a complete account of CI: the artifact-roster
families, the wide-population families, the path-scheduled jobs and the
type-check lanes are CI's.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…on the read refuses — one comparison class, one answer per policy (objectstack-ai#20355) (objectstack-ai#20427)

Fixes objectstack-ai#20355
Clause-②: yes (narrowing)

## What this does

One RLS policy that compares two fields of no shared comparison class
used to get two answers: driver-sql refused the read it scopes
(`INVALID_FILTER` / 400), and the in-process write check compared the
two raw values and admitted and stored the write. Both evaluators now
read objectstack-ai#20347's classification (`crossFieldComparisonVerdict` /
`crossFieldColumnVerdict`, `@objectstack/spec/data`), and the write
check refuses where the read refuses.

- **`@objectstack/formula` (the write-check evaluator).**
`matchesFilterCondition(record, filter, options?)` takes the object's
declared columns as `options.fields`. Given them, every `{ $field }`
comparison between two declared columns is judged by
`crossFieldComparisonVerdict` before any record is read
(record-independent, like the objectstack-ai#5240 / objectstack-ai#19886 shape refusals), and
`cross-class` or `no-class` throws `INVALID_FILTER` / 400. The message
names nothing from the filter (the objectstack-ai#7929 posture the read takes for the
same comparison); the refused comparison travels on the error under a
symbol key for the server log. New exports:
`findCrossFieldClassRefusal`, `crossFieldClassRefusalCarriedBy`, types
`MatchesFilterOptions`, `CrossFieldClassRefusal`. Without `fields` the
evaluator is byte-for-byte the old one.
- **`@objectstack/plugin-security` (the write gate, step 3.6).** Hands
the evaluator the object's declared columns (`writeCheckFieldOptions`:
`ql.getSchema`, then the metadata service, the order
`loadObjectFieldNames` uses) for every image it judges: single and array
inserts, by-id updates, predicate updates. On the refusal it logs one
WARN naming the policy and both columns: `[Security] RLS check REFUSED
on insert 'OBJECT' (INVALID_FILTER): policy 'deal_guard' — the
comparison … compares "status" (type 'text') … and "amount" (type
'number') …`. The policy name comes from a WeakMap the RLS compiler now
keeps from each policy's compiled filter to the policy
(`compiledPolicyNameOf`); nothing is added to the filter objects
themselves.
- **`@objectstack/driver-sql`.** `crossFieldComparisonClass` delegates
to `crossFieldColumnVerdict` for every declared `FieldType`, and keeps
only the driver-internal aliases above it, read off its own sets
(`JSON_COLUMN_TYPES`: `object` / `array`; `NUMERIC_SCALAR_TYPES`:
`integer` / `int` / `float`). No second copy of the classification is
left.
- **`@objectstack/lint`.** `crossClassConsequence`'s write sentence now
states the runtime's answer: "the in-process write check refuses the
comparison by the same classification (`INVALID_FILTER` / 400), so every
insert or update it judges is refused and nothing is stored", and the
`check` clause closes "The policy reads as a write rule and admits no
write at all." (objectstack-ai#20347 ACCEPT note 1) Round 2: the one sentence in the
header's objectstack-ai#20347 section that said the write check has no class rule now
says it refuses by the same classification.
- **`@objectstack/spec` (patch, round 2).** One ADR-0087 D3 semantic
entry for the whole family,
`rls-predicate-cross-class-field-comparison-refused` under protocol
major 18
(`packages/spec/src/migrations/entries/semantic/18.rls-predicate-cross-class-field-comparison-refused.ts`).
It names both arms: objectstack-ai#20347's authoring arm (`os validate`, build, lint
and the permission save door) and this write check.
`packages/spec/src/migrations/registry.ts` is regenerated by `pnpm
--filter @objectstack/spec gen:migration-registry` (71 lines inserted,
none removed), as PRs objectstack-ai#19946, objectstack-ai#20259 and objectstack-ai#20310 did. The changeset's
marker moves to `registered` with that id, and it adds
`'@objectstack/spec': patch`. The two comments that named the retired
parity test (`filter-cross-field-comparison-class.ts`'s header and its
test's header) now say that driver-sql delegates to
`crossFieldColumnVerdict` and that
`sql-driver-20355-cross-field-class-driver-aliases.test.ts` pins the
alias layer it keeps.
- **The objectstack-ai#20347 parity test retires.**
`sql-driver-20347-cross-field-class-parity.test.ts` held driver-sql's
private copy equal to the export over 3,025 ordered pairs. There is no
private copy any more, so the pairs are equal by construction. Before it
was deleted it ran on the rewired driver (commit 4605cc7): 56/56
green. The alias layer the rewire kept is pinned by the new
`sql-driver-20355-cross-field-class-driver-aliases.test.ts`.

## Measured, before and after

Through the real plugin-security + ObjectQL, policy `operation: 'all'`,
a member caller. Before = base 789b2ae, after = this branch. The same
answers on better-sqlite3, sqlite-wasm and PostgreSQL 16:

| policy | read (`using`) | by-id update / delete (`using`) | insert
with `using` as the check | `check` insert | `check` by-id update |
|---|---|---|---|---|---|
| `record.status != record.amount` (text vs number) | 400 → 400 | 403 →
403 | admitted, stored → **400**, nothing stored | admitted, stored →
**400** | admitted → **400** |
| `record.status != record.photo` (text vs image) | 400 → 400 | 403 →
403 | admitted, stored → **400** | admitted, stored → **400** | admitted
→ **400** |
| `record.status != record.is_open` (text vs formula; the card's formula
cell) | 400 → 400 | 403 → 403 | admitted, stored → **400** | admitted,
stored → **400** | admitted → **400** |
| `record.status != record.meta` (text vs json holding one value) | 400
→ 400 | 403 → 403 | admitted, stored → **400** | admitted, stored →
**400** | admitted → **400** |
| `record.amount > record.status` (number vs text) | 400 → 400 | 403 →
403 | 403 → **400** | 403 → **400** | 403 → **400** |
| `record.status != record.title` (text vs text, control) | rows → rows
| admitted → admitted | admitted → admitted | admitted → admitted |
admitted → admitted |

The formula cell answers exactly like the other two: the classification
gives a formula field no class (`no-class`, reason `formula`), so it is
refused on both sides.

driver-memory, measured out of tree (this package cannot declare
`@objectstack/driver-memory` without a `driver-memory-census`
disposition): the write answers as in the table (400 on every write
cell, nothing stored), because the check runs in-process before any
driver. Its **read is unchanged and still admits** (`rows=1` for every
cross-class cell): driver-memory has no `{ $field }` arm at all and
compares the marker object as a literal (objectstack-ai#15104, closed not planned). So
"refused on read and write" holds on SQLite, sqlite-wasm and PostgreSQL,
and on memory for the write only.

A json or `multiple` column is a `no-class` column (`list-or-object`),
so a comparison against one is now refused by its declared type, for
every record. objectstack-ai#19886 stage 2d judged it by the value each record held (a
json column holding one scalar compared). driver-sql's read has always
refused it by declared type, so this moves the write onto the read's
answer too.

## Compile faces
(`.claude/skills/pm-dispatch/references/compile-surfaces.md`,
re-verified at c80202c)

| # | face | verdict |
|---|---|---|
| 1 | `driver-sql` `applyFilterCondition` (`sql-driver.ts:16192`), and
by inheritance `driver-sqlite-wasm` and local-mode `driver-turso` |
**changed**: `crossFieldComparisonClass` reads
`crossFieldColumnVerdict`. The answers are unchanged: parity 56/56 on
the rewired driver before it retired, the cross-field conformance and
reference suites green on SQLite and PostgreSQL. |
| 2 | turso `RemoteTransport.buildWhereSQL` (`remote-transport.ts:2695`)
| **already compliant**: refuses every `{ $field }` comparand in remote
mode, whatever the classes (`uncompilableComparand`,
`remote-transport.ts:4392`). |
| 3 | service-analytics `compileScopedFilterToSql`
(`read-scope-sql.ts:696`) | **already compliant**: a read scope carrying
a `{ $field }` is declined by `NativeSQLStrategy.canHandle` and served
on the engine path, where face 1 compiles or refuses it (objectstack-ai#7598 ruling,
`read-scope-sql.ts:284`). The `/analytics/sql` echo refuses the
reference outright. |
| 4 | service-analytics `lowerAnalyticsWhere`
(`filter-normalizer.ts:2171`) | **already compliant**: same routing: a
`{ $field }` comparand reaches face 1 (`filter-normalizer.ts:1453`). |
| 5 | `formula` `matchesFilterCondition` (`matches-filter.ts:305`) |
**changed**: judges every `{ $field }` comparison by
`crossFieldComparisonVerdict` when the caller supplies the declared
columns. |
| half | objectql `having-filter` (`applyHaving` / `matchesHaving`,
`having-filter.ts:1131` / `:1154`) | **out of scope**: it calls face 5
without declared columns, so its answers are unchanged. A `having`
reference compares columns of the AGGREGATED row (group keys, aggregate
aliases), not declared `FieldType` columns, so this classification does
not cover them (objectstack-ai#20127 classifies them separately). HAVING is evaluated
in-process on every driver, so there is no read-side twin that could
disagree. |
| unfrozen | `driver-memory` `checkCondition` (`memory-matcher.ts:361`)
| **out of scope**: no `{ $field }` arm to attach a class rule to
(objectstack-ai#15104, closed not planned). Measured above: its read compares the
marker as a literal. |
| unfrozen | `driver-mongodb` `translateFieldOperators`
(`mongodb-filter.ts:962`) | **already compliant**: refuses every `{
$field }` reference (objectstack-ai#19949, `mongodb-filter.ts:264`). |

## Tests (measured head c80202c)

- `formula`: new `matches-filter-cross-field-class.test.ts`: every
declared class against every other, all six operators, expectations
written from the table's labels and not from the verdict function;
record independence; `$and` / `$or` / `$not` nesting; the `addDays`
form; undeclared, dotted and unjudged columns left alone; without
`fields` unchanged; the withheld message and the carried diagnostic.
22/22 at c80202c. Full package (at 0ee6f4c; the merge of `main`
brought no change to formula, driver-sql, lint or plugin-security): 41
files, 1213 passed; `typecheck` exit 0 (`check:test-typecheck` OK, debt
unchanged).
- `plugin-security`: new `rls-check-cross-class-field-refused.test.ts`,
through the real engine on better-sqlite3, sqlite-wasm and PostgreSQL
(opt-in, `OS_TEST_POSTGRES_URL`). Cells: the read, by-id update and
delete, the using-as-check insert, the check insert, array insert and
by-id update. Each refusal asserts `code` + `status` and that nothing
was stored or changed; the 400 names neither column; exactly one WARN
names the policy and both columns; the same-class control is admitted.
48/48 at c80202c with PostgreSQL 16. Full package: 143 files, 3046
passed, 16 skipped (the PostgreSQL cells, no URL); `typecheck` exit 0.
- `driver-sql`: new alias pin, 8/8; `cross-field-reference` +
`cross-field-conformance` + alias pin with PostgreSQL: 290 passed, 1
skipped at c80202c. Full package: 194 files passed, 11 skipped; 3172
tests passed, 178 skipped; `typecheck` exit 0.
- `lint`: 115 files, 5314 passed; `typecheck` exit 0.
`validate-rls-predicate-enforceability.cross-class-field.test.ts`:
569/569 at c80202c.
- **Ablations**, each through `scripts/ablation-replace.mjs` with a
restore trap:
- **A**: the evaluator's `if (refusal) throw
crossFieldClassError(refusal);` was replaced by `void refusal;`. Anchor
1 → 0, blob 8e92043 → 58b1e295. formula was rebuilt (exit 0).
`ablation-dist-preflight` read the marker in 2 built files on the
pristine build and absent from all 6 on the mutated one. The formula pin
went **19 failed / 3 passed** and the plugin-security pin **45 failed /
3 passed** (the three survivors are the same-class controls). Restored:
blob == HEAD 8e92043, `git diff HEAD` empty, rebuilt, marker present,
tree clean; 22/22 and 48/48 again.
- **B**: the gate's `matchesFilterCondition(image as any, f as any,
checkFieldOptions)` was stripped of its third argument (blob af0a956 →
8f254f2f). plugin-security went 45 failed / 3 passed. Restored blob ==
HEAD, 48/48.
- **C** (reverse, predicted green): driver-sql's pre-rewire body was put
back in place (blob 4760990 → 77031c84). The alias pin and
`cross-field-reference` went 56/56 green, so the rewire did not move an
alias. Restored blob == HEAD, tree clean.
  - Directions observed: red, red, green, as predicted.
- **Lint (narrowed, proved)**: `eslint --no-inline-config --format json`
over the 10 `.ts` files this diff touches plus the 36 the `main` merge
brought in reported 46 files, 0 errors, 0 warnings (no file ignored).
Type-aware linting is not enabled (`eslint.config.mjs:328`: no
`parserOptions.project`, no typed rules), so this diff cannot move the
verdict of any file it does not touch. The full `pnpm lint` is CI's.

## Gates (c80202c, after merging `origin/main` 50e273f)

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 68 families. All 68 ran, each exit
code captured before any pipe, and all are 0. Three first answered exit
3, PREREQUISITE NOT MET: `check:i18n`, `check:dual-build-cjs-loads` and
`check:type-check-debt`. They were re-run after building their stated
prerequisites, and all three are 0: i18n "OK (9 packages)", cjs "104
entry points across 66 packages load", type-check-debt "4 ledger entries
re-measured, none above its recorded number". `--ran` reconciles: 68
derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. The changeset gates:
`check-adr-0087-registration` ✓ (1 declared-breaking changeset with a
disposition), `check-changeset-no-major` ✓, `check-empty-changeset` ✓.

## Patch rounds

- **Round 1 (e72518a).** `Clause-②: yes (narrowing)` in the changeset
and in this body, because formula's root entry grows.
- **Round 2 (cc0bf6e).** The D3 entry, the changeset's `registered`
marker and `'@objectstack/spec': patch`, the two spec comments, and the
one lint header sentence. `origin/main` was merged twice with true merge
commits: dbddf02, then e01d347, which carries objectstack-ai#20106's
`reclaimSpace`. Everything below was measured at cc0bf6e.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 96 families, the spec families now
among them. All 96 ran, each exit code captured before any pipe, and all
96 exit 0 on the first run. `--ran` reconciles: 96 derived, 96 run, 0
NOT-MEASURED, 0 UNRUN.
- `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts
are up to date, including `check:migration-registry`,
`check:spec-changes` and `check:upgrade-guide`. `pnpm
check:adr-0087-registration`: 0.
- spec `--project local src/migrations` plus the classification test: 4
files, 178 passed.
- On the merged code, driver-sql's full suite passes: 195 files passed
and 11 skipped, 3176 tests passed and 178 skipped. Its typecheck exits
0.
  - The formula pin passes 22/22 and the lint cross-class test 569/569.
- The plugin-security pin passes 32 with 16 skipped. PostgreSQL was not
provisioned this round; its cells passed 48/48 at c80202c, and this
round changed no code.
- **Round 3 (2698fa1).** Prose only; no logic or test change.
`origin/main` was merged twice more with true merge commits: 87c37ae
(4e430ba), then 0fcb101 (2698fa1). Everything below was measured
at 2698fa1.
- The D3 entry corrects three statements. `reason` gives the file
family's by-name refusal in the spec module's own words (the ADR-0104
dual-encoding window) instead of "no stored column", which is true of a
formula field only. `acceptanceCriteria` says the read answers 400 "on
the SQL drivers". `replacement` lists all four reference types, `tree`
included.
- `registry.ts` is regenerated by `gen:migration-registry` and changes
only in the entry's lines.
- Lint's objectstack-ai#20347 header paragraph now says driver-sql delegates through
`crossFieldColumnVerdict`, where it had named the retired parity test.
- `dispatch-gates --commands` derived 96 families; all 96 ran and exit
0, and `--ran` reconciles 96/96 with 0 NOT-MEASURED. `pnpm --filter
@objectstack/spec check:generated`: all 15 artifacts are up to date.
spec `--project local src/migrations` plus the classification test: 4
files, 178 passed.

## Deviations from the claim's file surface

- `packages/plugins/plugin-security/src/security-plugin.ts` (step 3.6
and `writeCheckFieldOptions`) and `rls-compiler.ts`
(`compiledPolicyNameOf`) are source, where the claim named
plugin-security for tests only. H2 held: the comparison is evaluated in
`matches-filter.ts`. That evaluator has no schema, though, and the
declared columns exist only at its caller, the write gate. Naming the
policy needs the compile seam, the one place that still knows each
policy's filter.
-
`packages/lint/src/validate-rls-predicate-enforceability.cross-class-field.test.ts`:
one assertion line, because it pinned the sentence this PR changes. In
`crossClassConsequence`, the changed text is the shared `write` constant
plus the check branch's closing sentence. The `using` branch
interpolates the same constant, so the `using` finding's last clause
reads the new answer too.
-
`packages/drivers/driver-sql/src/sql-driver-20355-cross-field-class-driver-aliases.test.ts`:
new, to pin the alias layer after the parity test retired.
- All three deviations above were accepted by the seat's amended claim
5868635246. Round 2's `packages/spec` files (the D3 entry, its
regenerated `registry.ts` and the two comments) and the lint header
sentence are in the claim re-posted as 5868966379.

## Acceptance notes

- **Not fixed here; reported for the seat.** `security.explain` (served
at `/security/explain`) answers a read of a row scoped by `record.status
!= record.amount` with `visible: true, decidedBy: rls`, while `find`
answers `INVALID_FILTER` / 400. Measured through the security service on
all three SQL drivers. Its record attribution calls
`matchesFilterCondition` without the declared columns. Passing them, the
option this PR adds, would align it. This is a separate face and the
file is outside this claim.
- objectstack-ai#20347's `listHoldingComparisons` second-spelling note is unchanged by
this PR.
- The write check now applies ruling 4 of objectstack-ai#5222 (same comparison class).
It does not apply rulings 1–3 (dotted path, declared-only, the
tenant-isolation column). An undeclared column is the RLS compiler's
field guard's job, and the dotted and tenant arms were not measured
here.
- **The `addDays` arm (corrected in rounds 2 and 3).** driver-sql's read
refuses an `addDays` reference with 400 when its base is not a `date` or
`datetime` column, or when its offset column is not numeric. The write
check does not always fail those closed, and three shapes can be
admitted on the write:
1. A text base holding a date-shaped string is shifted and compared,
because `addWholeDays` reads it with `Date.parse`.
2. A numeric base is shifted and compared, because `addWholeDays` adds
the offset to any finite number.
3. A text offset column holding a numeric string is read as a number of
days by `resolveDayOffset`.
This is pre-existing and not made worse here: the class rule runs first
and refuses every cross-class pair. What remains is a same-class pair
with an offset on a non-temporal base (text or numeric), or with a text
offset column. Read from the code; not measured.
carrier: domain:engine seat (objectstack-ai#6367) measures reach through the real
write door; a card follows only if a public door admits such a write
- A predicate update that matches zero rows judges no image, so it
completes as a no-op where the read answers 400. Nothing is stored.
- Seat ruling, claim 5868966379: the family gets an ADR-0087 D3 semantic
entry, registered in this PR.
- Seat ruling, claim 5868966379: execution note 3's driver-memory read
cell is accepted under objectstack-ai#15104 (closed, not planned). The memory read
still admits; the write refuses.
- objectstack-ai#20106 (`reclaimSpace` in `sql-driver.ts`) landed as e01d347 and is
merged here (cc0bf6e). This diff does not touch that region, and
driver-sql's full suite passes on the merged code.

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

---------

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants