Repository navigation
fix(formula): refuse an array comparand under $ne and in the equality slot (write-check bypass) - #19946
Conversation
… 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>
…rmula-face-refusal
📓 Docs Drift Check6 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
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
Contract reviewServed-tier: Reviewed and posted 2026-09-24T02:50Z by the at-tier review subagent the ① Derived judgmentsClosure — reachable paths. Closure — escapes still open, measured through the same real gate (forbidden insert ADMITTED and stored, allowed insert admitted, no warning logged): (a) Regression. Every shipped 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 Pins and ablation (re-measured). Removing both throw sites (blob 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 ② Semver level
③ Boundary flagsBlocking: ② — the changeset declaration (arm, level, banner, ADR-0087 marker), measured above; one changeset edit. Non-blocking:
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 Implemented-by: 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>
Contract reviewServed-tier: Reviewed and posted 2026-09-24T04:04Z by the at-tier review subagent the ① Derived judgmentsClosure — reachable paths, re-derived. Closure — same class, still admitting (measured admitted-and-stored through the same gate). (a) Regression. Callers enumerated repo-wide at this head: the three above, the 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, Pins and ablation (re-measured). Removing both throw sites (blob 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 ② Semver levelConsistent and correctly declared. ③ Boundary flagsBlocking: none. Non-blocking:
CI at this head: 46 check runs, 35 names after de-duplication on the latest Implemented-by: VERDICT: PASS |
…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>
…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>
…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>
…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>
Refs #19886
Clause-②: no (narrowing)
Rewritten short by the
domain:spec#5seat (2026-09-24T03:43Z; round 2 after record5806589396). Stage 2a of ruling5805254639(A) under seat ruling5805921577; stage 2b closes the card. The dev reports are on #19886 (5805865119stage 1,5806337850stage 2a,5807134185round 2).matchesFilterCondition(@objectstack/formula, compile face 5) now refuses an array comparand under$neand in the equality slot (a bare-array field spec, or$eq). It throwsINVALID_FILTER/ 400 before any record is judged, at any depth under$and/$or/$not. This face is the oneplugin-securityruns a row-levelcheckon. Stage 1 measuredchecks writtenrecord.f != [list],!= current_user.<membership array>and!(record.f == [list])admitting the forbidden writes and storing them.$in/$nin, scalars,null,Dateand{ $field }references are unchanged.Compile faces, both shapes (measured at this head; locations re-verified with the compile-surfaces grep)
$ne: [..]{ f: [..] }/$eq: [..]$not { f: [..] }applyFilterCondition(sql-driver.ts:15311; sqlite-wasm and turso local inherit)RemoteTransport.buildWhereSQL(remote-transport.ts:2632)compileScopedFilterToSql(read-scope-sql.ts:503)lowerAnalyticsWhere(filter-normalizer.ts:1508)matchesFilterCondition(matches-filter.ts:212), this PRmatchesHaving(having-filter.ts:292)memory-matcher.tsmatchtranslateFilter(mongodb-filter.ts:568)$ne: [..]through; mingo proxy selects every scalar row$nor; mingo proxy selects every rowA live
mongodwas 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)in(fail-closed). The positiverecord.f == [list]keeps its verdict (every write refused); its envelope moves from 403 to 400.@objectstack/formulaand@objectstack/plugin-securityatminor,@objectstack/specatpatchfor the ADR-0087 entryrls-predicate-array-comparand-refused(step 18).🤖 Generated with Claude Code
https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1