Skip to content

fix(formula)!: refuse an ordering comparison whose stored operand holds a list or an object - #20310

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19886-stage-2e-stored-list-ordering
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19886-stage-2e-stored-list-ordering

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #19886
Clause-②: no

Stage 2e, remainder items 2 and 3 of release 5858069107, as ruled on the fork report 5858423045 in seat ruling 5858444849 (Q1 A, Q2 A, Q3 A). Claim 5858140051. Base 17bd3187, merged with origin/main ae8e3ca0 through scripts/pm/os-regen-merge.sh; head 2e54475b.

What changes

  • @objectstack/formula matchesFilterCondition. One arm in evalOp: $gt / $gte / $lt / $lte and $between refuse, per record, a STORED operand (actual) that is a list or a plain object, whatever the comparand. It uses the stage 2d predicate isNonScalarValue and the stage 2d error arrayComparandError (INVALID_FILTER / 400). null and Date do not move, and neither does equality ($eq, $ne, implicit equality, $in, $nin) against a stored list.
  • The norm it follows is driver-sql's declared refusal (driver-sql: a declared operator on a multiple: true (JSON array) column silently answers wrong — $in/$eq always zero rows, $nin returns the rows it was asked to EXCLUDE #7398, JSON_COLUMN_INCOMPATIBLE_OPERATORS): every ordering comparison, and $between, on a column it stores as JSON text is refused by declared type with 400. The evaluator has no schema, so it judges the value on the record.
  • @objectstack/spec. A new ADR-0087 semantic entry, rls-predicate-stored-list-ordering-refused, plus the Q3 correction to the stage 2a entry rls-predicate-array-comparand-refused. Only the generated regions of registry.ts move, at about :12722 and :12749.
  • Changeset .changeset/19886-stored-list-ordering-refused.md: Clause-②: no (narrowing), BREAKING, registered rls-predicate-stored-list-ordering-refused. It names the three Q1 moves and explain's four cells.

No change to explain, packages/lint, security-plugin.ts, cel-to-filter.ts or the shared face.

Measured cells

The stack is real: SecurityPlugin + ObjectQL on driver-sql (better-sqlite3) and driver-memory. Before is 17bd3187, cells-before.json, sha256 a3d2b42d49563cca. After is head 2e54475b, cells-head.json, sha256 21548839aac31220. The after table is byte-identical to the fork report's temporary measurement of the same route.

Each cell reads before → after. Every 400 is INVALID_FILTER; every 403 is PERMISSION_DENIED. "(driver)" marks driver-sql's own refusal.

id predicate list on the write check insert (sql / memory) by-id update carrying the list by-id update of another field, stored list using read, driver-sql using read, driver-memory
O1 record.tags > 'a' ['m'] admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 400 (driver) → 400 (driver) rows r_list_az, r_list_m, r_scalar → rows r_list_az, r_list_m, r_scalar
O2 record.tags < 'z' ['m'] admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 400 (driver) → 400 (driver) rows r_list_az, r_list_m, r_scalar → rows r_list_az, r_list_m, r_scalar
O3 record.tags >= 'a' ['m'] admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 400 (driver) → 400 (driver) rows r_list_az, r_list_m, r_scalar → rows r_list_az, r_list_m, r_scalar
O4 record.tags <= 'z' ['m'] admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 400 (driver) → 400 (driver) rows r_list_az, r_list_m, r_scalar → rows r_list_az, r_list_m, r_scalar
O5 record.tags > 'n' ['m'] 403 / 403 → 400 / 400 403 / 403 → 400 / 400 403 / 403 → 400 / 400 400 (driver) → 400 (driver) rows r_list_az → rows r_list_az
O6 record.meta < 'a' {a:1} admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 400 (driver) → 400 (driver) no rows → no rows
O7 record.meta > 'a' {a:1} 403 / 403 → 400 / 400 403 / 403 → 400 / 400 403 / 403 → 400 / 400 400 (driver) → 400 (driver) rows r_scalar → rows r_scalar
O8 record.watchers > 'a' ['p1'] admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 400 (driver) → 400 (driver) rows r_list_az, r_list_m → rows r_list_az, r_list_m
O9 record.watchers < 'p2' ['p1'] admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 400 (driver) → 400 (driver) rows r_list_az, r_list_m → rows r_list_az, r_list_m
C1 record.status > 'a' status: ['m'] admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 admitted / admitted → admitted / admitted rows r_list_az, r_list_m, r_null, r_obj, r_scalar → rows r_list_az, r_list_m, r_null, r_obj, r_scalar rows r_list_az, r_list_m, r_null, r_obj, r_scalar → rows r_list_az, r_list_m, r_null, r_obj, r_scalar
C2 record.amount > 10 amount: [500] admitted / admitted → 400 / 400 admitted / admitted → 400 / 400 403 / admitted → 403 / 400 no rows → no rows no rows → no rows
C3 record.tags == 'm' ['m'] 403 / 403 → 403 / 403 403 / 403 → 403 / 403 403 / 403 → 403 / 403 400 (driver) → 400 (driver) rows r_list_m, r_scalar → rows r_list_m, r_scalar

Notes on the table:

Explain (Q2 A: named, not changed)

security/explain evaluates the business RLS layer in-process. Both 400 paths, this stage's (O1) and 2d's { $field } (X1), per operation:

