Skip to content

Commit aeb0557

Browse files
fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355) (#20427)
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 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 #5240 / #19886 shape refusals), and `cross-class` or `no-class` throws `INVALID_FILTER` / 400. The message names nothing from the filter (the #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." (#20347 ACCEPT note 1) Round 2: the one sentence in the header's #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: #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 #19946, #20259 and #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 #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 (#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. #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 (#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 (#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 (#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 (#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 #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 #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. - #20347's `listHoldingComparisons` second-spelling note is unchanged by this PR. - The write check now applies ruling 4 of #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 (#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 #15104 (closed, not planned). The memory read still admits; the write refuses. - #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>
1 parent 8cdbe0c commit aeb0557

16 files changed

Lines changed: 1117 additions & 167 deletions
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/formula': minor
4+
'@objectstack/driver-sql': patch
5+
'@objectstack/lint': patch
6+
'@objectstack/spec': patch
7+
---
8+
9+
fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355)
10+
11+
Clause-②: yes (narrowing)
12+
13+
<!-- adr-0087: registered rls-predicate-cross-class-field-comparison-refused -->
14+
15+
**BREAKING** — an accept-set narrowing on the row-level write check, shipped as `minor`
16+
under the launch-window convention (`check-changeset-no-major` refuses `major` until GA;
17+
breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the
18+
level). The hand-migration prescription is registered under protocol major 18 as
19+
`rls-predicate-cross-class-field-comparison-refused`, one ADR-0087 D3 entry for the whole
20+
family: the authoring arm `os validate` gained in #20347 and this write-check arm.
21+
22+
**What changed.** A row-level policy that compares two fields of no shared comparison
23+
class — `record.status != record.amount` (text and a number), `record.status !=
24+
record.photo` (text and a file field), `record.status != record.is_open` (text and a
25+
formula field), `record.status != record.meta` (text and a json field) — already had
26+
every read it scopes refused with `INVALID_FILTER` / 400 on the SQL drivers, because
27+
driver-sql compiles a column-to-column comparison only within one class. The write
28+
check did not know the rule: it compared the two raw values in-process, so an insert
29+
or update the policy's `check` judges (or its `using`, standing in as the check) was
30+
admitted and stored whenever that comparison happened to hold. Measured through
31+
plugin-security and ObjectQL on SQLite, sqlite-wasm and PostgreSQL. The write check
32+
now refuses the comparison too, with the read's envelope, `INVALID_FILTER` / 400, for
33+
every insert (single or array), by-id update and predicate update it judges, and
34+
nothing is stored. The same-class comparisons it always compared are compared as
35+
before. The 400 names no column of the policy; the server log names the policy and
36+
both columns. A comparison against a json or `multiple` field is refused by its
37+
declared type now, where it used to be judged by the value each record held.
38+
39+
**`@objectstack/formula`.** `matchesFilterCondition(record, filter, options?)` takes an
40+
optional third argument: `options.fields`, the object's declared columns (`type` and
41+
`multiple` per field name). Given it, every `{ $field }` comparison between two
42+
declared columns is judged by `crossFieldComparisonVerdict` from
43+
`@objectstack/spec/data` before any record is read, and one the platform defines no
44+
answer for throws `INVALID_FILTER` / 400. Without it the evaluator behaves exactly as
45+
before. Two new exports go with it: `findCrossFieldClassRefusal(filter, fields)`, the
46+
pure judgement, and `crossFieldClassRefusalCarriedBy(error)`, which reads the refused
47+
comparison off the error for a server-side log.
48+
49+
**`@objectstack/driver-sql`.** `crossFieldComparisonClass` reads the same export
50+
(`crossFieldColumnVerdict`) instead of keeping its own copy of the classification, and
51+
layers above it only its internal type aliases. Every read answers as before.
52+
53+
**`@objectstack/lint`.** The `rls-predicate-unenforceable` finding for such a
54+
comparison now states the write answer the runtime gives: the in-process write check
55+
refuses it by the same classification and stores nothing.
56+
57+
**If a policy of yours is refused.** The platform defines no comparison between those
58+
two columns on any path, so the policy never protected a read either. Compare a field
59+
only with a field of the same class — a number with a number, text with text, a
60+
boolean with a boolean, a date with a date, a datetime with a datetime, a time of day
61+
with a time of day — or, if the two columns do hold comparable values, correct the
62+
declaration of the one declared with the wrong type. `os validate` names every such
63+
comparison.

‎packages/drivers/driver-sql/src/sql-driver-20347-cross-field-class-parity.test.ts‎

