Repository navigation
fix(formula)!: refuse comparands that are not one value at the CEL lowering and the write-check evaluator - #20259
Conversation
…ering and the write-check evaluator
The CEL lowering refuses a list under every comparison (ordering operators
included, and on the constant-fold branch), the variable root or an object
under an ordering operator, and an `in` list whose member is itself a list.
The write-check evaluator refuses an array under the ordering operators, an
array member of `$in` / `$nin`, and a `{ $field }` comparison whose column
holds a list or an object on the record being judged.
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
…h clauses; register the ADR-0087 entry and changeset A table-driven pin through the real SecurityPlugin, ObjectQL, driver-sql and driver-sqlite-wasm: every leak row refuses both inserts and stores nothing, reads no rows or the driver's own refusal, and hands the analytics bridge the deny scope; the one-value spellings stay accepted and enforced. The semantic entry cel-predicate-one-value-comparand-refused is registered under protocol major 18 and the registry regenerated. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…age-2d-same-class-leaks
… (d) policy in this release Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check15 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 0f91e546cad4e7469eb7b2b75d1d2d9cca5bb64c && git checkout 0f91e546cad4e7469eb7b2b75d1d2d9cca5bb64c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 443b2f4fdce681f1d3656475c9c72f3ffacfee4c 39379b9b98cecfcf76d28a291c4567184d9ec38f && git checkout -B drift-repro 443b2f4fdce681f1d3656475c9c72f3ffacfee4c && git merge --no-ff 39379b9b98cecfcf76d28a291c4567184d9ec38f
node scripts/docs-audit/affected-docs.mjs --json 443b2f4fdce681f1d3656475c9c72f3ffacfee4c |
Contract reviewServed-tier: Read: the PR body, diff (8 files, +871/−37), 4 commits and 35 check runs; card #19886 (body, all 34 comments; in particular ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…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>
Part of #19886
Clause-②: no
Stage 2d of #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 note5853764742. The changeset declaresClause-②: no (narrowing), BREAKING, and registerscel-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 reasonunsupported:>,>=,<,<=), either side: a list literal, or acurrent_userkey that resolves to an array.inlist whose member is itself a list, written or resolved.current_userroot, or a key resolving to an object, under an ordering operator. fix(formula): refuse == / != against the bare current_user root at the CEL lowering #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):$gt/$gte/$lt/$lte, and an array member of$in/$nin, are refused by the up-front shape walk, before any record is judged.{ $field }comparison ($eq,$neor an ordering operator) whose compared column holds a list or an object on the record being judged is refused. This covers either side, and theaddDaysform 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:
RlsFieldGuardcarries column names only (getObjectFieldNamesreturns 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 (#5222crossFieldComparisonClass), which refuses a cross-field comparison against any JSON-stored ormultiplecolumn on either side.Measured cells
A real stack:
SecurityPlugin, ObjectQL, driver-sql (better-sqlite3) and driver-memory, ajsoncolumn and amultiplelookup. Doors measured for each cell:check(forbidden insert and allowed insert, with what was stored);usingread;isPushdownableCeland the lint rulevalidateRlsPredicateEnforceability);getReadFilterintocompileScopedFilterToSql, and intoassertReadScopeComparandsRunnable+engine.aggregate)."Before" is
origin/main3cb84d08, 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 oforigin/main0d3ec471, including #20212's comparand faces in the RLS compiler.record.status != record.tags!(record.status == record.tags)record.tags != record.status!(record.status in [['closed', 'archived']])!(record.status in current_user.nested_set)record.status > ['m']record.status <= ['m']current_user.org_user_ids > 'a'record.status > current_user.org_user_idsrecord.reviewer > current_usercurrent_user.org_user_ids != 'x'record.status != record.reviewer!(record.status in ['closed', 'archived'])record.status > 'm'Scope beyond the three recorded leaks, declared
(d)
record.reviewer > current_userand (e)current_user.org_user_ids != 'x'are not in5806608550. Seat 4's note5853764742reported 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:
==/!=out of it would have written a hole into a guard that refuses the same list under>. (d) is fix(formula): refuse == / != against the bare current_user root at the CEL lowering #20189's guard with its==/!=condition removed, and its sentence already names the operator.cel-to-filter.tsis on this claim.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, #20212's faces already drop (d)'s policy; (e) still folds to allow-all there.Remainder, still open on this card
{ $field }reference at all: neither its source nor its tests handle$field. Ausingread underrecord.status != record.tagsreturns every row, and so does the scalar controlrecord.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, [spec] SqlDriver 将$field编译为列对列比较(cross-field comparison push-down) #5222).os validate). A field compared with ajson/multiplefield still lowers and is not reported at authoring. Judging it needs field types: either the lint rule, whose object graph has them (RLS policies are admitted when they are authored: policy save andobjectstack validate/ compile judge the lowered read-scope filter with the engine's judge-only method (objectstack#19995 ruling C, second consumer) #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 flatin, a scalar fold, and field-to-field==/!=, which still lower.packages/formula/src/matches-filter-array-comparand.test.ts. 2a's table-drivenSHAPESgains$gt/$gte/$lt/$ltewith an array and$in/$ninwith an array member. Each runs at every depth, with the empty array, and for every record. A newdescribecovers 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.a { $field } reference is judged by what was AUTHORED, not by what it resolves toasserted{ tags: { $eq: { $field: 'tags' } } }→trueover a list-holding column. It now assertsINVALID_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 handsgetReadFilterthe 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(fix(formula): refuse == / != against the bare current_user root at the CEL lowering #20189), and RLS: a policy's compiled filter skips the shared comparand-shape faces, so a null list member or null ordering bound reaches driver-sql, and the read and the write check disagree (the read hides a row its own check admits) #20212'srls-compiled-comparand-faces.test.tsandrls-null-comparand-fails-closed.test.tsstay green unchanged.cel-to-filter-list-comparand.test.tsandcel-to-filter-variable-root-comparand.test.tsstay green; none of them pins an ordering or constant-branch list.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 throughscripts/ablation-replace.mjs(the anchor must hit; on-disk counts are verified).ablation-dist-preflight.mjsthen confirmed the marker had reached formula'sdist/, which plugin-security reads, before any result was read. The script armedtrap … EXIT INT TERM, with absolute paths, as its restore.{ $field }check, the four ordering operators inARRAY_REFUSED_OPERATORS, and the$in/$ninmember 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).==/!=. 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).git checkout HEADon the absolute paths.git diff HEADwas 0 bytes and the blob equals the HEAD blob (matches-filter.ts1fb6f28c22e6,cel-to-filter.ts4c6ef215947). formula was rebuilt, and the preflight--absentfound 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):Lowering, list member (new
listMemberRefusal):The resolved form reads "member N of
current_user.KEYresolves to a list". The==/!=sentence of 2c and the object/root sentence of #20189 are unchanged; the latter now fires under ordering operators too.Evaluator (
arrayComparandError, one sentence for the class, widened from 2a's):Census (Zone 2 item 4)
Zero producers carry any of the shapes:
3cb84d08: 123 literalusing/checkstrings in non-testpackages/**, 63 lines of them indefault-permission-sets.ts, and 3 inexamples/**.origin/main96eb092: 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 nestedinlists and 0 orderings against a list. A positive control confirms the detector fires on each shape. No D2 conversion is owed.Zone 2 answers
3cb84d08. On the merged main, RLS: a policy's compiled filter skips the shared comparand-shape faces, so a null list member or null ordering bound reaches driver-sql, and the read and the write check disagree (the read hides a row its own check admits) #20212's faces drop (d) only.{ $field }isrecord.status != record.tags; it is reachable and refused with 2a's sentence, widened.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 spec: FilterConditionSchema (the save-time door) admits a non-boolean $null / $exists flag, so a stored dataset or widget filter carrying one saves clean and is refused 400 on every query face #20116.$notJSON-only shapes remain unreachable from every authored surface. CEL never lowers a bare$notover a field-less$ne, an empty$or, or a nested relation value, and the only production callers ofmatchesFilterConditionare the RLS write check, the tenant wall (platform-derived filters), explain (RLS-compiled filters) and objectqlhaving(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/specbuild, thencheck:generated: all 15 artifacts current.typecheckexit 0.typecheckexit 0.Pre-merge head
bfd409ee:havingpair: 2 files / 176 passed.Generated artifacts:
check:migration-registry,check:spec-changesandcheck:upgrade-guidewere current after regeneration, on both sides of the merge.registry.tswas regenerated after the merge: 265 semantic entries, carrying this entry and main'sinline-grid-column-currency-scale-refused.eslint on the 7 touched
.tsfiles: 7 files, 0 errors, 0 warnings, counted fromeslint --no-inline-config --format json. The population comes fromeslint.config.mjs, whose header states it never enables type-aware linting (noparserOptions.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/objectstackwas re-derived on the actual change set at39379b9b(90 commands; the same set at the merged head4ade1b94). Every command ran, and its exit code was recorded before any pipe.--ranreads: 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-loadsandcheck:type-check-debtneed every workspace package'sdist/(45 unbuilt here), andcheck:i18nneeds 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.check:migration-registry,check:spec-changes,check:upgrade-guide,check:api-surface,check:authorable-surfaceandcheck: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 fromcelToFilterOutcomeand not driven live.Acceptance notes (not filed)
.changeset/19886-formula-array-comparand-refused.mdand its entryrls-predicate-array-comparand-refusedstill 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.havingwith 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).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