explain operation driver enforcement answers equal
read driver-sql 400 (the driver's refusal) yes
update driver-memory 400 (the post-image check) yes
update driver-sql 403: the pre-image gate fails closed on the driver's 400 no, both deny
read driver-memory the rows its element-wise read admits no, the test driver

Premises

  • P1 holds. The census covered packages/** and examples/** at 17bd3187, and cloud 96eb092. 0 shipped using/check/sharing-condition predicates use any ordering operator.
  • P2 as restated by the ruling: it holds for every production door, and parts only on the frozen driver-memory (O5, O6).
  • P3 has four cells, above.

Refusal text (the one changed sentence, quoted)

Before (the tail of the first clause of arrayComparandError):

… or as a member of an "$in" / "$nin" list, or a column on either side of a { "$field" } comparison that holds a list or an object. A list is not one comparable value.

After:

… or as a member of an "$in" / "$nin" list, or a column that holds a list or an object on either side of a { "$field" } comparison or on the compared side of an ordering operator or "$between". A list is not one comparable value.

The rest of the message is unchanged. It still withholds the field, the operator and the value, and a pin asserts that for the stored side too.

Q3, the stage 2a entry's replacement now ends:

Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before

Pins

  • packages/formula/src/matches-filter-array-comparand.test.ts, new stage-2e block of 38 tests:
    • six stored shapes × five operators (a one- and a multi-element list, an empty list, a list of lists, an object, an empty object);
    • the depths evaluation reaches, including under $not;
    • every kind of comparand (number, Date, null, { $field });
    • a list written into a scalar field;
    • per-record controls (a scalar compared), null, a missing field and a Date;
    • equality against a stored list, unchanged;
    • the message withholding the field and value.
    • The 2a/2d pins are unchanged, including "a scalar comparand against a STORED array is untouched".
  • packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts, 94 tests, real stack on driver-sql (better-sqlite3) and driver-sqlite-wasm:
    • O1–O9, C1, C2, each on:
      • the check insert (400, nothing stored), beside its scalar or null control;
      • the by-id update carrying the list (400, row unchanged);
      • the by-id update of another field on a stored-list row (400, row unchanged; json and multiple columns);
      • the using read (driver's own 400; C1 and C2 compare).
    • The null, Date and C3 controls.
    • The explain cells for O1 and X1 (read 400 = find 400; update 400 against a 403 by-id update).
    • driver-memory is not pinned in-tree. Declaring @objectstack/driver-memory in plugin-security needs a driver-memory-census ledger disposition, which is a maintainer ruling; the in-repo precedent is bootstrap-platform-admin-promotion-selection.test.ts. Its cells above are measured out of tree.

Ablation (one-shot proof that the pins can fail)

  • Base. Taken from committed 18e5a78b, via scripts/ablation-replace.mjs. The arm was disarmed (op === 'ZZ_ABL_19886E' && …): anchor 1 → 0, blob 6e061b4c → af46be42.
  • Build. Formula rebuilt, and ablation-dist-preflight found the marker in dist/index.js and dist/index.mjs.
  • Red.
    • formula: 34 of 129 tests red — the 30-cell matrix, depth, comparand kinds, list in a scalar field, and message.
    • plugin-security: 64 of 94 tests red — every list-holding insert and update cell and the O1 explain cell, on both drivers.
    • The 30 still green are exactly the using reads, the controls and 2d's X1 explain.
  • Restore. Back to the HEAD blob 6e061b4c; git diff HEAD empty; rebuilt; preflight --absent passes on all 6 built files.

Tests and gates (at head 2e54475b)

Build and suites. Full turbo run build exited 0 (72/72). All of these exit 0, each through scripts/pm/os-verify-lock.sh:

suite files tests
formula 39 1165
plugin-security 141 2990
new pin alone 1 94
lint — 4295 (no pin moved)
objectql having (engine-aggregate-having-comparand-shape, having-filter) 2 176
plugin-sharing 37 913
driver-memory 56 1374
driver-sql consumer files of the evaluator 8 474, 4 skipped
driver-sqlite-wasm consumer files 6 217
platform-objects consumer — 18

Typechecks. formula and plugin-security exit 0. Both run check:test-typecheck over tsconfig.test.json, which includes src/**/*, so the test files are covered.

Gates. dispatch-gates --commands at 2e54475b derives 90 commands. All 90 exit 0, and --ran reconciles: "90 run, 0 NOT-MEASURED". Also run, all exit 0:

  • the dispatch lead's pnpm check:authz-resolver;
  • check:generated (15/15 up to date);
  • the four roster gates whose roster lies under a changed path (check-changeset-fixed, check:meta-url-spelling, check:error-code-casing, check:filter-alias-parity);
  • check:engine-double-contract;
  • check:doc-authoring.

check:entry-nameability prints a pre-existing partial NOT MEASURED for @objectstack/spec/api-assembled and /qa, unrelated to this diff.

eslint (narrowed, measured). Covered here:

  1. The population is the 6 changed .ts files, each resolved by eslint's own config (--print-config).
  2. --format json counts 6 files, 0 errors, 0 warnings, under --no-inline-config.
  3. eslint.config.mjs never enables type-aware linting (no parserOptions.project), so this diff cannot move the verdict of an untouched file.

Repo-wide pnpm lint is CI's.

Remainder, still open on this card

Acceptance notes (noted, not filed)

  • Explain fidelity. On driver-sql json/multiple predicates, explain reports visible=true on rows where the enforced read answers 400 and the by-id update 403. Measured on 17bd3187 before any change, cells O1–O4, O8, O9, C3. Reach measured at explain(); the HTTP door was not driven.
  • having $between. It keeps a NULL group while $gte with $lte excludes it, and numeric bounds compare NULL as 0 (both spellings keep NULL for [-10, 10]). Reach is engine.aggregate; no REST door was driven.
  • C2 at the REST data door, measurement only, for the seat to file. On the CRM dev server with driver-sql, better-sqlite3, POST /api/v1/data/crm_activity with duration_minutes: [500] into a number field, no RLS involved, answered 201. SQLite stored it as TEXT '[500]', and GET returns the string "[500]". [5, 7] answers 400 VALIDATION_FAILED; a scalar 500 is stored as a real. Not fixed here.
  • Correction of review 5857896186 ③. "driver-sql compares the JSON text on the read" is false at 17bd3187: driver-sql refuses with 400 (driver-sql: a declared operator on a multiple: true (JSON array) column silently answers wrong — $in/$eq always zero rows, $nin returns the rows it was asked to EXCLUDE #7398). The seat corrected it in 5858444849.

Generated by Claude Code

…ds a list or an object

The write-check evaluator compared a list's JavaScript string form under
$gt / $gte / $lt / $lte and $between, so a row-level check such as
record.tags > 'a' admitted and stored a write whose json column held ['m'].
It now refuses, per record, with the stage 2d envelope and sentence
(INVALID_FILTER / 400), following driver-sql's declared refusal of an
ordering comparison on a JSON-stored column. null, Date and equality are
untouched. Adds the semantic migration entry and corrects the stage 2a
entry's replacement sentence.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6a6a17b62e8b58d0ee31cbfabf5fc7322032ba94 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from deaabc2a64cffd5f8f4f196e8bba6afe45e106b9 — the merge of head 2e54475b4d164c7c1c79b044e5ff2d8cf51ee289 into base 6a6a17b62e8b58d0ee31cbfabf5fc7322032ba94, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin deaabc2a64cffd5f8f4f196e8bba6afe45e106b9 && git checkout deaabc2a64cffd5f8f4f196e8bba6afe45e106b9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6a6a17b62e8b58d0ee31cbfabf5fc7322032ba94 2e54475b4d164c7c1c79b044e5ff2d8cf51ee289 && git checkout -B drift-repro 6a6a17b62e8b58d0ee31cbfabf5fc7322032ba94 && git merge --no-ff 2e54475b4d164c7c1c79b044e5ff2d8cf51ee289

node scripts/docs-audit/affected-docs.mjs --json 6a6a17b62e8b58d0ee31cbfabf5fc7322032ba94

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2e54475b4d164c7c1c79b044e5ff2d8cf51ee289
Local-runs: probe — the dispatch ordered a measured review on this security card, so one detached worktree was opened at the head and, through the verify lock, ran the PR's pins, a 280-cell real-stack probe on three drivers, a lowering pass over the predicate census and an ablation slice with its restore; removed afterwards

Read: the PR body, the net diff against the merge-base ae8e3ca0 (7 files, +619/−9), its 2 commits (18e5a78b, merge 2e54475b) and the 39 check runs on the head (read 2026-09-27T21:00Z: 33 success, 6 expected skips, 0 failing, 0 in progress; every required context green); card #19886 (body, all 40 comments; in particular the release 5858069107, the claim 5858140051, the fork report 5858423045, the seat ruling 5858444849, the final report 5859465386, the seat ACCEPT 5859479876) and the 2d record 5857896186 on PR #20259; matches-filter.ts (evalOp, assertFilterShape, order, resolveValue, isOperatorMap), sql-driver.ts 3145–3215 (JSON_COLUMN_INCOMPATIBLE_OPERATORS), security-plugin.ts 3150–3175 and 3420–3445, tenant-layer.ts 179, explain-engine.ts 855–885, having-filter.ts 226 and 1030–1360, the 2a, 2d and 2e ADR-0087 entries, the 2a and 2d changesets, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, build-migration-registry.ts, ablation-replace.mjs, ablation-dist-preflight.mjs, record-recognisers.mjs on main 28ad7e4b, and AGENTS.md in full. Ran, in the worktree at this head (pnpm install --frozen-lockfile; the dependency closure built through the lock, 19 tasks): formula 39 files / 1165 tests; the new plugin-security pin 94; five sibling plugin-security pins (2a, 2c/2d comparand faces, explain) 223; the objectql having pair 176; spec check:generated 15/15 up to date; the probe (SecurityPlugin + ObjectQL on driver-sql better-sqlite3, driver-memory and driver-sqlite-wasm, with a json list column, a json object column, a multiple lookup, a text and a number column: 16 predicates × check insert with the list and with a scalar × by-id update carrying the list × by-id update of another field on a stored-list row × using read, null and Date controls, explain read and update beside enforcement for O1 and the 2d path X1, having literal / { $field } / $between on sql and memory, 19 direct evaluator cells); a lowering pass over the census; an ablation of the arm with both pins and the probe re-run, then the restore proved. Outside the lock: gen:migration-registry and check:migration-registry, check-adr-0087-registration --base ae8e3ca0, check-changeset-no-major --base ae8e3ca0, a driverless merge-tree probe against main 28ad7e4b with check:migration-registry on the merged tree, and the predicate census over objectstack 2e54475b and cloud origin/main 96eb092. NOT MEASURED: live mongod, PG and MySQL; the HTTP door (every cell is at the engine or the security service); the derived gate families beyond those named — the head's check-runs are their verdict.

① Derived judgments

  • 1. Fail-closed at every reachable write door — RIGHT. Measured on all three drivers, for O1–O9 (the PR's table) and three more stored shapes (E1 empty list, E2 empty object, E3 list of lists): the check insert carrying the list or object answers INVALID_FILTER/400 and nothing is stored; the by-id update carrying it answers 400 with the row unchanged; the by-id update of ANOTHER field (due) on a row whose stored json or multiple column holds the list answers 400 with the row unchanged — the post-image merges the stored row, which driver-sql and driver-sqlite-wasm hand back parsed and driver-memory hands back raw. C1 and C2 (a list into a text or number column under record.status > 'a' / record.amount > 10): the insert and the update-set answer 400 on all three drivers; before, they were admitted and driver-sql and wasm stored the text '["m"]' / '[500]', driver-memory the raw list. The before table is the ablated run (the arm disarmed reproduces pre-2e behaviour exactly): 130 of 280 cells move (sql 41, memory 43, wasm 41, evaluator 5), every move toward a refusal — admitted → 400, or 403 → 400 where the coerced string compare already denied (O5 ['a','z'] under $gt 'n', O7 {a:1} under $gt 'a', E1, E2). The 2d at-tier reading that a stored list under an ordering bound "still coerces" is confirmed on the before side: O1–O4, O6, O8, O9 and E3 were admitted and stored. Controls unchanged in all 150 other cells: the scalar control per predicate (O1–O4, O7, C1, C2 admitted; O5, O6, O8, O9 denied 403 by the ordinary compare); null in the ordered field denied 403, not refused; a Date compared as an instant (late admitted, early 403); C3 record.tags == 'm' against a stored list 403 on every door, the 2a pin; X1 record.status != record.tags 400 on every door, 2d's arm; all 48 using cells (driver-sql and wasm refuse the json/multiple predicates with their own 400 by declared type, driver-sql: a declared operator on a multiple: true (JSON array) column silently answers wrong — $in/$eq always zero rows, $nin returns the rows it was asked to EXCLUDE #7398, and return rows for the text/number ones; driver-memory returns its element-wise rows). $between at the evaluator: { tags: { $between: ['a','z'] } } over ['m'] was true, now 400; over 'm' true, null false, a Date true, unchanged. Depth: the refusal under $not (was false, the inversion of the coerced answer) is 400; an $or decided before the field is reached still answers true. driver-sqlite-wasm, the pin's second driver, agrees with driver-sql in every cell.
  • 2. No over-refusal — RIGHT. Census: objectstack at 2e54475b, 9,622 files, 1,064 using / check / condition / criteria string occurrences (316 / 223 / 513 / 12), 447 non-test, 552 distinct; cloud origin/main 96eb092, 2,034 files, 25 occurrences (4 / 3 / 18), 7 non-test, 18 distinct. Zero shipped row-level or sharing-rule predicate orders a json or multiple column. The lowering (cel-to-filter.ts) is not in this diff, so no lowering outcome can move; only the evaluator moved, and it moves only on a record whose ordered field holds a list or an object. Of the 101 single-line non-test using / check / sharing-condition strings, the 28 authored predicates all lower (default-permission-sets.ts, rls.zod.ts examples, showcase permission and sharing rules, docs, skills) and none throws at the evaluator over scalar rows; the 73 others are census artefacts (check: script names, template strings, prose fragments, SQL-spelled flow strings) or nested-path forms already unsupported before this PR. Exactly one lowered predicate carries an ordering: record.amount > 100000 on a number column (content/docs/permissions/permissions-matrix.mdx:189), unaffected. Callers of matchesFilterCondition, all four: security-plugin.ts:3168 (the RLS check, the target); :3439 (the tenant wall, whose filter is { organization_id: { $in: [...] } } or a deny — tenant-layer.ts:179 — so no ordering operator ever reaches the arm there); explain-engine.ts:872 (moves: a list or an object under an ordering predicate now answers 400 from explain, declared in the changeset's explain paragraph with its four cells); having-filter.ts:1117 (only a { $field } comparand reaches the evaluator, and 2d's assertComparableReference runs first in evalOp and refuses a list-holding value before this arm is reached — measured byte-identical before and after on both drivers: sql [] / [] / [null], memory [["m"],["a","z"]] / 400 (2d) / [["m"],["a","z"],null]; no objectql move, so its absence from the changeset is right). Every behaviour move is declared: (1) the write check now answers 400 where driver-sql's read answers 400; (2) driver-memory's read keeps its element-wise rows while its write check refuses, pointer [finding] driver-memory's own reference matcher has no $field arm — a cross-field comparand (bare or with addDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104; (3) a list into a scalar column under an ordering check answers 400 instead of being stored stringified; (4) explain's 400 per operation.
  • 3. The sentence — TRUE for every class it names, and it names nothing from the filter. Each class, and the code that refuses it: an array under $ne and in the equality position (assertFilterShape, ARRAY_REFUSED_OPERATORS); an array under $gt / $gte / $lt / $lte (same walk; $between is correctly not in that list, its comparand is a two-list); an array member of $in / $nin (LIST_MEMBER_OPERATORS); a column holding a list or an object on either side of a { $field } comparison (assertComparableReference, measured X1 and the evaluator cell with the list on the record side); a column holding a list or an object on the compared side of an ordering operator or $between (the new arm, ORDERING_OPERATORS × isNonScalarValue, measured over 30 shape × operator cells). Its explanation "an ordering operator compared the array as a string" is true of the stored side too (['m'] > 'a' is 'm' > 'a'). The text is one constant: measured byte-equal across a $gt, a $lt, a $between and a 2d { $field } refusal, and it withholds the field names and every value probed with secret spellings; its remedy names the json or multiple field class. It matches the formula pin ("the message withholds the field and the stored value") and the 2a/2d pins, which are unchanged.
  • 4. Q2, explain unchanged — RIGHT. explain-engine.ts is not in the file list. The changeset's four cells are true as measured, for this stage's O1 and for 2d's X1 alike: read on driver-sql, explain 400 and the enforced by-id find 400; update on driver-memory, explain 400 and the by-id update 400; update on driver-sql, explain 400 against 403 PERMISSION_DENIED (the pre-image gate fails closed on the driver's 400), both deny, row unchanged; read on driver-memory, explain 400 against the enforced read returning the list row. driver-sqlite-wasm answers as driver-sql. The before side also confirms the acceptance note: at the ablated state explain read on driver-sql reported visible=true where the enforced find answered 400 and the update 403 — pre-existing, and for this class this PR brings explain and the enforced read into agreement on the production driver.
  • 5. Q3 and the ADR-0087 entries — RIGHT. The 2a entry's replacement ends "Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before", which is true: equality, null and Date are unchanged in every cell above, and both record-side arms require a list or an object on some side, so single-valued columns never reach them. The new entry rls-predicate-stored-list-ordering-refused is accurate in each field: surface names check and using-as-check, the four ordering operators, json / multiple / list-into-scalar, and $between at the JSON face "whatever the comparand"; replacement names the one-value rewrite and states what is unchanged; reason states the coercion, driver-sql's declared refusal as the norm, both write doors including the other-field update, the stringified C1/C2 case, the driver-memory parting, the zero-producer count, and adds no D2 conversion (right: the platform cannot tell which comparison the ordering stood in for); acceptanceCriteria is actionable. check-adr-0087-registration --base ae8e3ca0 reads [BREAKING+clause-②-narrowing] registered rls-predicate-stored-list-ordering-refused (new here); check-changeset-no-major --base ae8e3ca0 exits 0 (no major; the level axis is PR-scoped and Check Changeset is success in CI). The registry is generated, not hand-edited: gen:migration-registry at the head rewrites registry.ts byte-identical (blob 4c5da0e9 before and after), check:migration-registry is current (268 semantic), the two hunks (about :12721 and :12748) are the two entry files' output, and check:generated is 15/15. No drift against main: a driverless merge-tree against 28ad7e4b is clean, and on that merged tree check:migration-registry is already current (270 semantic) with no regeneration needed.
  • 6. Pins and ablation — RIGHT. From the committed head, ablation-replace disarmed the arm (op === 'ZZ_ABL_REVIEW_20310' && …; anchor 1 → 0, blob 6e061b4c → e10edafb), formula was rebuilt with --force, and ablation-dist-preflight found the marker in the dist. Red: formula 34 of 129 (the 30-cell matrix, depth, comparand kinds, the list-in-scalar-field case, the message), plugin-security 64 of 94 (every list-holding insert and update cell and the O1 explain cell on both drivers) — identical to the dev's numbers; the 30 still green are the using reads, the controls and 2d's X1 explain. Restore: git checkout HEAD -- plus an index reset, blob back to 6e061b4c = HEAD, git diff HEAD empty, whole-tree git status --porcelain empty once my two untracked review files were deleted, formula rebuilt with --force, preflight --absent exit 0 on both readings (marker absent from all 6 built files, tree clean), formula pin 129/129, plugin-security pin 94/94, and the restored probe's 280 cells byte-equal to the head's.

② Semver level

  • minor + BREAKING — RIGHT. The diff narrows a published accept set (matchesFilterCondition, and through it the RLS write check and explain). AGENTS.md's post-task rule makes (narrowing) BREAKING, and check-changeset-no-major.mjs carries the launch-window convention under which a breaking change ships as minor with the **BREAKING** banner and the ADR-0087 disposition as the carriers; the changeset has all three. Clause-②: no (narrowing) in the changeset and Clause-②: no in the PR body copied from the claim — the same pair as the 2a round and the 2d precedent (PR fix(formula)!: refuse comparands that are not one value at the CEL lowering and the write-check evaluator #20259).
  • Packages — RIGHT. @objectstack/formula: minor (source), @objectstack/plugin-security: minor (a behaviour move with no source change, the 2a precedent) and @objectstack/spec: patch (a new entry plus the regenerated registry, the 2a and 2d precedent). @objectstack/objectql, @objectstack/plugin-sharing and @objectstack/lint are correctly absent: having is measured unchanged, and neither sharing nor lint calls the evaluator. All three are in the one fixed group of 69, so no version moves that would not have moved.
  • registered — RIGHT. 18.rls-predicate-stored-list-ordering-refused.ts is new in this diff, resolves at the head, and the gate reads it as registered here; the marker comment sits in the changeset body beside the Clause-② line, where the gate reads the arm.

③ Boundary flags

  • The read-only shape. main's record-recognisers.mjs brief makes the contract review read-only, with Local-runs: none as its default; this dispatch ordered a measured review, so the record's Local-runs: line declares the one probe round, as the template asks, and every number above is from it.
  • The PR body's C1 "by-id update of another field, stored list" cell is not a stored-list cell — the body's own note says the update edits status itself, and the pin skips that cell for C1 and C2 (readsBackAsList: false). Measured with a genuinely different field on a row whose text column holds the stringified '["m"]' (or number holds '[500]'): driver-sql and wasm answer 403 before and after (the text sorts below the bound), driver-memory, holding the raw list, moves admitted → 400 (the changeset's third move). Both deny after; the body's table is imprecise for that one cell, not wrong about any door. Non-blocking.
  • driver-memory is not pinned in-tree (a driver-memory-census ledger disposition, a maintainer ruling; the seat accepted the deviation). Its 43 moves and its unchanged using reads are measured here out of tree, so the frozen driver's parting — record.tags > 'a' reads a row holding ['m'] while the check now refuses writing it — is a measured, declared fact ([finding] driver-memory's own reference matcher has no $field arm — a cross-field comparand (bare or with addDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104), not an inference.
  • $between reaches the arm only through a JSON filter; CEL lowers a range to $and of $gte and $lte (the changeset says so). Measured at the evaluator only.
  • A census claim's edge. The entry's "no shipped row-level or sharing-rule predicate orders a field at all" holds for the shipped metadata (packages and examples); one docs page carries a sharing example ordering a scalar number column, unaffected by this arm. Non-blocking.
  • NOT a finding: a stale shared build cache. The first probe run, through tsx against the workspace dist, failed to load because @objectstack/core's dist imported PLATFORM_OWNER_EMAIL_ENV from an @objectstack/types dist that lacked it — the shared .turbo cache replaying an older generation into this worktree (AGENTS.md, multi-agent discipline). CI's Build Core and Test Core are green at this head, and plugin-security's vitest config aliases types to src, which is the path the pin and the re-run probe take. Nothing in this diff.
  • Remainder, unchanged by this record: item 1, the compile-door lint arm (os validate), serial after PR feat(lint,metadata-protocol,cli)!: RLS read scopes are admitted by the engine judge when authored, at the save door and at os validate / build / lint #20265, which has landed as 1207baf0. The C2 REST cell ([500] into a number field answers 201 and is stored as text) is the seat's filing record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309. The having $between NULL-group asymmetry (measured here too: memory lit_between keeps the null group where lit_gt drops it) and the driver-sql by-id update's 403 envelope where the read says 400 stay as acceptance notes, both pre-existing.
  • Landing state. Head unchanged at report time (2e54475b, 2 commits), draft, needs:contract-review, base main; the API's mergeable_state was not yet computed at read time, the driverless merge-tree against 28ad7e4b is clean. No governed path; 628 changed lines.

Implemented-by: claude/issue-19886-stage-2e-stored-list-ordering
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 21:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit de091b5 Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19886-stage-2e-stored-list-ordering branch September 27, 2026 21:33
This was referenced Sep 27, 2026
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…on the read refuses — one comparison class, one answer per policy (objectstack-ai#20355) (objectstack-ai#20427)

Fixes objectstack-ai#20355
Clause-②: yes (narrowing)

## What this does

One RLS policy that compares two fields of no shared comparison class
used to get two answers: driver-sql refused the read it scopes
(`INVALID_FILTER` / 400), and the in-process write check compared the
two raw values and admitted and stored the write. Both evaluators now
read objectstack-ai#20347's classification (`crossFieldComparisonVerdict` /
`crossFieldColumnVerdict`, `@objectstack/spec/data`), and the write
check refuses where the read refuses.

- **`@objectstack/formula` (the write-check evaluator).**
`matchesFilterCondition(record, filter, options?)` takes the object's
declared columns as `options.fields`. Given them, every `{ $field }`
comparison between two declared columns is judged by
`crossFieldComparisonVerdict` before any record is read
(record-independent, like the objectstack-ai#5240 / objectstack-ai#19886 shape refusals), and
`cross-class` or `no-class` throws `INVALID_FILTER` / 400. The message
names nothing from the filter (the objectstack-ai#7929 posture the read takes for the
same comparison); the refused comparison travels on the error under a
symbol key for the server log. New exports:
`findCrossFieldClassRefusal`, `crossFieldClassRefusalCarriedBy`, types
`MatchesFilterOptions`, `CrossFieldClassRefusal`. Without `fields` the
evaluator is byte-for-byte the old one.
- **`@objectstack/plugin-security` (the write gate, step 3.6).** Hands
the evaluator the object's declared columns (`writeCheckFieldOptions`:
`ql.getSchema`, then the metadata service, the order
`loadObjectFieldNames` uses) for every image it judges: single and array
inserts, by-id updates, predicate updates. On the refusal it logs one
WARN naming the policy and both columns: `[Security] RLS check REFUSED
on insert 'OBJECT' (INVALID_FILTER): policy 'deal_guard' — the
comparison … compares "status" (type 'text') … and "amount" (type
'number') …`. The policy name comes from a WeakMap the RLS compiler now
keeps from each policy's compiled filter to the policy
(`compiledPolicyNameOf`); nothing is added to the filter objects
themselves.
- **`@objectstack/driver-sql`.** `crossFieldComparisonClass` delegates
to `crossFieldColumnVerdict` for every declared `FieldType`, and keeps
only the driver-internal aliases above it, read off its own sets
(`JSON_COLUMN_TYPES`: `object` / `array`; `NUMERIC_SCALAR_TYPES`:
`integer` / `int` / `float`). No second copy of the classification is
left.
- **`@objectstack/lint`.** `crossClassConsequence`'s write sentence now
states the runtime's answer: "the in-process write check refuses the
comparison by the same classification (`INVALID_FILTER` / 400), so every
insert or update it judges is refused and nothing is stored", and the
`check` clause closes "The policy reads as a write rule and admits no
write at all." (objectstack-ai#20347 ACCEPT note 1) Round 2: the one sentence in the
header's objectstack-ai#20347 section that said the write check has no class rule now
says it refuses by the same classification.
- **`@objectstack/spec` (patch, round 2).** One ADR-0087 D3 semantic
entry for the whole family,
`rls-predicate-cross-class-field-comparison-refused` under protocol
major 18
(`packages/spec/src/migrations/entries/semantic/18.rls-predicate-cross-class-field-comparison-refused.ts`).
It names both arms: objectstack-ai#20347's authoring arm (`os validate`, build, lint
and the permission save door) and this write check.
`packages/spec/src/migrations/registry.ts` is regenerated by `pnpm
--filter @objectstack/spec gen:migration-registry` (71 lines inserted,
none removed), as PRs objectstack-ai#19946, objectstack-ai#20259 and objectstack-ai#20310 did. The changeset's
marker moves to `registered` with that id, and it adds
`'@objectstack/spec': patch`. The two comments that named the retired
parity test (`filter-cross-field-comparison-class.ts`'s header and its
test's header) now say that driver-sql delegates to
`crossFieldColumnVerdict` and that
`sql-driver-20355-cross-field-class-driver-aliases.test.ts` pins the
alias layer it keeps.
- **The objectstack-ai#20347 parity test retires.**
`sql-driver-20347-cross-field-class-parity.test.ts` held driver-sql's
private copy equal to the export over 3,025 ordered pairs. There is no
private copy any more, so the pairs are equal by construction. Before it
was deleted it ran on the rewired driver (commit 4605cc7): 56/56
green. The alias layer the rewire kept is pinned by the new
`sql-driver-20355-cross-field-class-driver-aliases.test.ts`.

## Measured, before and after

Through the real plugin-security + ObjectQL, policy `operation: 'all'`,
a member caller. Before = base 789b2ae, after = this branch. The same
answers on better-sqlite3, sqlite-wasm and PostgreSQL 16:

| policy | read (`using`) | by-id update / delete (`using`) | insert
with `using` as the check | `check` insert | `check` by-id update |
|---|---|---|---|---|---|
| `record.status != record.amount` (text vs number) | 400 → 400 | 403 →
403 | admitted, stored → **400**, nothing stored | admitted, stored →
**400** | admitted → **400** |
| `record.status != record.photo` (text vs image) | 400 → 400 | 403 →
403 | admitted, stored → **400** | admitted, stored → **400** | admitted
→ **400** |
| `record.status != record.is_open` (text vs formula; the card's formula
cell) | 400 → 400 | 403 → 403 | admitted, stored → **400** | admitted,
stored → **400** | admitted → **400** |
| `record.status != record.meta` (text vs json holding one value) | 400
→ 400 | 403 → 403 | admitted, stored → **400** | admitted, stored →
**400** | admitted → **400** |
| `record.amount > record.status` (number vs text) | 400 → 400 | 403 →
403 | 403 → **400** | 403 → **400** | 403 → **400** |
| `record.status != record.title` (text vs text, control) | rows → rows
| admitted → admitted | admitted → admitted | admitted → admitted |
admitted → admitted |

The formula cell answers exactly like the other two: the classification
gives a formula field no class (`no-class`, reason `formula`), so it is
refused on both sides.

driver-memory, measured out of tree (this package cannot declare
`@objectstack/driver-memory` without a `driver-memory-census`
disposition): the write answers as in the table (400 on every write
cell, nothing stored), because the check runs in-process before any
driver. Its **read is unchanged and still admits** (`rows=1` for every
cross-class cell): driver-memory has no `{ $field }` arm at all and
compares the marker object as a literal (objectstack-ai#15104, closed not planned). So
"refused on read and write" holds on SQLite, sqlite-wasm and PostgreSQL,
and on memory for the write only.

A json or `multiple` column is a `no-class` column (`list-or-object`),
so a comparison against one is now refused by its declared type, for
every record. objectstack-ai#19886 stage 2d judged it by the value each record held (a
json column holding one scalar compared). driver-sql's read has always
refused it by declared type, so this moves the write onto the read's
answer too.

## Compile faces
(`.claude/skills/pm-dispatch/references/compile-surfaces.md`,
re-verified at c80202c)

| # | face | verdict |
|---|---|---|
| 1 | `driver-sql` `applyFilterCondition` (`sql-driver.ts:16192`), and
by inheritance `driver-sqlite-wasm` and local-mode `driver-turso` |
**changed**: `crossFieldComparisonClass` reads
`crossFieldColumnVerdict`. The answers are unchanged: parity 56/56 on
the rewired driver before it retired, the cross-field conformance and
reference suites green on SQLite and PostgreSQL. |
| 2 | turso `RemoteTransport.buildWhereSQL` (`remote-transport.ts:2695`)
| **already compliant**: refuses every `{ $field }` comparand in remote
mode, whatever the classes (`uncompilableComparand`,
`remote-transport.ts:4392`). |
| 3 | service-analytics `compileScopedFilterToSql`
(`read-scope-sql.ts:696`) | **already compliant**: a read scope carrying
a `{ $field }` is declined by `NativeSQLStrategy.canHandle` and served
on the engine path, where face 1 compiles or refuses it (objectstack-ai#7598 ruling,
`read-scope-sql.ts:284`). The `/analytics/sql` echo refuses the
reference outright. |
| 4 | service-analytics `lowerAnalyticsWhere`
(`filter-normalizer.ts:2171`) | **already compliant**: same routing: a
`{ $field }` comparand reaches face 1 (`filter-normalizer.ts:1453`). |
| 5 | `formula` `matchesFilterCondition` (`matches-filter.ts:305`) |
**changed**: judges every `{ $field }` comparison by
`crossFieldComparisonVerdict` when the caller supplies the declared
columns. |
| half | objectql `having-filter` (`applyHaving` / `matchesHaving`,
`having-filter.ts:1131` / `:1154`) | **out of scope**: it calls face 5
without declared columns, so its answers are unchanged. A `having`
reference compares columns of the AGGREGATED row (group keys, aggregate
aliases), not declared `FieldType` columns, so this classification does
not cover them (objectstack-ai#20127 classifies them separately). HAVING is evaluated
in-process on every driver, so there is no read-side twin that could
disagree. |
| unfrozen | `driver-memory` `checkCondition` (`memory-matcher.ts:361`)
| **out of scope**: no `{ $field }` arm to attach a class rule to
(objectstack-ai#15104, closed not planned). Measured above: its read compares the
marker as a literal. |
| unfrozen | `driver-mongodb` `translateFieldOperators`
(`mongodb-filter.ts:962`) | **already compliant**: refuses every `{
$field }` reference (objectstack-ai#19949, `mongodb-filter.ts:264`). |

## Tests (measured head c80202c)

- `formula`: new `matches-filter-cross-field-class.test.ts`: every
declared class against every other, all six operators, expectations
written from the table's labels and not from the verdict function;
record independence; `$and` / `$or` / `$not` nesting; the `addDays`
form; undeclared, dotted and unjudged columns left alone; without
`fields` unchanged; the withheld message and the carried diagnostic.
22/22 at c80202c. Full package (at 0ee6f4c; the merge of `main`
brought no change to formula, driver-sql, lint or plugin-security): 41
files, 1213 passed; `typecheck` exit 0 (`check:test-typecheck` OK, debt
unchanged).
- `plugin-security`: new `rls-check-cross-class-field-refused.test.ts`,
through the real engine on better-sqlite3, sqlite-wasm and PostgreSQL
(opt-in, `OS_TEST_POSTGRES_URL`). Cells: the read, by-id update and
delete, the using-as-check insert, the check insert, array insert and
by-id update. Each refusal asserts `code` + `status` and that nothing
was stored or changed; the 400 names neither column; exactly one WARN
names the policy and both columns; the same-class control is admitted.
48/48 at c80202c with PostgreSQL 16. Full package: 143 files, 3046
passed, 16 skipped (the PostgreSQL cells, no URL); `typecheck` exit 0.
- `driver-sql`: new alias pin, 8/8; `cross-field-reference` +
`cross-field-conformance` + alias pin with PostgreSQL: 290 passed, 1
skipped at c80202c. Full package: 194 files passed, 11 skipped; 3172
tests passed, 178 skipped; `typecheck` exit 0.
- `lint`: 115 files, 5314 passed; `typecheck` exit 0.
`validate-rls-predicate-enforceability.cross-class-field.test.ts`:
569/569 at c80202c.
- **Ablations**, each through `scripts/ablation-replace.mjs` with a
restore trap:
- **A**: the evaluator's `if (refusal) throw
crossFieldClassError(refusal);` was replaced by `void refusal;`. Anchor
1 → 0, blob 8e92043 → 58b1e295. formula was rebuilt (exit 0).
`ablation-dist-preflight` read the marker in 2 built files on the
pristine build and absent from all 6 on the mutated one. The formula pin
went **19 failed / 3 passed** and the plugin-security pin **45 failed /
3 passed** (the three survivors are the same-class controls). Restored:
blob == HEAD 8e92043, `git diff HEAD` empty, rebuilt, marker present,
tree clean; 22/22 and 48/48 again.
- **B**: the gate's `matchesFilterCondition(image as any, f as any,
checkFieldOptions)` was stripped of its third argument (blob af0a956 →
8f254f2f). plugin-security went 45 failed / 3 passed. Restored blob ==
HEAD, 48/48.
- **C** (reverse, predicted green): driver-sql's pre-rewire body was put
back in place (blob 4760990 → 77031c84). The alias pin and
`cross-field-reference` went 56/56 green, so the rewire did not move an
alias. Restored blob == HEAD, tree clean.
  - Directions observed: red, red, green, as predicted.
- **Lint (narrowed, proved)**: `eslint --no-inline-config --format json`
over the 10 `.ts` files this diff touches plus the 36 the `main` merge
brought in reported 46 files, 0 errors, 0 warnings (no file ignored).
Type-aware linting is not enabled (`eslint.config.mjs:328`: no
`parserOptions.project`, no typed rules), so this diff cannot move the
verdict of any file it does not touch. The full `pnpm lint` is CI's.

## Gates (c80202c, after merging `origin/main` 50e273f)

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 68 families. All 68 ran, each exit
code captured before any pipe, and all are 0. Three first answered exit
3, PREREQUISITE NOT MET: `check:i18n`, `check:dual-build-cjs-loads` and
`check:type-check-debt`. They were re-run after building their stated
prerequisites, and all three are 0: i18n "OK (9 packages)", cjs "104
entry points across 66 packages load", type-check-debt "4 ledger entries
re-measured, none above its recorded number". `--ran` reconciles: 68
derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. The changeset gates:
`check-adr-0087-registration` ✓ (1 declared-breaking changeset with a
disposition), `check-changeset-no-major` ✓, `check-empty-changeset` ✓.

## Patch rounds

- **Round 1 (e72518a).** `Clause-②: yes (narrowing)` in the changeset
and in this body, because formula's root entry grows.
- **Round 2 (cc0bf6e).** The D3 entry, the changeset's `registered`
marker and `'@objectstack/spec': patch`, the two spec comments, and the
one lint header sentence. `origin/main` was merged twice with true merge
commits: dbddf02, then e01d347, which carries objectstack-ai#20106's
`reclaimSpace`. Everything below was measured at cc0bf6e.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 96 families, the spec families now
among them. All 96 ran, each exit code captured before any pipe, and all
96 exit 0 on the first run. `--ran` reconciles: 96 derived, 96 run, 0
NOT-MEASURED, 0 UNRUN.
- `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts
are up to date, including `check:migration-registry`,
`check:spec-changes` and `check:upgrade-guide`. `pnpm
check:adr-0087-registration`: 0.
- spec `--project local src/migrations` plus the classification test: 4
files, 178 passed.
- On the merged code, driver-sql's full suite passes: 195 files passed
and 11 skipped, 3176 tests passed and 178 skipped. Its typecheck exits
0.
  - The formula pin passes 22/22 and the lint cross-class test 569/569.
- The plugin-security pin passes 32 with 16 skipped. PostgreSQL was not
provisioned this round; its cells passed 48/48 at c80202c, and this
round changed no code.
- **Round 3 (2698fa1).** Prose only; no logic or test change.
`origin/main` was merged twice more with true merge commits: 87c37ae
(4e430ba), then 0fcb101 (2698fa1). Everything below was measured
at 2698fa1.
- The D3 entry corrects three statements. `reason` gives the file
family's by-name refusal in the spec module's own words (the ADR-0104
dual-encoding window) instead of "no stored column", which is true of a
formula field only. `acceptanceCriteria` says the read answers 400 "on
the SQL drivers". `replacement` lists all four reference types, `tree`
included.
- `registry.ts` is regenerated by `gen:migration-registry` and changes
only in the entry's lines.
- Lint's objectstack-ai#20347 header paragraph now says driver-sql delegates through
`crossFieldColumnVerdict`, where it had named the retired parity test.
- `dispatch-gates --commands` derived 96 families; all 96 ran and exit
0, and `--ran` reconciles 96/96 with 0 NOT-MEASURED. `pnpm --filter
@objectstack/spec check:generated`: all 15 artifacts are up to date.
spec `--project local src/migrations` plus the classification test: 4
files, 178 passed.

## Deviations from the claim's file surface

- `packages/plugins/plugin-security/src/security-plugin.ts` (step 3.6
and `writeCheckFieldOptions`) and `rls-compiler.ts`
(`compiledPolicyNameOf`) are source, where the claim named
plugin-security for tests only. H2 held: the comparison is evaluated in
`matches-filter.ts`. That evaluator has no schema, though, and the
declared columns exist only at its caller, the write gate. Naming the
policy needs the compile seam, the one place that still knows each
policy's filter.
-
`packages/lint/src/validate-rls-predicate-enforceability.cross-class-field.test.ts`:
one assertion line, because it pinned the sentence this PR changes. In
`crossClassConsequence`, the changed text is the shared `write` constant
plus the check branch's closing sentence. The `using` branch
interpolates the same constant, so the `using` finding's last clause
reads the new answer too.
-
`packages/drivers/driver-sql/src/sql-driver-20355-cross-field-class-driver-aliases.test.ts`:
new, to pin the alias layer after the parity test retired.
- All three deviations above were accepted by the seat's amended claim
5868635246. Round 2's `packages/spec` files (the D3 entry, its
regenerated `registry.ts` and the two comments) and the lint header
sentence are in the claim re-posted as 5868966379.

## Acceptance notes

- **Not fixed here; reported for the seat.** `security.explain` (served
at `/security/explain`) answers a read of a row scoped by `record.status
!= record.amount` with `visible: true, decidedBy: rls`, while `find`
answers `INVALID_FILTER` / 400. Measured through the security service on
all three SQL drivers. Its record attribution calls
`matchesFilterCondition` without the declared columns. Passing them, the
option this PR adds, would align it. This is a separate face and the
file is outside this claim.
- objectstack-ai#20347's `listHoldingComparisons` second-spelling note is unchanged by
this PR.
- The write check now applies ruling 4 of objectstack-ai#5222 (same comparison class).
It does not apply rulings 1–3 (dotted path, declared-only, the
tenant-isolation column). An undeclared column is the RLS compiler's
field guard's job, and the dotted and tenant arms were not measured
here.
- **The `addDays` arm (corrected in rounds 2 and 3).** driver-sql's read
refuses an `addDays` reference with 400 when its base is not a `date` or
`datetime` column, or when its offset column is not numeric. The write
check does not always fail those closed, and three shapes can be
admitted on the write:
1. A text base holding a date-shaped string is shifted and compared,
because `addWholeDays` reads it with `Date.parse`.
2. A numeric base is shifted and compared, because `addWholeDays` adds
the offset to any finite number.
3. A text offset column holding a numeric string is read as a number of
days by `resolveDayOffset`.
This is pre-existing and not made worse here: the class rule runs first
and refuses every cross-class pair. What remains is a same-class pair
with an offset on a non-temporal base (text or numeric), or with a text
offset column. Read from the code; not measured.
carrier: domain:engine seat (objectstack-ai#6367) measures reach through the real
write door; a card follows only if a public door admits such a write
- A predicate update that matches zero rows judges no image, so it
completes as a no-op where the read answers 400. Nothing is stored.
- Seat ruling, claim 5868966379: the family gets an ADR-0087 D3 semantic
entry, registered in this PR.
- Seat ruling, claim 5868966379: execution note 3's driver-memory read
cell is accepted under objectstack-ai#15104 (closed, not planned). The memory read
still admits; the write refuses.
- objectstack-ai#20106 (`reclaimSpace` in `sql-driver.ts`) landed as e01d347 and is
merged here (cc0bf6e). This diff does not touch that region, and
driver-sql's full suite passes on the merged code.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants