Repository navigation
fix(formula)!: refuse an ordering comparison whose stored operand holds a list or an object - #20310
Conversation
…ds a list or an object The write-check evaluator compared a list's JavaScript string form under $gt / $gte / $lt / $lte and $between, so a row-level check such as record.tags > 'a' admitted and stored a write whose json column held ['m']. It now refuses, per record, with the stage 2d envelope and sentence (INVALID_FILTER / 400), following driver-sql's declared refusal of an ordering comparison on a JSON-stored column. null, Date and equality are untouched. Adds the semantic migration entry and corrects the stage 2a entry's replacement sentence. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…age-2e-stored-list-ordering
📓 Docs Drift Check4 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 deaabc2a64cffd5f8f4f196e8bba6afe45e106b9 && git checkout deaabc2a64cffd5f8f4f196e8bba6afe45e106b9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6a6a17b62e8b58d0ee31cbfabf5fc7322032ba94 2e54475b4d164c7c1c79b044e5ff2d8cf51ee289 && git checkout -B drift-repro 6a6a17b62e8b58d0ee31cbfabf5fc7322032ba94 && git merge --no-ff 2e54475b4d164c7c1c79b044e5ff2d8cf51ee289
node scripts/docs-audit/affected-docs.mjs --json 6a6a17b62e8b58d0ee31cbfabf5fc7322032ba94 |
Contract reviewServed-tier: Read: the PR body, the net diff against the merge-base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
…for a row-level policy comparing two fields of no shared comparison class (objectstack-ai#20598) Fixes objectstack-ai#20431 Clause-②: no ## What was wrong A row-level policy can compare two fields that share no comparison class, for example a text field against a number field. Enforcement refuses every request such a policy scopes. The find answers `INVALID_FILTER` / 400. A by-id update or delete fails closed at its row-level gate (403), because that gate's pre-image read is the same refused read. The record-grained explanation judged the same predicate in-process without the object's declared columns. It compared the two raw values and reported a record verdict: `visible: true` for one ordering of a pair, and `visible: false` (rls `excluded`) for the other. Both answers covered a request that enforcement refuses. ## What changed The landing point is `packages/plugins/plugin-security/src/explain-engine.ts`, as the dispatch expected; the other files are the pin file and the changeset. There are no changes to `security-plugin.ts`, `packages/formula`, `packages/spec`, the REST layer, or enforcement. - The record matcher (`matchesFilterCondition`) now receives the object's declared columns (`options.fields`), as the RLS write check does. They are read from `ql.getSchema(object)`: the schema the engine already reads for the OWD, and the ObjectQL registry that the find's driver compiles against. A schema that cannot be read hands over no columns, and the matcher judges values only, as before. - With the columns, the matcher refuses the comparison. Explain answers with that refusal: the explanation fails with `INVALID_FILTER` / 400 (the matcher's code and status, the envelope the find answers with), and no record verdict is reported. The message names the policy and both fields with their declared types. The matcher's own error rides as `cause`. - Naming the fields discloses nothing new. The report explain gives the same caller for the same object already publishes that predicate (`readFilter`, or the `rls` layer's `rowFilter`). ## Why a refusal and not a fail-closed report: dispatch assumption A3 did not hold A3 said to reuse PR objectstack-ai#20030's shape (layer `not_evaluated`, `record.visible: false`) for "enforcement refuses this read". Measurement on `main` says otherwise: - Explain already answers the matcher's other `INVALID_FILTER` refusals as a refusal. - `rls-stored-list-ordering-fails-closed.test.ts` (landed in `de091b50`, PR objectstack-ai#20310) pins it: "explain read 400 = find 400; explain update 400, the by-id update 403". One of its cells is a field-to-field comparison against a list-holding field. - PR objectstack-ai#20030's shape covers a dependency call that fails, not a predicate the matcher refuses. My first commit used the report shape. The full `plugin-security` suite then turned 2 cells of that landed pin red, because its field-to-field cell is now caught first by the comparison-class rule. Keeping the report shape would have added the second refusal dialect the dispatch forbids. So this PR follows the ruling's intent: "the read is refused … both orderings answer the same refusal as find". ## Measurement: before and after (better-sqlite3, the same stack as the pins) | policy class | find | by-id update / delete | explain read / update / delete, before | after | |---|---|---|---|---| | text vs number | 400 `INVALID_FILTER` | 403 `PERMISSION_DENIED` | `visible: true`, `decidedBy: 'rls'`, rls `admitted` | refused, 400 `INVALID_FILTER` | | number vs text (the other ordering) | 400 `INVALID_FILTER` | 403 `PERMISSION_DENIED` | `visible: false`, `decidedBy: 'rls'`, rls `excluded` | refused, 400 `INVALID_FILTER` | | text vs text (control) | the row | admitted | `visible: true`, `decidedBy: 'rls'` | unchanged | ## Tests New file: `packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts`. It uses the real `SecurityPlugin`, `ObjectQL` and SQL drivers (better-sqlite3 and sqlite-wasm; PostgreSQL when `OS_TEST_POSTGRES_URL` is set), on PR objectstack-ai#20427's harness. Every refused cell asserts both halves with their envelope `code` and `status`: explain's answer, and the caller's real request. - Five cells: text vs number, text vs image, text vs formula, text vs json, and number vs text. Each checks read, update and delete. Explain answers `{ code: 'INVALID_FILTER', status: 400 }` and its message names the policy and both fields. Find answers `INVALID_FILTER` / 400, update and delete answer `PERMISSION_DENIED` / 403, and nothing is stored. - Both orderings of one pair get `{ find: INVALID, explain: INVALID }`. - Control, same class: find returns only the matching row. Explain reports `visible: true` / `admitted` for it and `visible: false` / `excluded` for the other row. The update is admitted and matches explain. Pre-fix run: `main`'s `explain-engine.ts` restored from the base blob `92716c91`, under a trap whose restore is proven by the HEAD blob and an empty `git diff HEAD`. Result: `Tests 12 failed | 2 passed | 7 skipped (21)`. The 2 passes are the controls. **Ablation:** only the declared-columns argument was removed, through `scripts/ablation-replace.mjs`. The anchor hit 1 → 0 and the blob went `a46456db` → `5a314958`. Result: `Tests 12 failed | 2 passed | 7 skipped (21)`. Every refused cell on both drivers failed: ```text AssertionError: expected 'answered' not to be 'answered' // Object.is equality AssertionError: expected { find: { …(2) }, explain: 'admitted' } to deeply equal { find: { …(2) }, explain: { …(2) } } ``` Restore: `ok restored: blob == HEAD (a46456d) and git diff HEAD is empty`. All figures below were measured at `5e48f52c`, the head after merging `origin/main` `c876a742`: - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: `Test Files 144 passed (144)`, `Tests 3066 passed | 23 skipped (3089)`. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0, with the test layer OK. `tsc -p tsconfig.test.json --listFiles` counts the new file once. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 64 commands, and all 64 ran with exit 0. Three first answered exit 3 `PREREQUISITE NOT MET` (`check:dual-build-cjs-loads`, `check:i18n`, `check:type-check-debt`). I rebuilt with `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (71/71 tasks) and re-ran them; all three answered exit 0. `dispatch-gates --ran`: `64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN`. - Lint, narrowed: `eslint --no-inline-config --format json` over the two touched `.ts` files gives 2 files, 0 errors, 0 warnings. `eslint --print-config` shows no `parserOptions.project` / `projectService`. Linting is not type-aware, so this diff cannot move any untouched file's verdict. ## Acceptance notes - **The REST door answers 500 for this refusal.** The explain route's catch maps only `PERMISSION_DENIED` → 403 and `OBJECT_NOT_FOUND` → 404; every other throw becomes `500 EXPLAIN_FAILED`. I measured it through the real handler (`security-explain-envelope.test.ts` harness): a service refusal carrying `INVALID_FILTER` / 400 comes back as `{ status: 500, error: { code: 'EXPLAIN_FAILED', message: … } }`. The refusal's message survives. PR objectstack-ai#20310's refusals were already answered this way. It lives in `packages/rest/src/rest-server.ts`, outside this card's surface, so it is reported, not fixed here. - **The object-level answer is unchanged.** An explanation without a `recordId` runs no record matcher. For a read under such a policy, it still reports `allowed: true` and rls `narrows`, where the find answers 400. This PR does not change that; it is reported separately. - **Missing record, not measured.** When the record does not exist, the matcher never runs, so explain keeps its missing-record answer (`visible: false`, no `decidedBy`) for a policy the find would refuse. - **Duplicated attribution.** The policy-name attribution (`refusedPolicyNamesOf`) copies the RLS write check's attribution in `security-plugin.ts`. That file is held by objectstack-ai#20555, so one shared helper is left to whoever next touches both files. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #19886
Clause-②: no
Stage 2e, remainder items 2 and 3 of release
5858069107, as ruled on the fork report5858423045in seat ruling5858444849(Q1 A, Q2 A, Q3 A). Claim5858140051. Base17bd3187, merged withorigin/mainae8e3ca0throughscripts/pm/os-regen-merge.sh; head2e54475b.What changes
@objectstack/formulamatchesFilterCondition. One arm inevalOp:$gt/$gte/$lt/$lteand$betweenrefuse, per record, a STORED operand (actual) that is a list or a plain object, whatever the comparand. It uses the stage 2d predicateisNonScalarValueand the stage 2d errorarrayComparandError(INVALID_FILTER/ 400).nullandDatedo not move, and neither does equality ($eq,$ne, implicit equality,$in,$nin) against a stored list.multiple: true(JSON array) column silently answers wrong —$in/$eqalways zero rows,$ninreturns the rows it was asked to EXCLUDE #7398,JSON_COLUMN_INCOMPATIBLE_OPERATORS): every ordering comparison, and$between, on a column it stores as JSON text is refused by declared type with 400. The evaluator has no schema, so it judges the value on the record.@objectstack/spec. A new ADR-0087 semantic entry,rls-predicate-stored-list-ordering-refused, plus the Q3 correction to the stage 2a entryrls-predicate-array-comparand-refused. Only the generated regions ofregistry.tsmove, at about :12722 and :12749..changeset/19886-stored-list-ordering-refused.md:Clause-②: no (narrowing), BREAKING,registered rls-predicate-stored-list-ordering-refused. It names the three Q1 moves and explain's four cells.No change to explain,
packages/lint,security-plugin.ts,cel-to-filter.tsor the shared face.Measured cells
The stack is real: SecurityPlugin + ObjectQL on driver-sql (better-sqlite3) and driver-memory. Before is
17bd3187, cells-before.json, sha256a3d2b42d49563cca. After is head2e54475b, cells-head.json, sha25621548839aac31220. The after table is byte-identical to the fork report's temporary measurement of the same route.Each cell reads before → after. Every 400 is
INVALID_FILTER; every 403 isPERMISSION_DENIED. "(driver)" marks driver-sql's own refusal.usingread, driver-sqlusingread, driver-memoryrecord.tags > 'a'['m']record.tags < 'z'['m']record.tags >= 'a'['m']record.tags <= 'z'['m']record.tags > 'n'['m']record.meta < 'a'{a:1}record.meta > 'a'{a:1}record.watchers > 'a'['p1']record.watchers < 'p2'['p1']record.status > 'a'status: ['m']record.amount > 10amount: [500]record.tags == 'm'['m']Notes on the table:
statusitself, so the stored list is replaced before the check runs.[500]as the text'[500]'(see the REST cell below), so its post-image holds a string and is denied 403.usingread is unchanged. It still compares a stored list element by element, so its write and read now part; this is declared on [finding] driver-memory's own reference matcher has no$fieldarm — a cross-field comparand (bare or withaddDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104, as for 2d's{ $field }half.$betweenmeasured the same coercion on the evaluator:{ tags: { $between: ['a','z'] } }on['m']gave true before and 400 after. CEL never produces it (record.tags >= 'a' && record.tags <= 'z'lowers to$andof$gteand$lte).havingis byte-identical before and after on both drivers. The literal path uses objectql's own comparator, and the{ $field }path at :1117 was already refused by 2d.Explain (Q2 A: named, not changed)
security/explainevaluates the business RLS layer in-process. Both 400 paths, this stage's (O1) and 2d's{ $field }(X1), per operation:readupdateupdatereadPremises
packages/**andexamples/**at17bd3187, and cloud96eb092. 0 shipped using/check/sharing-condition predicates use any ordering operator.Refusal text (the one changed sentence, quoted)
Before (the tail of the first clause of
arrayComparandError):After:
The rest of the message is unchanged. It still withholds the field, the operator and the value, and a pin asserts that for the stored side too.
Q3, the stage 2a entry's
replacementnow ends:Pins
packages/formula/src/matches-filter-array-comparand.test.ts, new stage-2e block of 38 tests:$not;{ $field });null, a missing field and aDate;packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts, 94 tests, real stack on driver-sql (better-sqlite3) and driver-sqlite-wasm:usingread (driver's own 400; C1 and C2 compare).null,Dateand C3 controls.@objectstack/driver-memoryin plugin-security needs adriver-memory-censusledger disposition, which is a maintainer ruling; the in-repo precedent isbootstrap-platform-admin-promotion-selection.test.ts. Its cells above are measured out of tree.Ablation (one-shot proof that the pins can fail)
18e5a78b, viascripts/ablation-replace.mjs. The arm was disarmed (op === 'ZZ_ABL_19886E' && …): anchor 1 → 0, blob6e061b4c→af46be42.ablation-dist-preflightfound the marker indist/index.jsanddist/index.mjs.usingreads, the controls and 2d's X1 explain.6e061b4c;git diff HEADempty; rebuilt;preflight --absentpasses on all 6 built files.Tests and gates (at head
2e54475b)Build and suites. Full
turbo run buildexited 0 (72/72). All of these exit 0, each throughscripts/pm/os-verify-lock.sh:engine-aggregate-having-comparand-shape,having-filter)Typechecks. formula and plugin-security exit 0. Both run
check:test-typecheckovertsconfig.test.json, which includessrc/**/*, so the test files are covered.Gates.
dispatch-gates --commandsat2e54475bderives 90 commands. All 90 exit 0, and--ranreconciles: "90 run, 0 NOT-MEASURED". Also run, all exit 0:pnpm check:authz-resolver;check:generated(15/15 up to date);check-changeset-fixed,check:meta-url-spelling,check:error-code-casing,check:filter-alias-parity);check:engine-double-contract;check:doc-authoring.check:entry-nameabilityprints a pre-existing partial NOT MEASURED for@objectstack/spec/api-assembledand/qa, unrelated to this diff.eslint (narrowed, measured). Covered here:
.tsfiles, each resolved by eslint's own config (--print-config).--format jsoncounts 6 files, 0 errors, 0 warnings, under--no-inline-config.eslint.config.mjsnever enables type-aware linting (noparserOptions.project), so this diff cannot move the verdict of an untouched file.Repo-wide
pnpm lintis CI's.Remainder, still open on this card
os validate): a field compared with ajson/multiplefield, and now an ordering over one. Serial after PR feat(lint,metadata-protocol,cli)!: RLS read scopes are admitted by the engine judge when authored, at the save door and at os validate / build / lint #20265, which landed onmainas1207baf0during this stage, so item 1 is unblocked.Acceptance notes (noted, not filed)
visible=trueon rows where the enforced read answers 400 and the by-id update 403. Measured on17bd3187before any change, cells O1–O4, O8, O9, C3. Reach measured atexplain(); the HTTP door was not driven.having$between. It keeps a NULL group while$gtewith$lteexcludes it, and numeric bounds compare NULL as 0 (both spellings keep NULL for[-10, 10]). Reach isengine.aggregate; no REST door was driven.POST /api/v1/data/crm_activitywithduration_minutes: [500]into anumberfield, no RLS involved, answered 201. SQLite stored it as TEXT'[500]', andGETreturns the string"[500]".[5, 7]answers 400VALIDATION_FAILED; a scalar500is stored as a real. Not fixed here.5857896186③. "driver-sql compares the JSON text on the read" is false at17bd3187: driver-sql refuses with 400 (driver-sql: a declared operator on amultiple: true(JSON array) column silently answers wrong —$in/$eqalways zero rows,$ninreturns the rows it was asked to EXCLUDE #7398). The seat corrected it in5858444849.Generated by Claude Code