Repository navigation
fix(formula): refuse == / != against the bare current_user root at the CEL lowering - #20189
Conversation
…er == / != A CEL predicate comparing a field to the bare `current_user` root lowered against the whole caller context object, so a `check` written `record.owner_id != current_user` admitted every write. The root is now refused before resolution in both compile modes (the shape check reports it), and a variable resolving to an object is refused per request. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…osed on every leg Sole using, sole check (insert and by-id update), USING-only on the write pass, OR-sibling, explain and the delegator leg, each with a control spelled the way the refusal points. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…arand Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…under ADR-0087 One semantic entry under protocol major 18, the regenerated registry, and a BREAKING changeset (Clause-② no, narrowing) at minor for formula, plugin-security, plugin-sharing and lint, patch for spec. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
📓 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 32bc79d4c391c1ab9ace724cf6dc0213755ba5ee && git checkout 32bc79d4c391c1ab9ace724cf6dc0213755ba5ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e7824a445b1f7e33c91ada4b4f591db84ff76cc 60da27e665f8b15aa74609b5a83e72f5c35197d2 && git checkout -B drift-repro 9e7824a445b1f7e33c91ada4b4f591db84ff76cc && git merge --no-ff 60da27e665f8b15aa74609b5a83e72f5c35197d2
node scripts/docs-audit/affected-docs.mjs --json 9e7824a445b1f7e33c91ada4b4f591db84ff76cc |
Contract reviewServed-tier: Read: card #19959 with all 5 comments; #19886 ruling A (5805254639); PR #19947 record 5810400326; PR #20189 object, body, 8-file list, full diff (+714/-9), 5 commits, check-runs at the head (de-duplicated by name on latest started_at); at the head ref: cel-to-filter.ts whole, matches-filter.ts, rls-compiler.ts (context binding 453-513, shape gate 540-596, compile 680-730), security-plugin.ts (3140-3185, 7070-7095, 7315-7360), explain-engine.ts (1571-1624), bootstrap-declared-sharing-rules.ts (140-300), both lint rules (RLS 370-545, 740-800; sharing 425-525), rls-predicate.ts, rls.zod.ts, the new entry, registry.ts 6256-6346, the changeset, the 2c precedent changeset and entry on main, the headers and rule text of check-adr-0087-registration.mjs / check-changeset-no-major.mjs / check-empty-changeset.mjs and the workflow's WHICH LEVEL prose, .changeset/config.json; AGENTS.md whole. Ran (detached worktree at the head, pnpm install --frozen-lockfile, turbo build of the plugin-security/lint/plugin-sharing closures, all under os-verify-lock, VERDICT command-exit 0 each): a 70-source matrix on the built formula dist in value, bare ( ① Derived judgments(a) Refusal completeness. On the built dist at the head, 24 root spellings all answer (b) What newly refuses or changes answer. Three classes: (1) the bare root under (c) Fail-closed through the real plugin (driver-sql, better-sqlite3, real ObjectQL + SecurityPlugin with the guard set as fallback). The PR's 15 legs are green here, and my own 16 legs beside them: sole (d) Lints and skip reasons. Both rule suites green at the head (149). RLS rule: the shape verdict (e) Anything else in the published surface. No export added, removed or renamed ( ② Semver levelConsistent with the gates' stated rules and the precedent. ③ Boundary flagsBlocking: none. Non-blocking:
CI at this head: 35 check runs, 35 names after de-duplication on the latest PR body closing keywords: only Implemented-by: VERDICT: PASS |
…als the shape check cannot see (objectstack-ai#20210) Fixes objectstack-ai#19951 Clause-②: no `validateRlsPredicateEnforceability` judged an RLS predicate's shape with every `current_user` value replaced by a placeholder, so a predicate the runtime refuses because of a value passed `os validate` cleanly and enforced nothing. The rule's reference pass now reports two such classes as `rls-predicate-unenforceable` (`error`), each with a pasteable rewrite that uses the predicate's own field and key: 1. **A `current_user` value of the wrong type for its position.** Examples: a membership set under `==` / `!=` or handed to a string method, a one-value key on the right of `in`, the bare `current_user` object under `in` or a string method. The compiler refuses these on every request and `RLSCompiler` drops the policy. 2. **A lowered comparand the shared filter faces refuse by ruling.** A `null` list member or a `null` ordering bound. These compile, and the RLS layer never runs the faces on its own filter, so the backend answers with semantics the platform left undefined. Only `packages/lint/src/validate-rls-predicate-enforceability.ts`, its test and one changeset change. No runtime source is touched (formula lowering, `RLSCompiler` and the shared comparand faces are unchanged). ## Shape table (Zone 2, item 1) Lint readings come from a stack that declares the fields. Runtime readings come from a throwaway probe (never committed) built on the `rls-list-literal-comparand-fails-closed.test.ts` harness: real `SecurityPlugin` + ObjectQL + `SqlDriver` on better-sqlite3, caller `usr_member`, rows `r_open` (status open) and `r_closed` (status closed, reviewer usr_member), and `check` inserts of one `open` and one `closed` row. "main" is `560b724c`; "this PR" is `77060e6d`. | authored predicate | lint on main | lint, this PR | `using` read (runtime) | `check` write (runtime) | |:--|:--|:--|:--|:--| | `record.reviewer_id != current_user.org_user_ids` | 0 | unenforceable | 0 rows, `DENY (fail closed)` WARN | 403 / 403 | | `!(record.reviewer_id == current_user.org_user_ids)` | 0 | unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 | | `record.reviewer_id == current_user.positions` | 0 | unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 | | `record.reviewer_id in current_user.id` | 0 | unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 | | `record.reviewer_id in current_user` | 0 | unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 | | `record.reviewer_id.startsWith(current_user)` | 0 | unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 | | `record.reviewer_id.contains(current_user)` | 0 | unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 | | `record.reviewer_id.startsWith(current_user.org_user_ids)` | 0 | unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 | | `record.status in ['open', null]` | 0 | unenforceable | `r_open` (acts as `in ['open']`), no WARN | open admitted / closed 403 | | `record.status in [null]` | 0 | unenforceable | 0 rows, no WARN | 403 / 403 | | `!(record.status in ['open', null])` | 0 | unenforceable | **0 rows** (`r_closed` hidden), no WARN | open 403 / **closed admitted** | | `record.status > null` | 0 | unenforceable | 0 rows, no WARN | 403 / 403 | | `record.status <= null` | 0 | unenforceable | 0 rows, no WARN | 403 / 403 | | `record.reviewer_id != current_user` | unenforceable (since objectstack-ai#20189) | unenforceable (unchanged) | 0 rows, `uncompilable predicate` WARN | 403 / 403 | | CONTROL `record.status != ['closed', 'archived']` | unenforceable | unenforceable (unchanged) | 0 rows, `uncompilable predicate` WARN | 403 / 403 | | CONTROL `record.reviewer_id == current_user.id` | 0 | 0 | `r_closed` | per value | | CONTROL `!(record.reviewer_id in current_user.org_user_ids)` | 0 | 0 | `r_open` | admitted | | CONTROL (probe artefact) `current_user.email == 'member@example.com'` | 0 | 0 | every row for that caller | admitted | | CONTROL (probe artefact) `current_user.email == 'someone@else.com'` | 0 | 0 | 0 rows, `DENY (fail closed)` | 403 | Reading of Zone 2's assumption 1: - It holds for the type family: the runtime refuses those predicates on every request while the lint reports 0. - It is **falsified for the null family**. The null shapes are not refused on the RLS data path: they compile, reach the driver, and are answered with SQL null semantics. Within one policy, the `using` read and the `check` evaluator disagree on `!(record.status in ['open', null])`. The shared faces refuse the same comparands wherever those faces run: the engine seam on every caller-supplied filter, and `assertReadScopeComparandsRunnable` in analytics (read from code, `service-analytics/src/read-scope-sql.ts`; not re-measured here). The data-path gap is in the Acceptance notes and the report, not addressed here. - `f != current_user` was already moved by objectstack-ai#20189 and stays reported by the shape branch. ## The discriminator (Zone 2, item 2) **Type family: a one-reference type swap must make the same compile lower** (`attributeTypeFault`). When the reference pass's probe compile is refused `unsupported`, the rule rebinds ONE reference to a value of the other runtime type and compiles again. The candidates are each kernel key (scalar to list, or list to scalar) and the bare root (as a list that still carries the keys, then as one string). If that single swap makes the predicate lower, and the swapped value lands in a lowered field position, the refusal came from that reference's type. The type is not the probe's to choose: `ExecutionContextSchema` declares it for the kernel keys (read via `kernelKeyProbe`), and the root is always the whole context object. So the runtime refuses the same position on every request. A refusal no single swap cures reports nothing. That is the probe-artefact class. A constant comparison with no field, such as `current_user.email == 'ops@acme.com'`, folds on the value: it lowers to "no restriction" for the caller it names and is refused for everyone else. The swap value begins with the probe string and never equals it, so ordering folds and equality folds stay refused under every swap. The pinned control asserts `compileCelToFilter` really answers `{ ok: true, filter: {} }` for the named caller and `unsupported` for another, then asserts the lint stays silent on both clauses, alone and inside `|| record.owner_id == current_user.id`. **Null family: the verdict of the same two faces analytics runs** (`sharedFaceRefusal`): `assertListComparandShapes` + `normalizeFilterComparandTypes` from `@objectstack/spec/data`, run on the probe-compiled filter and graded by the refusal's `code` (`INVALID_FILTER`), never its prose. It is not a probe artefact, because the probe binds only strings and string lists, which both faces accept by construction; a probe carrying a nested record is not judged. The face-probe control `record.assigned_to_id in current_user.org_user_ids` stays clean. Conservative by construction, and recorded in the docblock: a predicate with two type faults (no single swap cures it) stays silent. ## New author-facing text (quoted verbatim; `KEY` stands where the shipped string spells an angle-bracketed placeholder) Type-family message: ``RLS CLAUSE `SOURCE` passes the shape check, but the compiler refuses it for the value `VARIABLE` holds on every request (COMPILER_DETAIL). HOLDING_SENTENCE `` + the existing clause consequence with a new preamble: > so `RLSCompiler` DROPS the policy on EVERY request (a request that resolves no value drops it too). The shape check passes, so the "uncompilable predicate" WARN is never logged; the only signal is a per-request "DENY (fail closed)" WARN, emitted only when nothing else applicable compiles, and nothing reports it at authoring time. Holding sentences: > `current_user.org_user_ids` is a membership set: `ExecutionContext` declares it, and the kernel resolves it, as a LIST on every request, so no request can ever put one value in this position. > `current_user.id` holds ONE value: `ExecutionContext` declares it, and the kernel resolves it, as a scalar on every request, so no request can ever put a list in this position. > `current_user` alone is the whole caller context object on every request, never one value and never one set. Hint leads, one per holding: > `==` / `!=` compare ONE value and a string method takes ONE string; a membership set is tested with `in` — "one of these" is `x in set`, "none of these" is `!(x in set)`. > `in` tests membership in a SET; one value is compared with `==` / `!=`. > `current_user` is the caller context, not a value: name the key that holds what you mean. Each hint lead is followed by `In this predicate: ` and the rewrites, for example: - ``replace `record.reviewer_id != current_user.org_user_ids` with `!(record.reviewer_id in current_user.org_user_ids)` `` - ``replace `record.reviewer_id == current_user.org_user_ids` with `record.reviewer_id in current_user.org_user_ids` `` - ``replace `record.reviewer_id in current_user.id` with `record.reviewer_id == current_user.id` (and `!(record.reviewer_id in current_user.id)` with `record.reviewer_id != current_user.id`); for a set, name a membership key: `record.reviewer_id in current_user.accessible_org_ids` / … `org_user_ids` / … `positions` `` - ``replace `record.reviewer_id in current_user` with the key that holds the set: `record.reviewer_id in current_user.accessible_org_ids` / … `org_user_ids` / … `positions`, or an app-staged §7.3.1 set `record.reviewer_id in current_user.KEY` `` - ``replace `record.reviewer_id.startsWith(current_user)` with the key that holds the string: `record.reviewer_id.startsWith(current_user.email)` / … `(current_user.id)` / … `(current_user.organization_id)` `` The key lists are derived from the declared types (`kernelKeysHolding`) and are not transcribed. Null-family message: ``RLS CLAUSE `SOURCE` lowers, but to a comparand the platform's shared filter check refuses (FACE_FIRST_SENTENCE). `` + this consequence: > The RLS layer does not pass its own filter through that check, so the policy is NOT dropped: the refused comparand reaches the backend, which answers it with semantics the platform has ruled undefined — backends disagree, and on the SQL drivers a `null` member or bound matches no row, so a negated list holding `null` or an ordering against `null` can admit NOTHING. One policy can even disagree with itself: a `using` read on a SQL driver hides rows that its `check` evaluator admits on write. Every analytics query over the object is refused outright, because its read-scope compiler runs the same check. Null-family hint: > `null` has no place inside a list or opposite an ordering operator — the platform refuses both in every filter it is sent, because no two backends agree on what they match. Test for no value with `== null` and for a value with `!= null`: replace `record.status in ["open", null]` with `(record.status in ["open"] || record.status == null)` to keep matching a missing value, or drop the member: `record.status in ["open"]`. Its other rewrites are ``replace `record.status in [null]` with `record.status == null` `` and ``replace `record.status > null` with `record.status != null` (has a value) or `record.status == null` (has none), or compare against a real bound``. Every rewrite is a local replacement, so it stays correct under any enclosing `!`, `&&` or `||`. If the face refuses something the locator cannot place, the hint falls back to the existing lowerable-subset sentence. The existing `consequence(clause)` text for uncompilable predicates is byte-identical; it only became parameterised. ## Pins - **Flipped:** `accepts every kernel-resolved key in BOTH positions` asserted `[]` for `owner_id == current_user.KEY` and `owner_id in current_user.KEY` for every kernel key, which wrote the blind spot down as a pin. It is now `never reports a kernel-resolved key as UNKNOWN — in its own position it is clean, in the other it is a type fault`. It asserts, per key and by its runtime type (the table hoisted to `KERNEL_KEY_RUNTIME_TYPE`), `[]` in the key's own position and exactly `[rls-predicate-unenforceable]` in the other, and never `rls-predicate-unknown-user-variable`. - **Pin sweep, repo-wide:** outside `packages/lint`, no test asserts an `rls-predicate-*` id. The one hit is prose in `packages/qa/dogfood/test/authz-conformance.matrix.ts`. The shape-branch pins (literal list, bare root, `size()`, SQL `AND`, over-budget), the verdict-parity corpus and the disjointness test are unchanged and green. A genuinely illegal shape keeps its refusal verbatim. - **New block** `the refusals the SHAPE check cannot see are reported (objectstack-ai#19951)`, table-driven over both clauses: - 8 type-fault rows and 7 null rows; each asserts `isSupportedRlsExpression(source) === true` (why it was silent), then exactly `[[rls-predicate-unenforceable, permissions[0].rowLevelSecurity[0].CLAUSE]]`, and that the hint contains the pasteable rewrite. - Message substance for each family: a DROP with `RLS_DENY_FILTER` on `using` and `PermissionDeniedError` on `check`, versus "NOT dropped". - Every site rewritten in one finding. - Controls: the literal list keeps one finding, and every spelling the hints point at is clean on both clauses. - The probe-artefact control. - Reach through `runAuthoringRules('validate', …)`. ## Verification (at `77060e6d`, the merge of `origin/main` `0bd11261`) - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 --reporter=verbose src/validate-rls-predicate-enforceability.test.ts`: `Tests 120 passed (120)`. - `pnpm --filter @objectstack/lint test`: `Test Files 108 passed (108)`, `Tests 4204 passed (4204)`. This is the whole package, the rule's only home. - `pnpm --filter @objectstack/lint typecheck`: exit 0. `tsc --noEmit`, then `check:test-typecheck` `OK` against `tsconfig.test.json`. `--listFiles` includes the test file, and it carries 0 type errors; the 6 ledgered test-layer errors live in two other files. - `pnpm --filter @objectstack/lint build`: exit 0. - **Ablations**, one-time and never left in the tree. Each went through `node scripts/ablation-replace.mjs` in WRAP mode, with the anchor hit once (1 to 0), a new blob, and the restore proven (blob == HEAD `8589ec8c`, `git diff HEAD` empty). The subject is imported from `src`, so no `dist` step applies. - **A.** `attributeTypeFault(...)` replaced by `?? FAKE_FAULT` (every refusal reported). Result: 2 failed / 118. Red: the probe-artefact control, and `§7.3.1 … a key used in BOTH positions takes the membership answer`. That is the discriminator is load-bearing. - **B.** `sharedFaceRefusal` made to return `null`. Result: 8 failed / 112, the 7 null rows plus "NOT reported as a drop". - **C.** The swap may never land (`sites.length > 0` becomes `< 0`). Result: 12 failed / 108: the 8 type rows, the flipped kernel-key pin, the message test, the every-site test, and the `os validate` reach. - **Census (Zone 2, item 4):** the BASE rule (`git show 560b724:…`, blob `cb850a12`, as a throwaway module) and this PR's rule were run in one process over every `using` / `check` literal. Outside tests: 126 predicates, 77 of them `current_user` (the note's control of 77 reproduced), **0 changed**. Including every test fixture: 327 predicates, 205 `current_user`, **0 changed**. The 4 template-literal predicates the extraction skipped (`${ownerField} == current_user.id` and similar) and the one JSON fixture are all type-correct. No in-repo producer is newly flagged. - **Interplay (Zone 2, item 5):** the null-family check calls the shared face `assertListComparandShapes` (`packages/spec/src/data/filter-comparand-shape.ts`) as it stands on `main`. objectstack-ai#19886 stage 2b edits that file's `$ne` / array arm. The lint only ever hands it filters the compiler already accepted, and the compiler refuses `==` / `!=` against a list, so a `$ne` array never reaches the face from here. Measured on `main`; not waited on. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived at `77060e6d` (59 commands, a superset of the lead's 52 once the changeset exists), all run. Reconciled with `--ran`: `59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN`; all 57 exited 0. NOT MEASURED: `pnpm check:dual-build-cjs-loads` and `pnpm check:type-check-debt`. Both exit 3, PREREQUISITE NOT MET: they need every package's `dist/` (a whole-repo build), which is CI's. - **Consumer suites** (`cli`, `mcp`, `metadata-protocol`, `platform-objects`, `cloud-connection`): not run locally and declared to CI. The public surface is byte-identical: no export, no wire shape. The census above measures every in-repo predicate, fixtures included, at 0 flips, and no suite outside `packages/lint` asserts an `rls-predicate-*` id. - **Dogfood:** the showcase predicates (`owner == current_user.email` and similar) are inside the 0-flip census. ## Choices settled here (four axes) 1. **Report the null family although the data path does not refuse it.** - **Business need:** measured. `!(record.status in ['open', null])` hides every `closed` row on read while its own `check` admits the write, and an AI writing a blocklist "neither closed nor missing" lands exactly here. - **Long-term soundness:** the contract (the null rulings) already refuses these comparands at every face that judges them. The lint agrees with the contract rather than with the one path that skips it. - **Guarding against AI metadata mistakes:** a loud authoring refusal, with a rewrite, instead of backend-dependent silence. - **Startup scope:** no new capability, no runtime change. - Not reporting it would leave "validated clean, behaves per backend" in place. 2. **Same id (`rls-predicate-unenforceable`), not a new one.** Zone 1's direction names the id for the compile-refusal family, and the null family's fix is the same kind of edit (rewrite inside the enforceable subset). An allowlist keyed on the id already means "this predicate enforces nothing", and a new public id would widen `@objectstack/lint`'s exported surface for no new decision. The message says which class it is. 3. **The discriminator** as above: asked of the compiler, the way `userVariableIsScalarPositioned` already asks it, and never by grading prose. ## Acceptance notes - **Runtime gap (reported, not addressed here):** the RLS data path does not run the shared comparand faces on its own compiled filter. A `null` list member or `null` ordering bound in a policy reaches the driver and the `check` evaluator, which disagree. - Measured through the real plugin on driver-sql: `!(record.status in ['open', null])` as `using` reads 0 rows (the `closed` row is hidden). As `check`, it admits a `closed` insert and refuses an `open` one. - `record.status in ['open', null]` reads `r_open` only. - `record.status > null` reads 0 rows and refuses both inserts. - The contract's own text calls these cells "constructively unreachable through the compile face" (`driver-memory/src/memory-matcher.ts`), and they are reachable through RLS. - Handed to the PM seat for the filing gate; this PR changes no runtime source. - `packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts` says "The authoring lint reports a list literal as rls-predicate-unenforceable; a membership set holds its value only per request, so that form is refused at request time." That is still true, but no longer complete: the lint now reports the membership-set form too. Carrier: none. - An ordering against a membership set (`record.f > current_user.positions`) lowers to `$gt` over a list. Neither the compiler nor the faces refuse it, and its runtime answer was not exercised here. Carrier: none. - Conservative limits, by design: a predicate with two type faults stays silent. So does an unknown app key in a scalar position when a later type fault masks it; that is `userVariableIsScalarPositioned`'s existing behaviour. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…wering and the write-check evaluator (objectstack-ai#20259) Part of objectstack-ai#19886 Clause-②: no Stage 2d of objectstack-ai#19886. It covers the three same-class leaks recorded in `5806608550`, each measured before any edit at every door, plus two members of the same family that the same guard carries: (d) and (e), from seat 4's note `5853764742`. The changeset declares `Clause-②: no (narrowing)`, BREAKING, and registers `cel-predicate-one-value-comparand-refused`. This PR does not close the card: the remainder below stays open. ## What changes **`packages/formula/src/cel-to-filter.ts`** (the CEL lowering that every RLS policy, declared sharing rule and the authoring lint compile through). Each shape below now fails with reason `unsupported`: - (c) a **list under an ordering operator** (`>`, `>=`, `<`, `<=`), either side: a list literal, or a `current_user` key that resolves to an array. - (b) an **`in` list whose member is itself a list**, written or resolved. - (c) and (e) a **list operand on the constant-fold branch**, where no field is involved. It previously folded to "no restriction". - (d) the **`current_user` root, or a key resolving to an object, under an ordering operator**. objectstack-ai#20189's guard now covers all six comparisons. Literal forms are also refused by the shape check (`isPushdownableCel`, `isSupportedRlsExpression`), so the lint reports them. Resolved values are refused per request. **`packages/formula/src/matches-filter.ts`** (the evaluator the RLS write check runs): - An array under `$gt` / `$gte` / `$lt` / `$lte`, and an array member of `$in` / `$nin`, are refused by the up-front shape walk, before any record is judged. - (a) A `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose compared column holds a list or an object on the record being judged is refused. This covers either side, and the `addDays` form judged on its base column. Every refusal gives 2a's envelope (`INVALID_FILTER` / 400) with one widened sentence. (a) cannot be refused at the lowering. The lowering sees the predicate's text, not the object's field types: `RlsFieldGuard` carries column names only (`getObjectFieldNames` returns a set of strings). So Zone 2 item 2's premise, "the CEL lowering knows the field's schema", is false. The evaluator is the first face that sees the values. It pulls the write check onto driver-sql's existing rule (objectstack-ai#5222 `crossFieldComparisonClass`), which refuses a cross-field comparison against any JSON-stored or `multiple` column on either side. ## Measured cells A real stack: `SecurityPlugin`, ObjectQL, driver-sql (better-sqlite3) and driver-memory, a `json` column and a `multiple` lookup. Doors measured for each cell: - the write `check` (forbidden insert and allowed insert, with what was stored); - the `using` read; - the CEL compile (`isPushdownableCel` and the lint rule `validateRlsPredicateEnforceability`); - the analytics read scope (`getReadFilter` into `compileScopedFilterToSql`, and into `assertReadScopeComparandsRunnable` + `engine.aggregate`). "Before" is `origin/main` `3cb84d08`, measured before any edit. The table was written to disk first. "After" is this branch. "Base on merged main" is this branch with both faces ablated at once, which is the formula of `origin/main` `0d3ec471`, including objectstack-ai#20212's comparand faces in the RLS compiler. | cell | predicate | door | before (3cb84d0) | base on merged main | after | |---|---|---|---|---|---| | a1 a | `record.status != record.tags` | compile | lowers | lowers | lowers | | | | check sql F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 400 / 400 | | | | check memory F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 400 / 400 | | | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | INVALID_FILTER/400 | | | | using memory | all rows | all rows | all rows | | | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | | | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows | READ_SCOPE_COMPILE_FAILED/500; oql all rows | READ_SCOPE_COMPILE_FAILED/500; oql all rows | | a2 a | `!(record.status == record.tags)` | compile | lowers | lowers | lowers | | | | check sql F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 400 / 400 | | | | check memory F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 400 / 400 | | | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | INVALID_FILTER/400 | | | | using memory | all rows | all rows | all rows | | | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | | | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows | READ_SCOPE_COMPILE_FAILED/500; oql all rows | READ_SCOPE_COMPILE_FAILED/500; oql all rows | | a5 a(mirror) | `record.tags != record.status` | compile | lowers | lowers | lowers | | | | check sql F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 400 / 400 | | | | check memory F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 400 / 400 | | | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | INVALID_FILTER/400 | | | | using memory | all rows | all rows | all rows | | | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | | | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows | READ_SCOPE_COMPILE_FAILED/500; oql all rows | READ_SCOPE_COMPILE_FAILED/500; oql all rows | | b1 b | `!(record.status in [['closed', 'archived']])` | compile | lowers | lowers | refused (shape + lint) | | | | check sql F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 403 / 403 | | | | check memory F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 403 / 403 | | | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows | | | | using memory | all rows | all rows | no rows | | | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | deny scope; oql no rows | | | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows | READ_SCOPE_COMPILE_FAILED/500; oql all rows | deny scope; oql no rows | | b3 b(var) | `!(record.status in current_user.nested_set)` | compile | lowers | lowers | refused per request | | | | check sql F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 403 / 403 | | | | check memory F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 403 / 403 | | | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows | | | | using memory | all rows | all rows | no rows | | | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 | deny scope; oql no rows | | | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows | READ_SCOPE_COMPILE_FAILED/500; oql all rows | deny scope; oql no rows | | c1 c | `record.status > ['m']` | compile | lowers | lowers | refused (shape + lint) | | | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / 403 | | | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / 403 | | | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows | | | | using memory | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows | | | | analytics sql | binds [["m"]]; oql INVALID_FILTER/400 | binds [["m"]]; oql INVALID_FILTER/400 | deny scope; oql no rows | | | | analytics memory | binds [["m"]]; oql INVALID_FILTER/400 | binds [["m"]]; oql INVALID_FILTER/400 | deny scope; oql no rows | | c3 c | `record.status <= ['m']` | compile | lowers | lowers | refused (shape + lint) | | | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / 403 | | | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / 403 | | | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows | | | | using memory | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows | | | | analytics sql | binds [["m"]]; oql INVALID_FILTER/400 | binds [["m"]]; oql INVALID_FILTER/400 | deny scope; oql no rows | | | | analytics memory | binds [["m"]]; oql INVALID_FILTER/400 | binds [["m"]]; oql INVALID_FILTER/400 | deny scope; oql no rows | | c4 c(const) | `current_user.org_user_ids > 'a'` | compile | lowers to {} (allow-all) | lowers to {} (allow-all) | refused per request | | | | check sql F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 403 / 403 | | | | check memory F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 403 / 403 | | | | using sql | all rows | all rows | no rows | | | | using memory | all rows | all rows | no rows | | | | analytics sql | no WHERE (all rows); oql all rows | no WHERE (all rows); oql all rows | deny scope; oql no rows | | | | analytics memory | no WHERE (all rows); oql all rows | no WHERE (all rows); oql all rows | deny scope; oql no rows | | c5 c(var) | `record.status > current_user.org_user_ids` | compile | lowers | lowers | refused per request | | | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / 403 | | | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / 403 | | | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows | | | | using memory | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows | | | | analytics sql | binds [["usr_member"]]; oql INVALID_FILTER/400 | binds [["usr_member"]]; oql INVALID_FILTER/400 | deny scope; oql no rows | | | | analytics memory | binds [["usr_member"]]; oql INVALID_FILTER/400 | binds [["usr_member"]]; oql INVALID_FILTER/400 | deny scope; oql no rows | | d1 d(seat4) | `record.reviewer > current_user` | compile | lowers | lowers | refused (shape + lint) | | | | check sql F/A | ADMITTED+stored / ADMITTED+stored | 403 / 403 | 403 / 403 | | | | check memory F/A | ADMITTED+stored / ADMITTED+stored | 403 / 403 | 403 / 403 | | | | using sql | INVALID_FILTER/400 | no rows | no rows | | | | using memory | no rows | no rows | no rows | | | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql READ_SCOPE_COMPILE_FAILED/500 | deny scope; oql no rows | deny scope; oql no rows | | | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql READ_SCOPE_COMPILE_FAILED/500 | deny scope; oql no rows | deny scope; oql no rows | | e1 e(seat4) | `current_user.org_user_ids != 'x'` | compile | lowers to {} (allow-all) | lowers to {} (allow-all) | refused per request | | | | check sql F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 403 / 403 | | | | check memory F/A | ADMITTED+stored / ADMITTED+stored | ADMITTED+stored / ADMITTED+stored | 403 / 403 | | | | using sql | all rows | all rows | no rows | | | | using memory | all rows | all rows | no rows | | | | analytics sql | no WHERE (all rows); oql all rows | no WHERE (all rows); oql all rows | deny scope; oql no rows | | | | analytics memory | no WHERE (all rows); oql all rows | no WHERE (all rows); oql all rows | deny scope; oql no rows | | ctl-a control | `record.status != record.reviewer` | compile | lowers | lowers | lowers | | | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / ADMITTED+stored | | | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / ADMITTED+stored | | | | using sql | archived,open | archived,open | archived,open | | | | using memory | all rows | all rows | all rows | | | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql archived,open | READ_SCOPE_COMPILE_FAILED/500; oql archived,open | READ_SCOPE_COMPILE_FAILED/500; oql archived,open | | | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows | READ_SCOPE_COMPILE_FAILED/500; oql all rows | READ_SCOPE_COMPILE_FAILED/500; oql all rows | | ctl-b control | `!(record.status in ['closed', 'archived'])` | compile | lowers | lowers | lowers | | | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / ADMITTED+stored | | | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / ADMITTED+stored | | | | using sql | open | open | open | | | | using memory | open | open | open | | | | analytics sql | binds ["closed","archive; oql open | binds ["closed","archive; oql open | binds ["closed","archive; oql open | | | | analytics memory | binds ["closed","archive; oql open | binds ["closed","archive; oql open | binds ["closed","archive; oql open | | ctl-c control | `record.status > 'm'` | compile | lowers | lowers | lowers | | | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / ADMITTED+stored | | | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored | 403 / ADMITTED+stored | | | | using sql | open | open | open | | | | using memory | open | open | open | | | | analytics sql | binds ["m"]; oql open | binds ["m"]; oql open | binds ["m"]; oql open | | | | analytics memory | binds ["m"]; oql open | binds ["m"]; oql open | binds ["m"]; oql open | ## Scope beyond the three recorded leaks, declared (d) `record.reviewer > current_user` and (e) `current_user.org_user_ids != 'x'` are not in `5806608550`. Seat 4's note `5853764742` reported them on this card, and the claim names only the three leaks. I folded them in under the bounded in-place rule, because all four conditions hold: - ① They are the same defect class: a comparand that is not one value. - ② The fix is mechanical, already shaped, and pinned. (e) is the constant-branch half of the guard (c) needs. Leaving `==` / `!=` out of it would have written a hole into a guard that refuses the same list under `>`. (d) is objectstack-ai#20189's guard with its `==` / `!=` condition removed, and its sentence already names the operator. - ③ `cel-to-filter.ts` is on this claim. - ④ The gates and pins are the same. Both were measured live on `3cb84d08`. (e) was allow-all on the write check, the read and analytics. (d) admitted and stored on the write check. On the merged main, objectstack-ai#20212's faces already drop (d)'s policy; (e) still folds to allow-all there. ## Remainder, still open on this card 1. **(a) at the read door on driver-memory.** driver-memory does not evaluate a `{ $field }` reference at all: neither its source nor its tests handle `$field`. A `using` read under `record.status != record.tags` returns every row, and so does the scalar control `record.status != record.reviewer`. This is a driver-memory source change, outside this claim (drivers are test side only). The analytics ObjectQL strategy on driver-memory inherits it. driver-sql refuses the read (400, objectstack-ai#5222). 2. **(a) at the compile door (`os validate`).** A field compared with a `json` / `multiple` field still lowers and is not reported at authoring. Judging it needs field types: either the lint rule, whose object graph has them (objectstack-ai#20158 holds that file), or types threaded into the RLS compile (the engine lane's files). This PR changes neither. ## Pins New and flipped pins: - `packages/formula/src/cel-to-filter-one-value-comparand.test.ts` (new). Every refused literal form, refused per request and by the shape check. Every resolved form, refused per request with the shape check passing; the detail names the variable path and no value. The remedy of each refusal. Neighbours unchanged: one bound, a flat `in`, a scalar fold, and field-to-field `==` / `!=`, which still lower. - `packages/formula/src/matches-filter-array-comparand.test.ts`. 2a's table-driven `SHAPES` gains `$gt` / `$gte` / `$lt` / `$lte` with an array and `$in` / `$nin` with an array member. Each runs at every depth, with the empty array, and for every record. A new `describe` covers the per-record `{ $field }` refusal: both sides, negated, ordering, an object column, the offset form, and under `$or`. Its control is that the same filter over one-value columns is compared, not refused, and the message withholds columns and values. - **Flipped by the pin-sweep rule:** `a { $field } reference is judged by what was AUTHORED, not by what it resolves to` asserted `{ tags: { $eq: { $field: 'tags' } } }` → `true` over a list-holding column. It now asserts `INVALID_FILTER` / 400. Its two scalar lines stay, moved to a scalar-control case. - `packages/plugins/plugin-security/src/rls-one-value-comparand-fails-closed.test.ts` (new). Table-driven: leak × door × driver (driver-sql, driver-sqlite-wasm). Every leak row refuses both inserts and stores nothing. It reads no rows, or gets the driver's own refusal for (a). A dropped policy hands `getReadFilter` the deny scope. The one-value controls are accepted and enforced. Pin sweep ① (the error code and message swept repo-wide for same-meaning pins): - `rls-check-array-comparand-refusal.test.ts` (2a), `rls-list-literal-comparand-fails-closed.test.ts` (2c), `rls-variable-root-comparand-fails-closed.test.ts` (objectstack-ai#20189), and objectstack-ai#20212's `rls-compiled-comparand-faces.test.ts` and `rls-null-comparand-fails-closed.test.ts` stay green unchanged. - `cel-to-filter-list-comparand.test.ts` and `cel-to-filter-variable-root-comparand.test.ts` stay green; none of them pins an ordering or constant-branch list. - **No lint pin's verdict moves**: lint's full suite, 109 files / 4232 tests, is green with the change. The lint findings for the new shapes move from clean to `rls-predicate-unenforceable`, and no pin asserts them. ## Ablation (one-shot proof that each pin can fail) Ablations ran from the committed merged head `4ade1b94`. Each mutation went through `scripts/ablation-replace.mjs` (the anchor must hit; on-disk counts are verified). `ablation-dist-preflight.mjs` then confirmed the marker had reached formula's `dist/`, which plugin-security reads, before any result was read. The script armed `trap … EXIT INT TERM`, with absolute paths, as its restore. - **Evaluator face cut.** Three arms were neutralised: the per-record `{ $field }` check, the four ordering operators in `ARRAY_REFUSED_OPERATORS`, and the `$in` / `$nin` member walk. Readings: 3 markers on disk and 0 anchors left; the marker was in 2 built files. Result: formula pins **57 failed** / 63 passed, and the plugin-security pin **8 failed** / 48 passed (exactly the (a) write rows, 4 leaks × 2 drivers). - **Lowering face cut.** Six arms were neutralised: the list-member refusal, the list refusal under ordering operators, both constant-branch list refusals, and objectstack-ai#20189's root and object refusals narrowed back to `==` / `!=`. Readings: 4 markers on disk (two arms are re-spelled conditions) and 0 anchors left; the marker was in 2 built files. Result: formula pins **25 failed** / 95 passed, and the plugin-security pin **28 failed** / 28 passed (every (b), (c) and (e) row, write and read, on both drivers). - The (d) rows stayed green, as expected on the merged head: objectstack-ai#20212's comparand faces in the RLS compiler drop that policy independently. The (d) formula pins went red, tying the lowering arm to its pin. - **Both faces cut together** gives main's formula on the merged tree. That is the "base on merged main" column above, read from the real stack. - **Restores.** `git checkout HEAD` on the absolute paths. `git diff HEAD` was 0 bytes and the blob equals the HEAD blob (`matches-filter.ts` `1fb6f28c22e6`, `cel-to-filter.ts` `4c6ef215947`). formula was rebuilt, and the preflight `--absent` found the marker in none of 6 built files, with a clean tree. The final re-run was green: formula pins 120/120 and the plugin-security pin 56/56. ## Refusal texts (every changed or new one, quoted) Lowering, ordering (new branch of `arrayComparandRefusal`): ```text `OP` orders against one value, but its comparand is a list literal — compare against one bound (`record.f OP 'm'`), write a range as two comparisons joined by `&&`, and test membership with `record.f in [...]` ``` Lowering, list member (new `listMemberRefusal`): ```text `in` compares the field with each member of its list, one value at a time, but member N of the list literal is itself a list — flatten it: `record.f in ['a', 'b']`, not `record.f in [['a', 'b']]` ``` The resolved form reads "member N of `current_user.KEY` resolves to a list". The `==` / `!=` sentence of 2c and the object/root sentence of objectstack-ai#20189 are unchanged; the latter now fires under ordering operators too. Evaluator (`arrayComparandError`, one sentence for the class, widened from 2a's): ```text A single-value comparison in this filter received an array as its comparand: an array under "$ne", or an array in the equality position ({ "field": [ ... ] } or "$eq"), or under an ordering operator ("$gt", "$gte", "$lt", "$lte"), or as a member of an "$in" / "$nin" list, or a column on either side of a { "$field" } comparison that holds a list or an object. A list is not one comparable value. For "one of these values" use "$in", and for "none of these values" use "$nin" — the list operators the filter protocol declares; an ordering comparison takes one bound. It is refused rather than evaluated, because this evaluator compares strictly and no stored value ever equals an array: "$ne" matched EVERY record, and so did a negated equality or a negated "$in", which on a row-level write check admitted every write the check was written to refuse, and an ordering operator compared the array as a string. The field, the operator and the value are withheld from this message because the filter may be an access policy the caller did not write; in a row-level policy, look for a comparison against a list literal, a current_user membership key, or a json or multiple field, and rewrite it with "in" (for example "!(record.status in ['closed', 'archived'])"). ``` ## Census (Zone 2 item 4) Zero producers carry any of the shapes: - objectstack `3cb84d08`: 123 literal `using` / `check` strings in non-test `packages/**`, 63 lines of them in `default-permission-sets.ts`, and 3 in `examples/**`. - cloud `origin/main` `96eb092`: 0 authored predicates. A shape-wide scan of all non-test strings finds one field-vs-field line, `examples/app-showcase/src/ui/actions/predicate-matrix.action.ts:188`. It is an action predicate compared against an element (`record.f_lookups[0]`) and not an RLS predicate. There are 0 nested `in` lists and 0 orderings against a list. A positive control confirms the detector fires on each shape. No D2 conversion is owed. ## Zone 2 answers 1. Measured. Nothing was already closed on `3cb84d08`. On the merged main, objectstack-ai#20212's faces drop (d) only. 2. **Falsified premise.** The lowering holds no field schema, so (a) is judged at the evaluator. The CEL route to a list-resolving `{ $field }` is `record.status != record.tags`; it is reachable and refused with 2a's sentence, widened. 3. (b) and (c) run through the lowering and the evaluator. The shared face (`normalizeFilterComparandTypes`) does not judge an array under an ordering operator ("Arrays outside `$in`/`$nin`/`$between`" are left to the drivers) and let `$in: [[...]]` through, measured on the analytics scope. No leak here needed a new shared-face arm, so nothing is serial after objectstack-ai#20116. 4. See the census: 0 carry any shape. 5. The three `$not` JSON-only shapes remain unreachable from every authored surface. CEL never lowers a bare `$not` over a field-less `$ne`, an empty `$or`, or a nested relation value, and the only production callers of `matchesFilterCondition` are the RLS write check, the tenant wall (platform-derived filters), explain (RLS-compiled filters) and objectql `having` (a `{ $field }` probe). ## Tests and gates All builds and tests ran through `os-verify-lock.sh`, with exit codes captured before any pipe. **Merged head `4ade1b94`** (lock verdict: command-exit 0, held 717s on a shared box). The only later commit, `39379b9b`, edits changeset prose. - `@objectstack/spec` build, then `check:generated`: all 15 artifacts current. - formula: 39 files / 1127 tests passed; `typecheck` exit 0. - plugin-security full suite: 140 files / 2895 tests passed; `typecheck` exit 0. - lint full suite: 109 files / 4232 tests passed. - plugin-sharing full suite passed. **Pre-merge head `bfd409ee`**: - plugin-security 138 files / 2847 passed. - lint 109 / 4232 passed. - plugin-sharing 37 / 913 passed. - objectql `having` pair: 2 files / 176 passed. - driver-sql cross-field and formula-consumer files: 6 files / 445 passed, 2 skipped (live PG / MySQL). **Generated artifacts:** `check:migration-registry`, `check:spec-changes` and `check:upgrade-guide` were current after regeneration, on both sides of the merge. `registry.ts` was regenerated after the merge: 265 semantic entries, carrying this entry and main's `inline-grid-column-currency-scale-refused`. **eslint** on the 7 touched `.ts` files: 7 files, 0 errors, 0 warnings, counted from `eslint --no-inline-config --format json`. The population comes from `eslint.config.mjs`, whose header states it never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. `packages/spec/**` sits in that config's ignores for the generic block and is linted by its own block. **Gates:** `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` was re-derived on the actual change set at `39379b9b` (90 commands; the same set at the merged head `4ade1b94`). Every command ran, and its exit code was recorded before any pipe. `--ran` reads: **90 derived, 87 run at exit 0, 3 NOT MEASURED, 0 unrun**. The 3 NOT MEASURED are each a recorded exit 3, PREREQUISITE NOT MET: `check:dual-build-cjs-loads` and `check:type-check-debt` need every workspace package's `dist/` (45 unbuilt here), and `check:i18n` needs the built CLI. None of them is a verdict on this diff; CI runs them after its full build. The 87 include: - `check-adr-0087-registration --base origin/main`: 1 declared-breaking changeset, `registered cel-predicate-one-value-comparand-refused`. - `check:nul-bytes`: 9704 files, no raw control bytes. - `check:doc-authoring`. - `check:keyed-text-bounds`. - the spec `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:api-surface`, `check:authorable-surface` and `check:liveness`. CI convergence is not awaited; it is in progress at report time. Not measured here: live `mongod`, PostgreSQL and MySQL; the sharing bootstrap skip, which is read from `celToFilterOutcome` and not driven live. ## Acceptance notes (not filed) - 2a's unreleased changeset `.changeset/19886-formula-array-comparand-refused.md` and its entry `rls-predicate-array-comparand-refused` still say `{ $field }` references "evaluate exactly as before". That is now false for a list-holding column. This PR does not edit them, because editing another stage's changeset is a deliberate correction the seat rules on; this changeset states the delta instead. - objectql `having` with a `{ $field }` against a list-holding groupBy column now refuses 400 on driver-memory, where the row carries the list. driver-sql rows carry the stored JSON text and compare as before (measured, both drivers). - The analytics native compiler (`compileScopedFilterToSql`) bound an array as one SQL parameter for `$gt: [...]` before this change. Nothing reaches it now because the lowering drops the policy, but the compiler itself still would for a host-supplied scope. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19959
Clause-②: no (narrowing)
A row-level or sharing CEL predicate that compares with
==/!=against the barecurrent_userroot is now refused at the CEL lowering (unsupported). It used to lower against the whole caller context object, so acheckwrittenrecord.owner_id != current_useradmitted every write it was written to refuse.Inherits #19886 ruling A (
5805254639) with its reason: the published$eq/$necontract admits a literal or a{ $field }reference, and ADR-0058 D2 declares the operand opposite a field as a literal, acurrent_userscalar or a pre-resolvedcurrent_userset. The whole context object is none of them, so the refusal pulls back to the declared contract.What changed
packages/formula/src/cel-to-filter.ts: two guards under==/!=, applied wherever an operand is resolved (opposite a field, and on either side of a constant comparison):isPushdownableCel,isSupportedRlsExpression) reports it at authoring time, and every compile gives the sameunsupportedwhatever it binds, the sharing seeder'svariables: {}included;Date) is refused per request, like a resolved array.record.owner_id != current_user.id.current_user != 'guest'folded to "no restriction" (an object is never strictly equal to a literal). That is the same whole-object comparison one branch over, in the same function, so it is fixed in place (bounded-fix exemption: same defect class, mechanical, no other claim on the file, same gate family).SecurityPlugin+ObjectQLon driver-sql (new file, 15 cases); lint (5 RLS cases plus 1 control, 3 sharing cases).cel-predicate-variable-root-comparand-refusedunder protocol major 18, plus the regenerated registry. BREAKING changeset atminorfor formula, plugin-security, plugin-sharing and lint, andpatchfor spec.The refusal's target, and the census behind it
Is there a legitimate non-array object comparand under
==/!=? None was found at9347c1f23:{ $field }references never reachcomparandOf: the field-to-field branch emits them itself.classify. A bytes literal is refused bycoerceLiteral. A function call isunsupported.rls-compiler.tscompileFilter) bindsid,organization_idandemailas strings, andpositions,org_user_idsandaccessible_org_idsas arrays. It merges only array-valuedrlsMembershipkeys. The sharing seeder binds{}. The lint reference pass binds scalars and arrays.variableRoots: one in-tree use, a formula test that reads the scalar keyctx.department. No caller binds a root to a scalar.The rule is therefore: refuse the bare root statically, in every mode, and refuse any resolved object except a
Date, which the$eq/$necontract admits as a literal.Regression census at
9347c1f23, over the whole objectstack tree (tests, examples, content, skills, apps and docs included): a regex for==,!=,=or any ordering operator againstcurrent_userwith no key after it, on either side, returns 0 hits. Positive control: 2 of 2 on a synthetic file. The keyed form (current_user.after an operator) returns 406 lines. hotcrm at2f7b232returns 2 hits, both prose (a URL query string), and 0 predicates. No shipped policy, sharing condition, formula example or docs example changes verdict.Pre-fix and post-fix, measured
The pre-fix readings come from an ablation of both guards: the anchor replaced through
scripts/ablation-replace.mjs, formula rebuilt, andablation-dist-preflight.mjsconfirming the marker in 2 built files. Restore: blob equal to HEAD,git diff HEADempty, formula rebuilt, marker absent from all 6 built files, tree clean, then all green again.using:!=,==and!(==)against the root[], ground truth intactcheck:!=and!(==), insert and by-id updatePERMISSION_DENIED/ 403, nothing landscheck:==against the root!=policy, insert (write pass)== current_user.idbeside the root)status != 'archived'beside the root)archivedadmittedopenadmitted,archived403read,recordId)narrowsdenies,allowed: false,record.visible: false,readFilterisRLS_DENY_FILTER, no membership id echoedsys_userwhose set carries the rootcheck)Each leg has a control spelled the way the refusal points (
current_user.id, or a scalarcheckon the delegator's set). Every control stays green under the ablation, so each leg discriminates.Lint, on the same stack,
usingandcheck:rls-predicate-unenforceableon both clauses.sharing-rule-runtime-variable-condition. Post-fixsharing-rule-unlowerable-condition. The review record said both lints were silent; that holds for the RLS rule only.Ablation counts: formula 20 of 49 red (the new file's 20 refusal pins; its 5 neighbours and the 24 list-comparand pins stay green). plugin-security 10 of 15 red (the 4 controls and the already-fail-closed
==check stay green). lint 8 of 149 red.Compile surfaces (lowering semantics changed, so one line each)
The refusal sits upstream of every compile surface. The CEL RLS / sharing path now hands a driver only
RLS_DENY_FILTER, a scalaridequality, or seeds nothing.applyFilterCondition(sql-driver.ts:16077; sqlite-wasm and turso local inherit it): out of scope, refused before any compile surface. Measured: a soleusingread returns[](pre-fix 400).buildWhereSQL(remote-transport.ts): out of scope, refused upstream; it receives the sentinel only. Not run.compileScopedFilterToSql(read-scope-sql.ts:658): out of scope. Its scope isctx.getReadScope, the RLS read filter, which is the sentinel after the refusal. Not run.lowerAnalyticsWhere(strategies/filter-normalizer.ts:2171): out of scope. It lowers an analyticswhere, never a CEL predicate (service-analytics does not callcompileCelToFilter).matchesFilterCondition(matches-filter.ts:209on this tree): out of scope. Thecheckit evaluates is now the sentinel. Measured 403 through the real plugin; pre-fix,$newith the object answered true.applyHaving/matchesHaving(having-filter.ts:905/:918): out of scope. A HAVING filter comes from the query, never from the CEL RLS / sharing lowering.checkCondition(memory-matcher.ts:361): out of scope, refused upstream. Not run.translateFieldOperators(mongodb-filter.ts:875on this tree): out of scope, refused upstream. A raw$newith an object from a raw filter stays outside this card. No live producer bypasses the compile: the core{token}resolver yields strings only.Verification (every reading at
60da27e66unless named)@objectstack/formula:pnpm test1027 of 1027 passed across 37 files.pnpm typecheckexit 0; test layer OK.@objectstack/lint:vitest run4160 of 4160 passed across 108 files.pnpm typecheckexit 0; test layer OK.@objectstack/plugin-security:vitest run2755 of 2755 passed across 137 files.pnpm typecheckexit 0; test layer 0 errors.@objectstack/plugin-sharing:vitest run913 of 913 passed across 37 files.tsc --listFileson each package'stsconfig.test.jsonlists every new or edited test file (1 each).node scripts/pm/dispatch-gates.mjs --commands: 91 families derived. 88 exit 0. 3 exit 3, PREREQUISITE NOT MET, so NOT MEASURED:check:dual-build-cjs-loads,check:i18nandcheck:type-check-debteach need the full./packages/*build, which CI runs.--ranreconciliation: 91 accounted, 0 UNRUN..tsfiles with--no-inline-config --format json: 7 files, 0 errors, 0 warnings. The population is the diff's.tsfiles; the changeset is not linted, and no file was reported as ignored. Invariance:eslint.config.mjsnever enables type-aware linting (noparserOptions.project, no typed rules, stated at its line 326), so this diff cannot move the verdict of an untouched file.git merge-treeof this branch againstorigin/main9e7824a44: clean.NOT MEASURED: live mongod, PG and MySQL. The three gates above.
bootstrapDeclaredSharingRulesend to end: the seeder's own call (compileCelToFilter(source, { variables: {} })) is pinned at the formula face and through the sharing lint.Acceptance notes
record.f > current_userstill lowers to$gtwith the whole object, andmatchesFilterConditionanswers true for a string that sorts after the object's string coercion. This is outside the==/!=letter of ruling A. It belongs to the non-scalar-comparand-under-ordering family, which is [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 stage 2d (c). Carrier: [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 2d. Measured on the formula face only.current_user.org_user_ids != 'x'still folds to "no restriction", because an array is never strictly equal to a literal. This is the list family ([finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886), the constant-branch sibling of 2c, and is not folded in here. Measured on the formula face.record.f in current_userandrecord.f.startsWith(current_user)pass the shape check and are refused per request (fail closed), and the RLS lint stays silent on them because its reference pass stops on a refused probe. That is the class of lint:validate-rls-predicate-enforceabilityis silent on==/!=against a kernel membership key (current_user.org_user_ids…), a policy the runtime drops on every request once PR #19947 lands #19951.validate-rls-predicate-enforceabilityis silent on==/!=against a kernel membership key (current_user.org_user_ids…), a policy the runtime drops on every request once PR #19947 lands #19951 (membership-key==/!=, same lint file) is not moved: the root guard fires only on a one-segment path, and no lint source changed.9347c1f23.origin/main(9e7824a44) is 48 files ahead. The overlap isregistry.ts(a different, non-adjacent entry) andpackages/lint(a different rule file). The driver-less merge-tree is clean; not merged.Generated by Claude Code