Lines changed: 0 additions & 128 deletions
This file was deleted.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20355] This driver's cross-field comparison class READS the spec's
5+
* classification (`crossFieldColumnVerdict`, `@objectstack/spec/data`) for
6+
* every declared `FieldType`, and layers above it only what the spec leaves to
7+
* a driver: its internal aliases, which are not `FieldType` members and which
8+
* the spec answers `undefined` for.
9+
*
10+
* The `FieldType` half needs no pin of its own any more — it IS the export,
11+
* which `filter-cross-field-comparison-class.test.ts` pins in the spec. The
12+
* #20347 parity test that held this driver's private copy equal to the export
13+
* over every declared pair retired with the copy. What this file pins is the
14+
* alias layer the rewire kept, read off the driver's own column sets:
15+
*
16+
* | declared `type` | class | why |
17+
* |---|---|---|
18+
* | `integer` / `int` / `float` | numeric | `NUMERIC_SCALAR_TYPES`' driver-internal aliases |
19+
* | `object` / `array` | none — refused | `JSON_COLUMN_TYPES`' driver-internal aliases |
20+
*
21+
* (The absent-type `string` default is not pinned: `createColumn` refuses a
22+
* field that declares no `type`, so no managed table carries one to compare.)
23+
*
24+
* Each cell is read from what the driver DOES — the comparison compiles and
25+
* runs, or it is refused in the `INVALID_FILTER` / 400 envelope — so an alias
26+
* the rewire reclassified shows up as an admitted refusal or a refused
27+
* admission, never as prose.
28+
*/
29+
30+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
31+
import { SqlDriver, withheldFilterDiagnosticOf } from './index.js';
32+
import type { FilterCondition } from '@objectstack/spec/data';
33+
34+
const OBJ = 'cfc_alias_probe';
35+
36+
const FIELDS: Record<string, Record<string, unknown>> = {
37+
id: { name: 'id', type: 'text' },
38+
f_number: { name: 'f_number', type: 'number' },
39+
f_integer: { name: 'f_integer', type: 'integer' },
40+
f_int: { name: 'f_int', type: 'int' },
41+
f_float: { name: 'f_float', type: 'float' },
42+
f_text: { name: 'f_text', type: 'text' },
43+
f_object: { name: 'f_object', type: 'object' },
44+
f_array: { name: 'f_array', type: 'array' },
45+
f_json: { name: 'f_json', type: 'json' },
46+
};
47+
48+
type Observed = 'admitted' | 'refused';
49+
50+
const CELLS: ReadonlyArray<[target: string, ref: string, expected: Observed]> = [
51+
['f_integer', 'f_number', 'admitted'],
52+
['f_int', 'f_float', 'admitted'],
53+
['f_float', 'f_number', 'admitted'],
54+
['f_integer', 'f_text', 'refused'],
55+
['f_object', 'f_text', 'refused'],
56+
['f_text', 'f_array', 'refused'],
57+
['f_object', 'f_object', 'refused'],
58+
['f_object', 'f_json', 'refused'],
59+
];
60+
61+
describe('[#20355] driver-sql cross-field class — the driver-internal aliases above the spec classification', () => {
62+
let driver: SqlDriver;
63+
64+
beforeAll(async () => {
65+
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
66+
await driver.initObjects([{ name: OBJ, fields: FIELDS } as never]);
67+
});
68+
69+
afterAll(async () => {
70+
await driver.disconnect();
71+
});
72+
73+
async function observe(target: string, ref: string): Promise<Observed> {
74+
const where = { [target]: { $eq: { $field: ref } } } as FilterCondition;
75+
try {
76+
await driver.find(OBJ, { fields: ['id'], where });
77+
return 'admitted';
78+
} catch (e) {
79+
const err = e as { code?: unknown; status?: unknown };
80+
expect({ code: err.code, status: err.status }, `${target} vs ${ref}: ${String(e)}`)
81+
.toEqual({ code: 'INVALID_FILTER', status: 400 });
82+
expect(withheldFilterDiagnosticOf(e), `${target} vs ${ref}: not the cross-field boundary's refusal`)
83+
.toMatch(/stored as|no scalar stored column/);
84+
return 'refused';
85+
}
86+
}
87+
88+
for (const [target, ref, expected] of CELLS) {
89+
it(`${target} vs ${ref}: ${expected}, in both orders`, async () => {
90+
expect(await observe(target, ref)).toBe(expected);
91+
expect(await observe(ref, target)).toBe(expected);
92+
});
93+
}
94+
});

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERI
2626
// `os generate migration` reads the SAME table, in both of its formats — that
2727
// shared table IS the repair, so ⛔ never restate one of its numbers here.
2828
import { numericColumnFor } from '@objectstack/spec/data';
29+
// [#20355] The cross-field comparison class, defined once in the spec (#20347,
30+
// lifted case for case from this driver's #5222 boundary). This driver READS it
31+
// — `crossFieldComparisonClass` below delegates — so the read it compiles and
32+
// the write check `@objectstack/formula` evaluates judge one comparison by one
33+
// rule.
34+
import { crossFieldColumnVerdict, type CrossFieldComparisonClass } from '@objectstack/spec/data';
2935
// [#5659] The Filter Protocol's boolean identity reduction — `$and: []` is TRUE,
3036
// `$or: []` is FALSE, `{}` is a TRUE disjunct, `$not: {}` is FALSE. One
3137
// implementation for all four consumers, proven against the same
@@ -2729,6 +2735,16 @@ const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet<string> = new Set([
27292735
* [#5222] The comparison class a declared field's stored column belongs to, or
27302736
* `null` for a field no column-to-column comparison can be compiled against.
27312737
*
2738+
* [#20355] The classification is the SPEC'S now — `crossFieldColumnVerdict`
2739+
* (`@objectstack/spec/data`), lifted case for case from this function by
2740+
* #20347 — and this function is its reader for the one thing the spec leaves
2741+
* to a driver: its internal aliases. The write check (`@objectstack/formula`'s
2742+
* `matchesFilterCondition`, handed the object's declared columns by the RLS
2743+
* write gate) and the authoring door (`@objectstack/lint`) read the same
2744+
* export, so a policy's comparison has one answer on the read, on the write
2745+
* and at `os validate`. The reasoning below is the classification's, kept
2746+
* here because this driver is where it was measured.
2747+
*
27322748
* Cross-field comparison is only emitted between two columns of the SAME
27332749
* class. One class = one storage shape on both sides of one row, which is what
27342750
* makes the SQL answer provably the memory evaluator's answer (the cross-path
@@ -2771,19 +2787,22 @@ const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet<string> = new Set([
27712787
*/
27722788
function crossFieldComparisonClass(
27732789
decl: Record<string, unknown>,
2774-
): 'numeric' | 'text' | 'boolean' | 'date' | 'datetime' | 'time' | null {
2790+
): CrossFieldComparisonClass | null {
27752791
const type = String((decl as { type?: unknown }).type || 'string');
2776-
if (isMultiValuedColumn(type, decl)) return null;
2777-
if (type === 'formula') return null;
2778-
if (JSON_COLUMN_TYPES.has(type) || FILE_REFERENCE_TYPES.has(type)) return null;
2792+
// [#20355] A declared `FieldType` is the spec's to classify — the one
2793+
// classification the write check and the authoring door read too. Its
2794+
// `multiple` reading is `isMultiValueField`'s, the same predicate
2795+
// `isMultiValuedColumn` asks.
2796+
const verdict = crossFieldColumnVerdict({ type, multiple: (decl as { multiple?: unknown }).multiple === true });
2797+
if (verdict !== undefined) return verdict.kind === 'class' ? verdict.class : null;
2798+
// A type outside `FieldType` is a driver-internal alias the spec does not
2799+
// judge (its module header: "a driver layers its own aliases above this
2800+
// table"). This driver's are read off its own column sets, never restated:
2801+
// `object` / `array` are JSON columns ({@link JSON_COLUMN_TYPES}), `integer` /
2802+
// `int` / `float` numeric ones ({@link NUMERIC_SCALAR_TYPES}), and everything
2803+
// else — the absent-type default `string` included — is stored as TEXT.
2804+
if (JSON_COLUMN_TYPES.has(type)) return null;
27792805
if (NUMERIC_SCALAR_TYPES.has(type)) return 'numeric';
2780-
if (type === 'boolean' || type === 'toggle') return 'boolean';
2781-
if (type === 'date') return 'date';
2782-
if (type === 'datetime') return 'datetime';
2783-
if (type === 'time') return 'time';
2784-
// Everything else `createColumn` stores as TEXT: string/text/textarea/html/
2785-
// markdown/email/url/phone/password, select, lookup/user (row ids),
2786-
// autonumber, and the unknown-type default.
27872806
return 'text';
27882807
}
27892808

‎packages/formula/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ export { __resetPushdownLimitWarnings } from './cel-to-filter';
116116
// conditions ARE the red/green line.
117117
export { isSupportedRlsExpression, sqlPredicateToCel } from './rls-predicate';
118118
export { matchesFilterCondition } from './matches-filter';
119+
// #20355 — the write check's comparison-class rule: the spec's cross-field
120+
// classification, judged over a filter against the object's declared columns,
121+
// and the reader a caller uses to log the refused comparison server-side.
122+
export { crossFieldClassRefusalCarriedBy, findCrossFieldClassRefusal } from './matches-filter';
123+
export type { CrossFieldClassRefusal, MatchesFilterOptions } from './matches-filter';
119124
// #13594 — the function-EXISTENCE verdict, isolated from the rest of what
120125
// cel-js's `check()` has an opinion about. Published for the same reason as
121126
// `firstUndeclaredReference` above and under the same discipline: the answer to

0 commit comments

Comments
 (0)