Repository navigation
fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355) - #20427
Conversation
…ison the read refuses
The write check's evaluator (formula matchesFilterCondition) now takes the
object's declared columns and judges every { $field } comparison by the spec's
crossFieldComparisonVerdict, refusing a non-comparable one with INVALID_FILTER
/ 400 before any record is read. plugin-security's write gate hands it the
declared columns and logs the refused policy and columns server-side.
driver-sql's crossFieldComparisonClass delegates to crossFieldColumnVerdict,
layering only its own aliases. lint's check-clause consequence sentence states
the new write answer.
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…d and write; retire the lift parity test formula: every declared class against every other, all six operators, record independence, nesting, the offset form, the withheld message and the carried diagnostic. plugin-security: through the real engine on better-sqlite3, sqlite-wasm and (opt-in) PostgreSQL, the read, by-id update/delete, the using- as-check insert, the check insert and by-id update, the named server log, and the same-class control. driver-sql: the alias layer crossFieldComparisonClass keeps above the spec classification. The #20347 parity test retires with the private copy it held equal to the export. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…s-write-check-cross-class
📓 Docs Drift CheckThis PR changes 5 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 142 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 35515256da406a9199f3fa1011305a702099e49f && git checkout 35515256da406a9199f3fa1011305a702099e49f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5 2698fa15bdd83e3392e752b16e28172469180d7c && git checkout -B drift-repro 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5 && git merge --no-ff 2698fa15bdd83e3392e752b16e28172469180d7c
node scripts/docs-audit/affected-docs.mjs --json 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5
|
… surface grows @objectstack/formula's root entry gains findCrossFieldClassRefusal, crossFieldClassRefusalCarriedBy, their two types and an optional third argument on matchesFilterCondition, so the widening question answers yes; the narrowing arm stays for the write check's new refusal. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsRead from the net diff against
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL — on ② and on the gates of this head, not on the contract. ① carries no defect: the write check refuses, on every path it judges, exactly the comparisons the read refuses, by the one classification, and admits every same-class comparison it admitted before; driver-sql answers every read as before. What fails at |
…s-write-check-cross-class
…under protocol major 18 One ADR-0087 D3 semantic entry, rls-predicate-cross-class-field-comparison-refused, names both arms: the authoring arm os validate gained and this write-check arm; registry.ts regenerated by gen:migration-registry. The changeset moves to `registered` and adds @objectstack/spec at patch. The two spec comments and the one lint header sentence this PR had made stale now say what holds. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…s-write-check-cross-class
Contract reviewServed-tier: ① Derived judgmentsDelta review on the head that answers record 5868954092 (FAIL at
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS — the contract holds and the two grounds of the FAIL at |
…s-write-check-cross-class
…t header names the delegation The entry's reason no longer says a file field has no stored column: the file family is refused by name for the ADR-0104 dual-encoding reason the spec module gives. acceptanceCriteria qualifies the 400 read with "on the SQL drivers", and the replacement's text class lists all four reference types (tree included). registry.ts regenerated by gen:migration-registry. The lint header's #20347 paragraph says driver-sql now delegates through crossFieldColumnVerdict instead of naming the retired parity test. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…s-write-check-cross-class
Contract reviewServed-tier: ① Derived judgmentsSecond delta review, on the head that answers record 5869972995 (PASS at
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS — the contract holds and every gate lane is green at this head. The PR's own delta since |
…te -wal sidecar too, never waiting on another connection (objectstack-ai#20426) (objectstack-ai#20463) Fixes objectstack-ai#20426 Clause-②: no `reclaimSpace()` on better-sqlite3 now returns the freed bytes from the `-wal` sidecar as well as the freelist, and it never waits on another connection. Every size below is the database file plus its `-wal` file, read from the file system while the driver is still open. Every freelist and page count is read from a second connection. Measured head: `effb34a8a` (the branch after merging `origin/main` at `b28550818`, which carries PR objectstack-ai#20427). ## What was wrong With PR objectstack-ai#20425, one `Database.exec('PRAGMA incremental_vacuum')` returns the whole freelist in one transaction. In WAL mode, the file-backed default, that transaction's dirty pages outgrow the page cache, so SQLite spills them into the WAL before the commit truncates them away. Nothing afterwards truncates the WAL, so the sidecar keeps its high-water size until the last connection closes. ## What changed - `packages/drivers/driver-sql/src/sql-driver.ts`: the better-sqlite3 arm of `SqlDriver.reclaimSpace` calls a module-local `reclaimBetterSqlite3(connection)`. It is module-local, like `formatDuplicateGroups`, because `SqlDriver`'s `.d.ts` carries its non-public members and this helper is no entry point. The published types are unchanged; the `.d.ts` gains one doc-comment sentence on `reclaimSpace`. The helper: 1. reads `PRAGMA freelist_count`, and sends nothing more when it is `0`; 2. runs `PRAGMA incremental_vacuum(N)` in chunks, N being a quarter of this connection's page cache (1,000 pages at better-sqlite3's default `cache_size = -16000` and 4 KiB pages), with a `PASSIVE` checkpoint after each chunk; 3. stops when the freelist is empty or a chunk frees nothing (an `auto_vacuum = NONE` file never shrinks its freelist); 4. ends with one `PRAGMA wal_checkpoint(TRUNCATE)` under a busy timeout of `0`, and puts the connection's own busy timeout back in a `finally`. Every statement goes through the binding's `exec()` / `pragma()`, which step to completion. The loop is synchronous, so nothing else runs on the connection between chunks. Every other SQLite client stays on `knex.raw`, as before. - Tests in `driver-sql` and `driver-turso` (below), and `.changeset/20426-reclaim-space-wal-sidecar.md` (`@objectstack/driver-sql`: `patch`). - `.changeset/20106-reclaim-space-full-freelist.md`: one paragraph removed. It said the freed pages pass through the `-wal` file, "which keeps its size until the last connection closes". This PR makes that false, and that note is still pending release. **This keeps `check-empty-changeset` red on purpose** — see "The one red gate" below. ## The dispatch's hypotheses - **H1 — confirmed** on `origin/main` `8cdbe0c6e`, through `SqlDriver` (25,754 free pages): | step | database file | `-wal` | freelist / pages | |:--|--:|--:|:--| | after the delete | 103,149,568 | 4,255,992 | 25,754 / 25,789 | | after `reclaimSpace()` (351 ms) | 16,384 | 94,430,432 | 0 / 4 | | after one more write | 16,384 | 94,430,432 | 0 / 4 | | after `disconnect()` | 16,384 | 0 | 0 / 4 | The DELETE-journal control on the same tree: 105,631,744 → 16,384 while open, with no `-wal` file. - **H2 — re-measured on this tree, and the picked variant is a fourth one.** Each variant ran on `SqlDriver`'s own pooled connection after the real fill-and-delete path (25,754 free pages, chunk 1,000). The rows show database file + `-wal` after the call, driver open. This is one run per cell on a shared box, so read the ratios, not the absolute times. | variant | no reader | reader in this process (read transaction open) | reader in another process (open for 1.5 s) | |:--|:--|:--|:--| | `exec` alone (PR objectstack-ai#20425) | 16,384 + 94,430,432 · 315 ms | 103,149,568 + 94,430,432 · 715 ms | 103,149,568 + 94,430,432 · 273 ms | | + `wal_checkpoint(TRUNCATE)` | 16,384 + 0 · 476 ms | 103,149,568 + 94,430,432, busy · **5,333 ms** | 16,384 + 0 · **1,526 ms** (waited out the reader) | | chunked + `PASSIVE` | 16,384 + 4,255,992 · 157 ms | 103,149,568 + 4,255,992 · 47 ms | 103,149,568 + 4,255,992 · 62 ms | | **chunked + `PASSIVE` + `TRUNCATE` at busy timeout 0 (this PR)** | **16,384 + 0** · 276 ms, 108 ms on a rerun | 103,149,568 + 4,255,992, busy · 48 ms | 103,149,568 + 4,255,992, busy · 61 ms | - The objectstack-ai#20106 reading of about 210 KB for chunked + `PASSIVE` does not hold through `SqlDriver`. `PASSIVE` never shrinks the sidecar: it stays at whatever high-water size the sweep's own deletes left (4,255,992 here). Only a `TRUNCATE` checkpoint returns it. - A waiting `TRUNCATE` checkpoint blocks the whole process on this synchronous binding, for up to the connection's busy timeout (5,000 ms; knex's better-sqlite3 client passes no `timeout`, so it is always better-sqlite3's default). The lifecycle sweep runs in the server process, so the triage's never-wait direction holds. - So this PR takes the triage's chunked, never-waiting variant, plus one `TRUNCATE` checkpoint that cannot wait. It is the only row that both returns the space with no reader and never waits with one. - With a reader present, no variant can shrink the database file. The chunked rows keep the pair at its size before the call (107,405,560). The one-statement rows grow it to 197,580,000. **The chunk size, and why.** A chunk that outgrows the page cache spills its pages into the WAL, just as one statement does. Frames left in the WAL by the call, with a reader pinning every frame so none is reused: | chunk (pages) | 100 | 250 | 500 | 1,000 | 2,000 | 4,000 | 8,000 | one statement | |:--|--:|--:|--:|--:|--:|--:|--:|--:| | default cache (`-16000`) | 1,437 | 1,121 | 1,003 | 928 | 883 | 3,779 | 14,216 | 22,920 | | 2 MB cache (`-2000`) | | 1,121 | 4,554 | 15,491 | | | | | - The spill starts where the chunk reaches the page cache: between 2,000 and 4,000 pages at the default (`PRAGMA cache_spill` reads 3,871), and between 250 and 500 at `-2000`. - Below that point, larger chunks mean fewer commits and fewer frames. - A fixed 1,000 would spill on a connection with a smaller cache or larger pages. So N is derived from the connection's own `cache_size` and `page_size`, and the quarter leaves room for the per-page overhead and the b-tree pages each chunk rewrites. At the default that is 1,000 pages (4 MB). - **H3 — confirmed.** `resolveSqliteJournalMode()` answers `wal` for a file-backed database unless configured otherwise, and the probe's second connection reads `journal_mode = wal`. The DELETE-journal control is unchanged by the fix. Before and after, the file shrinks while the driver is open and no `-wal` file exists: 105,631,744 → 16,384, 216 ms before and 127 ms after. - **H4 — confirmed.** The local `TursoDriver` face uses knex's `better-sqlite3` client, so it takes this arm through `super.reclaimSpace()`. Its suite reached the method, but it read only the freelist and the page count. It now has a WAL-size case. The remote route is untouched. - **H5 — nothing new is thrown, so the sweep logs nothing new.** Both checkpoints report "busy" as a result row, not as an error. So a busy checkpoint degrades to "vacuumed, not checkpointed": the call resolves, the pages are off the freelist, and `LifecycleService.sweep()` lists the datasource as reclaimed, as before. - Their bytes leave the files at a later checkpoint: the next reclaim with free pages, SQLite's auto-checkpoint at 1,000 frames, or the last connection closing. The reader case of the new test measures the next reclaim. - What can still throw is unchanged. Another connection holding the write lock (`BEGIN IMMEDIATE`) makes the vacuum statement itself wait out the busy timeout and throw `SQLITE_BUSY`. Measured: `main` 5,021 ms and this PR 5,014 ms, both freelist unchanged, busy timeout 5,000 afterwards. - In that case the sweep logs its existing warning (`space reclaim on datasource 'X' failed (database is locked)`) and does not list the datasource. - The busy-timeout swap comes after the loop, so a throw inside the loop never reaches it. ## The fix through `SqlDriver` Same 25,754-page fixture: | condition | database file + `-wal` after the call | call | busy timeout after | |:--|:--|--:|--:| | WAL, no reader (was 103,149,568 + 4,255,992) | 16,384 + 0 | 101 ms, 109 ms | 5,000 | | DELETE journal | 16,384, no `-wal` | 127 ms | 5,000 | | WAL, reader in this process | 103,149,568 + 4,255,992 (unchanged; freelist 0) | 47 ms | 5,000 | | WAL, reader in another process | 103,149,568 + 4,255,992 (unchanged; freelist 0) | 66 ms | 5,000 | ## Tests `driver-sql/src/sql-driver-sqlite-reclaim-space.test.ts`, 7 cases (4 before). Each size is the database file plus the `-wal` file, read while the driver is open. Each freelist and page count is read from a second connection. - **WAL:** freelist 0, and `{ file: pages × 4096, wal: 0 }` while open and again after close. - **WAL with a reader holding a read transaction.** The reopened file has no WAL, the cache is set to about 100 pages, and 600 pages are free. The case asserts: - the call resolves in under half the busy timeout; - the busy timeout reads 5,000 afterwards; - freelist 0; - WAL growth under a quarter of the freed bytes. Measured: 0.08 for this PR, and 0.87 for both one statement and a fixed 1,000-page chunk. - Once the reader commits, the next reclaim returns everything: `{ file: pages × 4096, wal: 0 }`. - **The `auto_vacuum = NONE` control:** - the call resolves, so the loop stopped; - freelist and pages are unchanged; - the database file equals pages × 4096; - file + `-wal` is no larger than before. - **DELETE journal:** freelist 0, and `{ file: pages × 4096, wal: 0 }` while open. - **The empty-freelist control, for both journal modes:** nothing changes, the sizes included. - **Unchanged:** the pooled connection is handed back. `driver-turso/src/turso-remote-inherited-members.test.ts`: new case "local face: in WAL mode the freed bytes leave the -wal sidecar too, while the driver is still open". Suites on the merged head `effb34a8a`, all through `scripts/pm/os-verify-lock.sh`, each exit code recorded: - `pnpm --filter @objectstack/driver-sql test`: exit 0, 195 files passed and 11 skipped; 3,179 tests passed and 178 skipped. The count before the merge was 3,227; the merge brought in PR objectstack-ai#20427, which removed tests of its own. - `pnpm --filter @objectstack/driver-turso test`: exit 0, 74 files; 1,982 passed and 16 skipped. - `typecheck` for `driver-sql` and `driver-turso`: exit 0 each. `tsc --listFilesOnly` shows both changed test files are in each package's program. ## Ablations Every leg ran on the committed state through `scripts/ablation-replace.mjs`. In each, the anchor went from 1 hit to 0, and the restore was proven blob-equal to HEAD with an empty `git diff HEAD`. The `driver-sql` suite imports `./sql-driver.js` (source), so those legs needed no build. | leg | mutation | result | |:--|:--|:--| | A | final `TRUNCATE` checkpoint removed | 3 red: WAL `{16,384 + 1,334,912}` vs `{16,384 + 0}`; the reader case's follow-up `{16,384 + 296,672}`; the NONE control's pair grew 1,318,384 → 2,555,376. 4 green. | | B | one statement instead of chunks | 1 red: the reader case, WAL growth 2,142,400 vs a bound of 618,496. 6 green. | | C | fixed 1,000-page chunk instead of the derived one | 1 red: the reader case, 2,142,400 vs 618,496. 6 green. | | D | busy timeout not zeroed for the `TRUNCATE` | 1 red: the reader case, elapsed 5,034.99 ms vs under 2,500. 6 green. | | E | busy timeout not restored | 1 red: the reader case, busy timeout 0 vs 5,000. 6 green. | | F | the no-progress stop removed | the NONE control hung in the synchronous loop and was killed after 60 s (SIGKILL). | | A, dist | leg A built into `driver-sql`'s `dist/`, which `driver-turso` resolves | `ablation-dist-preflight` found the marker in 2 built files. `driver-turso`: 1 red (local face `{32,768 + 280,192}` vs `{32,768 + 0}`), 82 green. After the restore and a rebuild, `--absent` found the marker in none of the 6 built files, and the tree was clean. | In the first B–E runs, the red reader case also timed out its cleanup hook: the failed assertion left the reader's transaction open. The fixed case rolls the transaction back first. A rerun of leg B went red in 91 ms with no hook timeout. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `effb34a8a` derived 63 commands. All 63 ran, and every exit code was recorded before any pipe. 62 exited 0; `check-empty-changeset --base origin/main` exited 1 (next section). - `--ran`: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. - The `--ran` pass printed a STALE TREE warning: `origin/main` moved 6 commits after the merge, and `scripts/cross-package-test-inputs.mjs` changed in that range. Of those 6 commits, only PR objectstack-ai#20447 touches a driver: it changes the `driver-turso` constructor, and none of this PR's files. CI reads the merge ref. - `check:driver-conformance`: 50 covered, 0 DEBT, 0 exempt, both before (`8cdbe0c6e`) and after (`effb34a8a`). - `pnpm lint` is CI's run. The narrowed run: `eslint --no-inline-config --format json` over the 3 changed `.ts` files reports 3 files, 0 errors and 0 warnings. `ESLint.isPathIgnored` answers false for each, so all three are in `pnpm lint`'s population. `eslint.config.mjs` sets no `parserOptions.project` and no typed rule, so this diff cannot move the verdict of an untouched file. ## The one red gate: `check-empty-changeset` (a deliberate correction, for confirmation) This PR edits `.changeset/20106-reclaim-space-full-freelist.md`, which exists on the merge base. The gate refuses that by name, and its own text sets out two classes. This is the **deliberate correction** class, not a collision. - The removed paragraph says the `-wal` file "keeps its size until the last connection closes". After this PR it is truncated at the end of the call unless another connection is reading. - That note has not been released, so restoring it from the base would publish the false sentence. - The gate's prescription for this class is to leave it red and get the correction confirmed on the PR. `skip-changeset` is not applied and must not be: this PR publishes a `patch`. **For the seat: please confirm, or choose the other route.** The other route is to restore the 20106 file from the merge base. The gate then goes green, but the release would carry that sentence beside this PR's own changeset, which describes the new behaviour. ## Acceptance notes - **The file surface is widened by one file.** The claim names `.changeset/20426-*.md`, and this PR also edits `.changeset/20106-reclaim-space-full-freelist.md` (one paragraph removed). It is the same defect, a mechanical removal, a card that has already landed, and the same changeset gate family. - **Behind a long reader, the bytes wait.** When a reader holds a snapshot during the call, the database file keeps its size until a later checkpoint. `LifecycleService.sweep()` still lists the datasource as reclaimed. The next sweep that deletes rows returns it, and SQLite's auto-checkpoint or the last close returns it sooner. No producer is left worse off than on `main`, where the same reader left 197,580,000 bytes instead of 107,405,560. - **Partial progress is possible.** Chunks commit one by one. Another process can take the write lock between two chunks, and then the next chunk waits up to the busy timeout and may throw with the earlier chunks already committed. This was not measured. A one-statement vacuum waited and threw the same way, all or nothing. - **Blocking is shorter, not gone.** The call still blocks the event loop while it runs: 101 to 276 ms at 25,754 pages on this shared box, against 315 to 351 ms for PR objectstack-ai#20425's single statement. - The remote `TursoDriver` route, `SqliteWasmDriver`, `LifecycleService` and `packages/spec` are untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…staged $empty operator (objectstack-ai#20444) (objectstack-ai#20523) Fixes objectstack-ai#20444 Clause-②: yes (widening) The `domain:engine` lane's arms for the staged `$empty` operator, under ruling A on objectstack-ai#20399 (`5865693155`): 「**One sibling card per compile-surface lane**, each `Blocked-by:` objectstack-ai#20311's spec PR: `domain:engine` — driver-sql and its heirs, turso `RemoteTransport`, driver-memory, driver-mongodb, formula, objectql `having`; `domain:services` — service-analytics' two faces. The two faces with no field declarations (the formula matcher, objectql `having`) judge by value, diverging only on a non-text column holding `''` (the write-door class objectstack-ai#20308 closed).」 Every arm calls the spec's one expansion from PR objectstack-ai#20442 (`expandEmptyOperator` / `isEmptyFilterValue` in `@objectstack/spec/data`); no face keeps a copy of the table. The staging does not move (the maintainer's 「照 $like 先例分阶段」, `5868169573`): `$empty` is **not** added to `FILTER_OPERATORS`, the `is_empty` / `is_not_empty` lowering still emits `$null`, and the engine's front door still refuses the operator. A driver or evaluator called directly now answers it. ## What each face does now | face | reads | `$empty: true` | undeclared field | |---|---|---|---| | `driver-sql` `applyFilterCondition` (and `driver-sqlite-wasm`, `driver-turso` local, which inherit it) | declared row | null-only: `col IS NULL`; text: `(col IS NULL OR col = '')`; multi-value: `(col IS NULL OR L)` | refused | | `driver-turso` `RemoteTransport.buildWhereSQL` | declared row, via a resolver `TursoDriver` wires from the same registry | same SQL, SQLite dialect | refused (also when used standalone with no resolver) | | `driver-memory` live path (`find` / `count` / `update` / `delete` through mingo) | declared row | null-only `{ f: { $eq: null } }`; text `{ f: { $in: [null, ''] } }`; multi-value `{ $or: [{ f: { $eq: null } }, { f: { $size: 0 } }] }` | refused | | `driver-mongodb` `translateFilter` (and the aggregate `$match`) | declared row, via a new optional `valueShape` resolver | the same three documents | refused (also standalone with no resolver) | | `driver-memory` reference matcher (`match`) | by value | `isEmptyFilterValue(value)` | answered by value (it holds no declarations) | | `formula` `matchesFilterCondition` | by value | `isEmptyFilterValue(actual)` | answered by value | | objectql `having` and per-aggregation `filter` | by value | `isEmptyFilterValue(value)` | answered by value | | `driver-memory` analytics (cube) face | — | refused `INVALID_FILTER` / 400 as a declared operator it cannot compile, as it refuses `$null` | — | `$empty: false` is the exact complement on every face: `(col IS NOT NULL AND NOT L)` / a non-null value other than `''` (the not-equal operator against a bound `''`) / `IS NOT NULL` on SQL, `$nin` / `$nor` / `$ne` on the document faces, `!isEmptyFilterValue` on the value faces. A non-boolean flag is refused on every query face (`INVALID_FILTER` / 400, on each driver's validating walk, so an identity that settles the node first cannot skip it); formula answers it `false`, its standing posture for an unevaluable `check`. `L`, the empty-list test on a multi-value column (a JSON column: TEXT on SQLite, `json` on PostgreSQL and MySQL): - SQLite (and libSQL): `(CASE WHEN json_valid(col) THEN json_type(col) = 'array' AND json_array_length(col) = 0 ELSE 0 END)` — a malformed legacy cell answers FALSE instead of failing the statement; a non-array JSON value is not an empty list; - PostgreSQL: `(CAST(col AS jsonb) = CAST('[]' AS jsonb))`; - MySQL: `(JSON_TYPE(col) = 'ARRAY' AND JSON_LENGTH(col) = 0)`; - any other knex dialect: the multi-value row is refused (the text and null-only rows need no dialect). An empty list is always tested as a stored value, never bound as a `$eq: []` comparand (ruling 乙 on objectstack-ai#19757 stands). Every SQL predicate is TOTAL (never UNKNOWN), so `$not` over `$empty` needs no NULL guard: both SQL compilers' polarity tables gain the row (`operatorIsNullTotal` → true, `nullValueSatisfiesOperator` → `value === true`). ## PM hypotheses, measured - **H1 — held, with the sources named.** Measured on base `4a1df1965` by driving each face directly (a scratch probe, not committed) with `{ f: { $empty: true } }`, `$empty: false` and `{ $and: [{ g: 'x' }, { f: { $empty: true } }] }`, beside a `$null` control (answered on every face) and a `$bogus` control. Refusal sources: driver-sql the emitter's `default:` arm (`unsupportedFilterOperatorError`); turso remote its own vocabulary refusal (`unsupportedOperator`); driver-memory live path and matcher both at the shared shape gate (`assertFilterConditionShape`, `filter-refusal.ts`); driver-mongodb `translateFieldOperators`' `default:`; objectql `having` `unknownOperator`. All `INVALID_FILTER` / 400. formula answered `[]` for all three shapes (the silent `false`), exactly as `$bogus`. After this PR, the same probe answers `['2','3']` / `['1']` / `['2','3']` on every face that holds the declaration or judges by value, and refuses on the two standalone entry points given no declaration. - **H2 — each declared-type face's declaration.** `driver-sql`: a new per-table registry `valueShapeFields` (`{ type, multiple }` per field), filled beside `jsonFields` at `registerManagedObjectMetadata` (so `initObjects` and `registerObjectMetadata`), `registerExternalObject`, and the shard alias. turso remote: `registerRemoteFieldMetadata` → `registerExternalObject` fills the same registry, and `TursoDriver` hands the transport `setDeclaredValueShapeResolver`. driver-memory and driver-mongodb: a map filled by `syncSchema` beside the temporal-kind map. The engine's registry injects the audit / tenant / owner fields into the object's field map before it is synced (per `registry.ts`' own docblock; not re-measured end to end here), so those are declared too. **A field with no declaration (a knex-built table, the builtin `id`, a field with no `type`) is a refusal, never a row guessed from a value:** the spec's by-value reading has no SQL form without the type (`amount = ''` is a type error on PostgreSQL). A declared non-member type (`string`, `object`, `array` from an introspected or test object) takes the row the spec's expansion gives it, null-only. - **H3 — SQL arms**, above. Pinned on SQLite locally; `sql-driver-20444-empty-operator.test.ts` runs on every cell of the live dialect matrix, so PostgreSQL and MySQL are measured by the `Temporal Conformance (live PG + MySQL)` job. **Locally NOT MEASURED** on PG / MySQL: no server is reachable in this container. The MySQL `' '` row relies on the NO PAD default collation of the job's `mysql:8.0`. - **H4 — the conformance table.** `FILTER_LOGIC_CASES` gains seven `$empty` cases on the fixture's nullable column `d` (true, false, both under `$not`, inside `$or`, inside `$and`, beside `$ne` on the same field). The fixture stores neither `''` nor `[]`, so on it every row of the table agrees; the rows pin that every face HAS an arm, that `$not` over it is total and that it composes. The per-type discrimination is each face's own suite (below). Census of every consumer that iterates the table: - driver-sql `sql-driver-or-filter.test.ts` — built its table through knex, so the harness now registers the fixture's declaration (`registerObjectMetadata`); - driver-sqlite-wasm, driver-turso local and remote, driver-memory live path and matcher, driver-mongodb live suite — already declared the fixture (`initObjects` / `syncSchema`), pass unchanged; - driver-memory analytics face — the harness's rule is "agree or refuse loudly", and it refuses; - driver-mongodb `mongodb-filter-logic-translation.test.ts` — calls `translateFilter` standalone, so it now passes a declaration resolver; - formula `matches-filter-or-semantics.test.ts` — by value, passes unchanged; - spec `filter-verdict.test.ts` — the rows reduce to `clause`, passes unchanged; lint `validate-empty-combinators.test.ts` reads only the `objectstack-ai#5322` rows; - service-analytics `read-scope-sql-conformance.test.ts` and `native-sql-filter-logic-conformance.test.ts` — outside this lane. Since PR objectstack-ai#20498 (merged) both faces answer `$empty`, but only when handed the field's declaration; each harness now passes a `text` declaration for the fixture (test-only, no service-analytics source touched), so they pass the rows rather than partition them. Declared as a deviation below. - **H5 — `having`'s conclusion.** By value over the aggregated row: null, a column the row lacks, `''` and `[]` are empty. A numeric aggregate holding `0` (a `count` over nothing, a `sum` netting to zero) is **not** empty. A `groupBy` text column holding `''` **is** empty — the row a declared text field takes too. The per-aggregation `filter` shares the walker and the reading. Pinned in `having-empty-operator.test.ts`, including the row-independent refusal of a non-boolean flag. - **H6 — formula's docblock.** Its header claimed a DECLARED operator never gets the silent `false`; that was false from objectstack-ai#20311's declaration until this arm. The header now records that, names the declared-but-staged set (`$like`, `$ilike`, `$empty`) as answered, and says the next declared name is owed an arm by the PR that lets an author write it or by its staging's lane card. ## Tests (head measured: `436a10a3e`) - New per-face pins, each over a text, a multi-value and a scalar field with null, `''`, `[]` and value rows, `$empty: false`, nesting under `$and` / `$or` / `$not`, a sibling operator on the same field, and refusals asserted by `code` + `status`: `sql-driver-20444-empty-operator.test.ts` (dialect matrix), `turso-20444-empty-operator.test.ts` (local and remote held to one row set, plus `count()`), `memory-20444-empty-operator.test.ts` (live, matcher, analytics face, and the one pinned cell where the declared row and the by-value reading part), `mongodb-20444-empty-operator.test.ts` (emitted documents and their rows; a live-`mongod` half runs when the opt-in server is available), `matches-filter-empty-operator.test.ts`, `having-empty-operator.test.ts`. - Extended: the withheld-refusal seam tests of driver-sql (three new builders, one needing the `'unknown'` dialect) and of the turso remote transport (two methods, and the local / remote one-sentence table), and driver-memory's operator-key clobber sweep (now declares its column and covers `$empty`). - Full package suites on the pre-merge head `ea3d95994`, each run through the verify lock: driver-sql 197 files passed, 1 failed, 11 skipped — the failure was the withheld-refusal seam enumeration, which the new refusal builders owed rows; they are added in this PR and that file re-ran green (107 tests); driver-turso 77 files, 2080 passed; driver-sqlite-wasm 36 files, 665 passed; driver-memory 59 files, 1419 passed; driver-mongodb 29 passed / 5 skipped, 656 passed; formula 42 files, 1227 passed; objectql `--project local` 332 files, 6636 passed; service-analytics 134 files, 3165 passed. - On the merged head `436a10a3e`: `typecheck` exit 0 for all seven packages above (spec's own `typecheck` ran green on the pre-merge head); the `$empty` suites and every `FILTER_LOGIC_CASES` harness re-run green (driver-sql 154 passed / 4 skipped, turso 240, sqlite-wasm 37, memory 194, mongodb 65 / 50 skipped, formula 43, objectql 36, service-analytics 72, spec 73). - **Ablations**, each through `scripts/ablation-replace.mjs` on the committed tree with a restore trap; every leg restored to blob == HEAD with `git diff HEAD` empty: - A1 — driver-sql's text arm drops its `''` limb: 4 red in `sql-driver-20444-empty-operator.test.ts`; the `FILTER_LOGIC_CASES` sweep stayed green, which is the measured proof the shared rows do not discriminate the text row. - A2 — driver-memory's multi-value lowering written as `$in: [null, []]`: 8 red (mingo does not match a stored `[]` that way). - A3 — formula's arm removed (the silent `false` back): 13 red, 6 in the new pins and all 7 `$empty` rows of the shared table. The first A3 attempt did not run: its replacement text already occurred in the anchor, the tool refused the non-rising count, and the file was restored; it was re-run with a distinct replacement. - `check:driver-conformance` read before and after: 50 covered cells, 0 DEBT, 0 exempt on both sides. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `436a10a3e` (after merging `origin/main` with a merge commit) derived 91 commands; all 91 ran, each exit code recorded before any pipe. `--ran` reconciliation: "91 derived famil(ies) accounted for — 89 run, 2 NOT-MEASURED". NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 (`PREREQUISITE NOT MET`: they need the whole workspace built). Narrowed probe instead: the built CJS entry of each changed package loads under `require` (driver-sql 51 exports, driver-turso 14, driver-memory 23, driver-mongodb 11, formula 47, objectql 178). Lint, narrowed: `eslint.config.mjs` lints `packages/**/*.{ts,tsx,mts,cts}` with no type-aware parsing (no `parserOptions.project`), so no verdict on an untouched file can move with this diff. `pnpm exec eslint --no-inline-config --format json` over the 26 changed `.ts` files: 26 file entries, 0 errors, 0 warnings. ## Deviations - **service-analytics test files** (`read-scope-sql-conformance.test.ts`, `native-sql-filter-logic-conformance.test.ts`) are edited, although the order bars service-analytics. The edit is test-only: it hands each harness the fixture's declaration so the new shared rows pass (H4). No service-analytics source moves. - **`packages/spec/src/data/filter-logic-conformance.ts`** gains the seven rows and a header paragraph, a declared cross-lane test-data edit (the claim names it). ## Acceptance notes (observations, not filed) - The `FILTER_OPERATORS` TSDoc table in `packages/spec/src/data/filter.zod.ts` still says no face answers `$empty` and lists each face as refusing it; `filter-empty-operator.ts`' header still says nothing in the repository calls the expansion. Both were already stale after PR objectstack-ai#20498 and are staler now. Carrier: the flip card, which rewrites that paragraph when it adds the operator. - `@objectstack/formula`'s `matchesFilterCondition` has accepted the object's declared columns (`options.fields`, type and `multiple`) since PR objectstack-ai#20427, after ruling A was taken. With them it could answer `$empty` by the declared row, as the read side of the same RLS policy does. This PR keeps the by-value reading the ruling and the card assign; the two part only on a stored state the declaration does not predict. Carrier: none named. - For the flip card: the engine's front door is the one remaining refusal on the ObjectQL execute path PR objectstack-ai#20498 names. `driver-memory`'s analytics face refuses `$empty` exactly as it refuses `$null` today, so the flip moves nothing there. --- _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>
Fixes #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 #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 asoptions.fields. Given them, every{ $field }comparison between two declared columns is judged bycrossFieldComparisonVerdictbefore any record is read (record-independent, like the{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 / [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 shape refusals), andcross-classorno-classthrowsINVALID_FILTER/ 400. The message names nothing from the filter (the finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #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, typesMatchesFilterOptions,CrossFieldClassRefusal. Withoutfieldsthe 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 orderloadObjectFieldNamesuses) 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.crossFieldComparisonClassdelegates tocrossFieldColumnVerdictfor every declaredFieldType, 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 thecheckclause closes "The policy reads as a write rule and admits no write at all." ([finding] An RLS predicate comparing two fields of different comparison classes (text vs number, text vs image) passes os validate; on driver-sql the using read answers 400 while the check insert is admitted and stored #20347 ACCEPT note 1) Round 2: the one sentence in the header's [finding] An RLS predicate comparing two fields of different comparison classes (text vs number, text vs image) passes os validate; on driver-sql the using read answers 400 while the check insert is admitted and stored #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-refusedunder protocol major 18 (packages/spec/src/migrations/entries/semantic/18.rls-predicate-cross-class-field-comparison-refused.ts). It names both arms: [finding] An RLS predicate comparing two fields of different comparison classes (text vs number, text vs image) passes os validate; on driver-sql the using read answers 400 while the check insert is admitted and stored #20347's authoring arm (os validate, build, lint and the permission save door) and this write check.packages/spec/src/migrations/registry.tsis regenerated bypnpm --filter @objectstack/spec gen:migration-registry(71 lines inserted, none removed), as PRs fix(formula): refuse an array comparand under $ne and in the equality slot (write-check bypass) #19946, fix(formula)!: refuse comparands that are not one value at the CEL lowering and the write-check evaluator #20259 and fix(formula)!: refuse an ordering comparison whose stored operand holds a list or an object #20310 did. The changeset's marker moves toregisteredwith 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 tocrossFieldColumnVerdictand thatsql-driver-20355-cross-field-class-driver-aliases.test.tspins the alias layer it keeps.sql-driver-20347-cross-field-class-parity.test.tsheld 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 newsql-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:using)using)usingas the checkcheckinsertcheckby-id updaterecord.status != record.amount(text vs number)record.status != record.photo(text vs image)record.status != record.is_open(text vs formula; the card's formula cell)record.status != record.meta(text vs json holding one value)record.amount > record.status(number vs text)record.status != record.title(text vs text, control)The formula cell answers exactly like the other two: the classification gives a formula field no class (
no-class, reasonformula), so it is refused on both sides.driver-memory, measured out of tree (this package cannot declare
@objectstack/driver-memorywithout adriver-memory-censusdisposition): 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=1for every cross-class cell): driver-memory has no{ $field }arm at all and compares the marker object as a literal (#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
multiplecolumn is ano-classcolumn (list-or-object), so a comparison against one is now refused by its declared type, for every record. #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)driver-sqlapplyFilterCondition(sql-driver.ts:16192), and by inheritancedriver-sqlite-wasmand local-modedriver-tursocrossFieldComparisonClassreadscrossFieldColumnVerdict. 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.RemoteTransport.buildWhereSQL(remote-transport.ts:2695){ $field }comparand in remote mode, whatever the classes (uncompilableComparand,remote-transport.ts:4392).compileScopedFilterToSql(read-scope-sql.ts:696){ $field }is declined byNativeSQLStrategy.canHandleand served on the engine path, where face 1 compiles or refuses it (#7598 ruling,read-scope-sql.ts:284). The/analytics/sqlecho refuses the reference outright.lowerAnalyticsWhere(filter-normalizer.ts:2171){ $field }comparand reaches face 1 (filter-normalizer.ts:1453).formulamatchesFilterCondition(matches-filter.ts:305){ $field }comparison bycrossFieldComparisonVerdictwhen the caller supplies the declared columns.having-filter(applyHaving/matchesHaving,having-filter.ts:1131/:1154)havingreference compares columns of the AGGREGATED row (group keys, aggregate aliases), not declaredFieldTypecolumns, so this classification does not cover them (#20127 classifies them separately). HAVING is evaluated in-process on every driver, so there is no read-side twin that could disagree.driver-memorycheckCondition(memory-matcher.ts:361){ $field }arm to attach a class rule to (#15104, closed not planned). Measured above: its read compares the marker as a literal.driver-mongodbtranslateFieldOperators(mongodb-filter.ts:962){ $field }reference (#19949,mongodb-filter.ts:264).Tests (measured head c80202c)
formula: newmatches-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/$notnesting; theaddDaysform; undeclared, dotted and unjudged columns left alone; withoutfieldsunchanged; the withheld message and the carried diagnostic. 22/22 at c80202c. Full package (at 0ee6f4c; the merge ofmainbrought no change to formula, driver-sql, lint or plugin-security): 41 files, 1213 passed;typecheckexit 0 (check:test-typecheckOK, debt unchanged).plugin-security: newrls-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 assertscode+statusand 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);typecheckexit 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;typecheckexit 0.lint: 115 files, 5314 passed;typecheckexit 0.validate-rls-predicate-enforceability.cross-class-field.test.ts: 569/569 at c80202c.scripts/ablation-replace.mjswith a restore trap:if (refusal) throw crossFieldClassError(refusal);was replaced byvoid refusal;. Anchor 1 → 0, blob 8e920434 → 58b1e295. formula was rebuilt (exit 0).ablation-dist-preflightread 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 8e920434,git diff HEADempty, rebuilt, marker present, tree clean; 22/22 and 48/48 again.matchesFilterCondition(image as any, f as any, checkFieldOptions)was stripped of its third argument (blob af0a9563 → 8f254f2f). plugin-security went 45 failed / 3 passed. Restored blob == HEAD, 48/48.cross-field-referencewent 56/56 green, so the rewire did not move an alias. Restored blob == HEAD, tree clean.eslint --no-inline-config --format jsonover the 10.tsfiles this diff touches plus the 36 themainmerge brought in reported 46 files, 0 errors, 0 warnings (no file ignored). Type-aware linting is not enabled (eslint.config.mjs:328: noparserOptions.project, no typed rules), so this diff cannot move the verdict of any file it does not touch. The fullpnpm lintis CI's.Gates (c80202c, after merging
origin/main50e273f)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 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-loadsandcheck: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".--ranreconciles: 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
Clause-②: yes (narrowing)in the changeset and in this body, because formula's root entry grows.registeredmarker and'@objectstack/spec': patch, the two spec comments, and the one lint header sentence.origin/mainwas merged twice with true merge commits: dbddf02, then e01d347, which carries driver-sql:reclaimSpace()frees ONE freelist page per call, not the freelist —PRAGMA incremental_vacuummeasured 300 → 299 pages on SQLite, so the lifecycle sweep never returns bulk-deleted space (ADR-0057 §3.4) #20106'sreclaimSpace. Everything below was measured at cc0bf6e.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 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.--ranreconciles: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN.pnpm --filter @objectstack/spec check:generated: all 15 artifacts are up to date, includingcheck:migration-registry,check:spec-changesandcheck:upgrade-guide.pnpm check:adr-0087-registration: 0.--project local src/migrationsplus the classification test: 4 files, 178 passed.origin/mainwas merged twice more with true merge commits: 87c37ae (4e430ba), then 0fcb101 (2698fa1). Everything below was measured at 2698fa1.reasongives 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.acceptanceCriteriasays the read answers 400 "on the SQL drivers".replacementlists all four reference types,treeincluded.registry.tsis regenerated bygen:migration-registryand changes only in the entry's lines.crossFieldColumnVerdict, where it had named the retired parity test.dispatch-gates --commandsderived 96 families; all 96 ran and exit 0, and--ranreconciles 96/96 with 0 NOT-MEASURED.pnpm --filter @objectstack/spec check:generated: all 15 artifacts are up to date. spec--project local src/migrationsplus 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 andwriteCheckFieldOptions) andrls-compiler.ts(compiledPolicyNameOf) are source, where the claim named plugin-security for tests only. H2 held: the comparison is evaluated inmatches-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. IncrossClassConsequence, the changed text is the sharedwriteconstant plus the check branch's closing sentence. Theusingbranch interpolates the same constant, so theusingfinding'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.packages/specfiles (the D3 entry, its regeneratedregistry.tsand the two comments) and the lint header sentence are in the claim re-posted as 5868966379.Acceptance notes
security.explain(served at/security/explain) answers a read of a row scoped byrecord.status != record.amountwithvisible: true, decidedBy: rls, whilefindanswersINVALID_FILTER/ 400. Measured through the security service on all three SQL drivers. Its record attribution callsmatchesFilterConditionwithout 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.listHoldingComparisonssecond-spelling note is unchanged by this PR.$field编译为列对列比较(cross-field comparison push-down) #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.addDaysarm (corrected in rounds 2 and 3). driver-sql's read refuses anaddDaysreference with 400 when its base is not adateordatetimecolumn, 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:addWholeDaysreads it withDate.parse.addWholeDaysadds the offset to any finite number.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 ([PM seat] domain:engine — 🟢 os-litant · session_01EUBvqtauTDmHi2ZgY759p2 #6367) measures reach through the real write door; a card follows only if a public door admits such a write
$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 (closed, not planned). The memory read still admits; the write refuses.reclaimSpace()frees ONE freelist page per call, not the freelist —PRAGMA incremental_vacuummeasured 300 → 299 pages on SQLite, so the lifecycle sweep never returns bulk-deleted space (ADR-0057 §3.4) #20106 (reclaimSpaceinsql-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