Skip to content

fix(formula)!: refuse comparands that are not one value at the CEL lowering and the write-check evaluator - #20259

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19886-stage-2d-same-class-leaks
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19886-stage-2d-same-class-leaks

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #19886
Clause-②: no

Stage 2d of #19886. It covers the three same-class leaks recorded in 5806608550, each measured before any edit at every door, plus two members of the same family that the same guard carries: (d) and (e), from seat 4's note 5853764742. The changeset declares Clause-②: no (narrowing), BREAKING, and registers cel-predicate-one-value-comparand-refused. This PR does not close the card: the remainder below stays open.

What changes

packages/formula/src/cel-to-filter.ts (the CEL lowering that every RLS policy, declared sharing rule and the authoring lint compile through). Each shape below now fails with reason unsupported:

  • (c) a list under an ordering operator (>, >=, <, <=), either side: a list literal, or a current_user key that resolves to an array.
  • (b) an in list whose member is itself a list, written or resolved.
  • (c) and (e) a list operand on the constant-fold branch, where no field is involved. It previously folded to "no restriction".
  • (d) the current_user root, or a key resolving to an object, under an ordering operator. fix(formula): refuse == / != against the bare current_user root at the CEL lowering #20189's guard now covers all six comparisons.

Literal forms are also refused by the shape check (isPushdownableCel, isSupportedRlsExpression), so the lint reports them. Resolved values are refused per request.

packages/formula/src/matches-filter.ts (the evaluator the RLS write check runs):

  • An array under $gt / $gte / $lt / $lte, and an array member of $in / $nin, are refused by the up-front shape walk, before any record is judged.
  • (a) A { $field } comparison ($eq, $ne or an ordering operator) whose compared column holds a list or an object on the record being judged is refused. This covers either side, and the addDays form judged on its base column.

Every refusal gives 2a's envelope (INVALID_FILTER / 400) with one widened sentence.

(a) cannot be refused at the lowering. The lowering sees the predicate's text, not the object's field types: RlsFieldGuard carries column names only (getObjectFieldNames returns a set of strings). So Zone 2 item 2's premise, "the CEL lowering knows the field's schema", is false. The evaluator is the first face that sees the values. It pulls the write check onto driver-sql's existing rule (#5222 crossFieldComparisonClass), which refuses a cross-field comparison against any JSON-stored or multiple column on either side.

Measured cells

A real stack: SecurityPlugin, ObjectQL, driver-sql (better-sqlite3) and driver-memory, a json column and a multiple lookup. Doors measured for each cell:

  • the write check (forbidden insert and allowed insert, with what was stored);
  • the using read;
  • the CEL compile (isPushdownableCel and the lint rule validateRlsPredicateEnforceability);
  • the analytics read scope (getReadFilter into compileScopedFilterToSql, and into assertReadScopeComparandsRunnable + engine.aggregate).

"Before" is origin/main 3cb84d08, measured before any edit. The table was written to disk first. "After" is this branch. "Base on merged main" is this branch with both faces ablated at once, which is the formula of origin/main 0d3ec471, including #20212's comparand faces in the RLS compiler.

cell predicate door before (3cb84d0) base on merged main after
a1 a record.status != record.tags compile lowers lowers lowers
check sql F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 400 / 400
check memory F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 400 / 400
using sql INVALID_FILTER/400 INVALID_FILTER/400 INVALID_FILTER/400
using memory all rows all rows all rows
analytics sql READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400
analytics memory READ_SCOPE_COMPILE_FAILED/500; oql all rows READ_SCOPE_COMPILE_FAILED/500; oql all rows READ_SCOPE_COMPILE_FAILED/500; oql all rows
a2 a !(record.status == record.tags) compile lowers lowers lowers
check sql F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 400 / 400
check memory F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 400 / 400
using sql INVALID_FILTER/400 INVALID_FILTER/400 INVALID_FILTER/400
using memory all rows all rows all rows
analytics sql READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400
analytics memory READ_SCOPE_COMPILE_FAILED/500; oql all rows READ_SCOPE_COMPILE_FAILED/500; oql all rows READ_SCOPE_COMPILE_FAILED/500; oql all rows
a5 a(mirror) record.tags != record.status compile lowers lowers lowers
check sql F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 400 / 400
check memory F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 400 / 400
using sql INVALID_FILTER/400 INVALID_FILTER/400 INVALID_FILTER/400
using memory all rows all rows all rows
analytics sql READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400
analytics memory READ_SCOPE_COMPILE_FAILED/500; oql all rows READ_SCOPE_COMPILE_FAILED/500; oql all rows READ_SCOPE_COMPILE_FAILED/500; oql all rows
b1 b !(record.status in [['closed', 'archived']]) compile lowers lowers refused (shape + lint)
check sql F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 403 / 403
check memory F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 403 / 403
using sql INVALID_FILTER/400 INVALID_FILTER/400 no rows
using memory all rows all rows no rows
analytics sql READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 deny scope; oql no rows
analytics memory READ_SCOPE_COMPILE_FAILED/500; oql all rows READ_SCOPE_COMPILE_FAILED/500; oql all rows deny scope; oql no rows
b3 b(var) !(record.status in current_user.nested_set) compile lowers lowers refused per request
check sql F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 403 / 403
check memory F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 403 / 403
using sql INVALID_FILTER/400 INVALID_FILTER/400 no rows
using memory all rows all rows no rows
analytics sql READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 READ_SCOPE_COMPILE_FAILED/500; oql INVALID_FILTER/400 deny scope; oql no rows
analytics memory READ_SCOPE_COMPILE_FAILED/500; oql all rows READ_SCOPE_COMPILE_FAILED/500; oql all rows deny scope; oql no rows
c1 c record.status > ['m'] compile lowers lowers refused (shape + lint)
check sql F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / 403
check memory F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / 403
using sql INVALID_FILTER/400 INVALID_FILTER/400 no rows
using memory INVALID_FILTER/400 INVALID_FILTER/400 no rows
analytics sql binds [["m"]]; oql INVALID_FILTER/400 binds [["m"]]; oql INVALID_FILTER/400 deny scope; oql no rows
analytics memory binds [["m"]]; oql INVALID_FILTER/400 binds [["m"]]; oql INVALID_FILTER/400 deny scope; oql no rows
c3 c record.status <= ['m'] compile lowers lowers refused (shape + lint)
check sql F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / 403
check memory F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / 403
using sql INVALID_FILTER/400 INVALID_FILTER/400 no rows
using memory INVALID_FILTER/400 INVALID_FILTER/400 no rows
analytics sql binds [["m"]]; oql INVALID_FILTER/400 binds [["m"]]; oql INVALID_FILTER/400 deny scope; oql no rows
analytics memory binds [["m"]]; oql INVALID_FILTER/400 binds [["m"]]; oql INVALID_FILTER/400 deny scope; oql no rows
c4 c(const) current_user.org_user_ids > 'a' compile lowers to {} (allow-all) lowers to {} (allow-all) refused per request
check sql F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 403 / 403
check memory F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 403 / 403
using sql all rows all rows no rows
using memory all rows all rows no rows
analytics sql no WHERE (all rows); oql all rows no WHERE (all rows); oql all rows deny scope; oql no rows
analytics memory no WHERE (all rows); oql all rows no WHERE (all rows); oql all rows deny scope; oql no rows
c5 c(var) record.status > current_user.org_user_ids compile lowers lowers refused per request
check sql F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / 403
check memory F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / 403
using sql INVALID_FILTER/400 INVALID_FILTER/400 no rows
using memory INVALID_FILTER/400 INVALID_FILTER/400 no rows
analytics sql binds [["usr_member"]]; oql INVALID_FILTER/400 binds [["usr_member"]]; oql INVALID_FILTER/400 deny scope; oql no rows
analytics memory binds [["usr_member"]]; oql INVALID_FILTER/400 binds [["usr_member"]]; oql INVALID_FILTER/400 deny scope; oql no rows
d1 d(seat4) record.reviewer > current_user compile lowers lowers refused (shape + lint)
check sql F/A ADMITTED+stored / ADMITTED+stored 403 / 403 403 / 403
check memory F/A ADMITTED+stored / ADMITTED+stored 403 / 403 403 / 403
using sql INVALID_FILTER/400 no rows no rows
using memory no rows no rows no rows
analytics sql READ_SCOPE_COMPILE_FAILED/500; oql READ_SCOPE_COMPILE_FAILED/500 deny scope; oql no rows deny scope; oql no rows
analytics memory READ_SCOPE_COMPILE_FAILED/500; oql READ_SCOPE_COMPILE_FAILED/500 deny scope; oql no rows deny scope; oql no rows
e1 e(seat4) current_user.org_user_ids != 'x' compile lowers to {} (allow-all) lowers to {} (allow-all) refused per request
check sql F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 403 / 403
check memory F/A ADMITTED+stored / ADMITTED+stored ADMITTED+stored / ADMITTED+stored 403 / 403
using sql all rows all rows no rows
using memory all rows all rows no rows
analytics sql no WHERE (all rows); oql all rows no WHERE (all rows); oql all rows deny scope; oql no rows
analytics memory no WHERE (all rows); oql all rows no WHERE (all rows); oql all rows deny scope; oql no rows
ctl-a control record.status != record.reviewer compile lowers lowers lowers
check sql F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / ADMITTED+stored
check memory F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / ADMITTED+stored
using sql archived,open archived,open archived,open
using memory all rows all rows all rows
analytics sql READ_SCOPE_COMPILE_FAILED/500; oql archived,open READ_SCOPE_COMPILE_FAILED/500; oql archived,open READ_SCOPE_COMPILE_FAILED/500; oql archived,open
analytics memory READ_SCOPE_COMPILE_FAILED/500; oql all rows READ_SCOPE_COMPILE_FAILED/500; oql all rows READ_SCOPE_COMPILE_FAILED/500; oql all rows
ctl-b control !(record.status in ['closed', 'archived']) compile lowers lowers lowers
check sql F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / ADMITTED+stored
check memory F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / ADMITTED+stored
using sql open open open
using memory open open open
analytics sql binds ["closed","archive; oql open binds ["closed","archive; oql open binds ["closed","archive; oql open
analytics memory binds ["closed","archive; oql open binds ["closed","archive; oql open binds ["closed","archive; oql open
ctl-c control record.status > 'm' compile lowers lowers lowers
check sql F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / ADMITTED+stored
check memory F/A 403 / ADMITTED+stored 403 / ADMITTED+stored 403 / ADMITTED+stored
using sql open open open
using memory open open open
analytics sql binds ["m"]; oql open binds ["m"]; oql open binds ["m"]; oql open
analytics memory binds ["m"]; oql open binds ["m"]; oql open binds ["m"]; oql open

Scope beyond the three recorded leaks, declared

(d) record.reviewer > current_user and (e) current_user.org_user_ids != 'x' are not in 5806608550. Seat 4's note 5853764742 reported them on this card, and the claim names only the three leaks.

I folded them in under the bounded in-place rule, because all four conditions hold:

  • ① They are the same defect class: a comparand that is not one value.
  • ② The fix is mechanical, already shaped, and pinned. (e) is the constant-branch half of the guard (c) needs. Leaving == / != out of it would have written a hole into a guard that refuses the same list under >. (d) is fix(formula): refuse == / != against the bare current_user root at the CEL lowering #20189's guard with its == / != condition removed, and its sentence already names the operator.
  • ③ cel-to-filter.ts is on this claim.
  • ④ The gates and pins are the same.

Both were measured live on 3cb84d08. (e) was allow-all on the write check, the read and analytics. (d) admitted and stored on the write check. On the merged main, #20212's faces already drop (d)'s policy; (e) still folds to allow-all there.

Remainder, still open on this card

  1. (a) at the read door on driver-memory. driver-memory does not evaluate a { $field } reference at all: neither its source nor its tests handle $field. A using read under record.status != record.tags returns every row, and so does the scalar control record.status != record.reviewer. This is a driver-memory source change, outside this claim (drivers are test side only). The analytics ObjectQL strategy on driver-memory inherits it. driver-sql refuses the read (400, [spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222).
  2. (a) at the compile door (os validate). A field compared with a json / multiple field still lowers and is not reported at authoring. Judging it needs field types: either the lint rule, whose object graph has them (RLS policies are admitted when they are authored: policy save and objectstack validate / compile judge the lowered read-scope filter with the engine's judge-only method (objectstack#19995 ruling C, second consumer) #20158 holds that file), or types threaded into the RLS compile (the engine lane's files). This PR changes neither.

Pins

New and flipped pins:

  • packages/formula/src/cel-to-filter-one-value-comparand.test.ts (new). Every refused literal form, refused per request and by the shape check. Every resolved form, refused per request with the shape check passing; the detail names the variable path and no value. The remedy of each refusal. Neighbours unchanged: one bound, a flat in, a scalar fold, and field-to-field == / !=, which still lower.
  • packages/formula/src/matches-filter-array-comparand.test.ts. 2a's table-driven SHAPES gains $gt / $gte / $lt / $lte with an array and $in / $nin with an array member. Each runs at every depth, with the empty array, and for every record. A new describe covers the per-record { $field } refusal: both sides, negated, ordering, an object column, the offset form, and under $or. Its control is that the same filter over one-value columns is compared, not refused, and the message withholds columns and values.
  • Flipped by the pin-sweep rule: a { $field } reference is judged by what was AUTHORED, not by what it resolves to asserted { tags: { $eq: { $field: 'tags' } } } → true over a list-holding column. It now asserts INVALID_FILTER / 400. Its two scalar lines stay, moved to a scalar-control case.
  • packages/plugins/plugin-security/src/rls-one-value-comparand-fails-closed.test.ts (new). Table-driven: leak × door × driver (driver-sql, driver-sqlite-wasm). Every leak row refuses both inserts and stores nothing. It reads no rows, or gets the driver's own refusal for (a). A dropped policy hands getReadFilter the deny scope. The one-value controls are accepted and enforced.

Pin sweep ① (the error code and message swept repo-wide for same-meaning pins):

Ablation (one-shot proof that each pin can fail)

Ablations ran from the committed merged head 4ade1b94. Each mutation went through scripts/ablation-replace.mjs (the anchor must hit; on-disk counts are verified). ablation-dist-preflight.mjs then confirmed the marker had reached formula's dist/, which plugin-security reads, before any result was read. The script armed trap … EXIT INT TERM, with absolute paths, as its restore.

  • Evaluator face cut. Three arms were neutralised: the per-record { $field } check, the four ordering operators in ARRAY_REFUSED_OPERATORS, and the $in / $nin member walk. Readings: 3 markers on disk and 0 anchors left; the marker was in 2 built files. Result: formula pins 57 failed / 63 passed, and the plugin-security pin 8 failed / 48 passed (exactly the (a) write rows, 4 leaks × 2 drivers).
  • Lowering face cut. Six arms were neutralised: the list-member refusal, the list refusal under ordering operators, both constant-branch list refusals, and fix(formula): refuse == / != against the bare current_user root at the CEL lowering #20189's root and object refusals narrowed back to == / !=. Readings: 4 markers on disk (two arms are re-spelled conditions) and 0 anchors left; the marker was in 2 built files. Result: formula pins 25 failed / 95 passed, and the plugin-security pin 28 failed / 28 passed (every (b), (c) and (e) row, write and read, on both drivers).
  • Both faces cut together gives main's formula on the merged tree. That is the "base on merged main" column above, read from the real stack.
  • Restores. git checkout HEAD on the absolute paths. git diff HEAD was 0 bytes and the blob equals the HEAD blob (matches-filter.ts 1fb6f28c22e6, cel-to-filter.ts 4c6ef215947). formula was rebuilt, and the preflight --absent found the marker in none of 6 built files, with a clean tree. The final re-run was green: formula pins 120/120 and the plugin-security pin 56/56.

Refusal texts (every changed or new one, quoted)

Lowering, ordering (new branch of arrayComparandRefusal):

`OP` orders against one value, but its comparand is a list literal — compare against one bound (`record.f OP 'm'`), write a range as two comparisons joined by `&&`, and test membership with `record.f in [...]`

Lowering, list member (new listMemberRefusal):

`in` compares the field with each member of its list, one value at a time, but member N of the list literal is itself a list — flatten it: `record.f in ['a', 'b']`, not `record.f in [['a', 'b']]`

The resolved form reads "member N of current_user.KEY resolves to a list". The == / != sentence of 2c and the object/root sentence of #20189 are unchanged; the latter now fires under ordering operators too.

Evaluator (arrayComparandError, one sentence for the class, widened from 2a's):

A single-value comparison in this filter received an array as its comparand: an array under "$ne", or an array in the equality position ({ "field": [ ... ] } or "$eq"), or under an ordering operator ("$gt", "$gte", "$lt", "$lte"), 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. For "one of these values" use "$in", and for "none of these values" use "$nin" — the list operators the filter protocol declares; an ordering comparison takes one bound. It is refused rather than evaluated, because this evaluator compares strictly and no stored value ever equals an array: "$ne" matched EVERY record, and so did a negated equality or a negated "$in", which on a row-level write check admitted every write the check was written to refuse, and an ordering operator compared the array as a string. The field, the operator and the value are withheld from this message because the filter may be an access policy the caller did not write; in a row-level policy, look for a comparison against a list literal, a current_user membership key, or a json or multiple field, and rewrite it with "in" (for example "!(record.status in ['closed', 'archived'])").

Census (Zone 2 item 4)

Zero producers carry any of the shapes:

  • objectstack 3cb84d08: 123 literal using / check strings in non-test packages/**, 63 lines of them in default-permission-sets.ts, and 3 in examples/**.
  • cloud origin/main 96eb092: 0 authored predicates.

A shape-wide scan of all non-test strings finds one field-vs-field line, examples/app-showcase/src/ui/actions/predicate-matrix.action.ts:188. It is an action predicate compared against an element (record.f_lookups[0]) and not an RLS predicate. There are 0 nested in lists and 0 orderings against a list. A positive control confirms the detector fires on each shape. No D2 conversion is owed.

Zone 2 answers

  1. Measured. Nothing was already closed on 3cb84d08. On the merged main, RLS: a policy's compiled filter skips the shared comparand-shape faces, so a null list member or null ordering bound reaches driver-sql, and the read and the write check disagree (the read hides a row its own check admits) #20212's faces drop (d) only.
  2. Falsified premise. The lowering holds no field schema, so (a) is judged at the evaluator. The CEL route to a list-resolving { $field } is record.status != record.tags; it is reachable and refused with 2a's sentence, widened.
  3. (b) and (c) run through the lowering and the evaluator. The shared face (normalizeFilterComparandTypes) does not judge an array under an ordering operator ("Arrays outside $in/$nin/$between" are left to the drivers) and let $in: [[...]] through, measured on the analytics scope. No leak here needed a new shared-face arm, so nothing is serial after spec: FilterConditionSchema (the save-time door) admits a non-boolean $null / $exists flag, so a stored dataset or widget filter carrying one saves clean and is refused 400 on every query face #20116.
  4. See the census: 0 carry any shape.
  5. The three $not JSON-only shapes remain unreachable from every authored surface. CEL never lowers a bare $not over a field-less $ne, an empty $or, or a nested relation value, and the only production callers of matchesFilterCondition are the RLS write check, the tenant wall (platform-derived filters), explain (RLS-compiled filters) and objectql having (a { $field } probe).

Tests and gates

All builds and tests ran through os-verify-lock.sh, with exit codes captured before any pipe.

Merged head 4ade1b94 (lock verdict: command-exit 0, held 717s on a shared box). The only later commit, 39379b9b, edits changeset prose.

  • @objectstack/spec build, then check:generated: all 15 artifacts current.
  • formula: 39 files / 1127 tests passed; typecheck exit 0.
  • plugin-security full suite: 140 files / 2895 tests passed; typecheck exit 0.
  • lint full suite: 109 files / 4232 tests passed.
  • plugin-sharing full suite passed.

Pre-merge head bfd409ee:

  • plugin-security 138 files / 2847 passed.
  • lint 109 / 4232 passed.
  • plugin-sharing 37 / 913 passed.
  • objectql having pair: 2 files / 176 passed.
  • driver-sql cross-field and formula-consumer files: 6 files / 445 passed, 2 skipped (live PG / MySQL).

Generated artifacts: check:migration-registry, check:spec-changes and check:upgrade-guide were current after regeneration, on both sides of the merge. registry.ts was regenerated after the merge: 265 semantic entries, carrying this entry and main's inline-grid-column-currency-scale-refused.

eslint on the 7 touched .ts files: 7 files, 0 errors, 0 warnings, counted from eslint --no-inline-config --format json. The population comes from eslint.config.mjs, whose header states it never enables type-aware linting (no parserOptions.project), so this diff cannot move the verdict on any untouched file. packages/spec/** sits in that config's ignores for the generic block and is linted by its own block.

Gates: dispatch-gates.mjs --commands --repo objectstack-ai/objectstack was re-derived on the actual change set at 39379b9b (90 commands; the same set at the merged head 4ade1b94). Every command ran, and its exit code was recorded before any pipe. --ran reads: 90 derived, 87 run at exit 0, 3 NOT MEASURED, 0 unrun.

The 3 NOT MEASURED are each a recorded exit 3, PREREQUISITE NOT MET: check:dual-build-cjs-loads and check:type-check-debt need every workspace package's dist/ (45 unbuilt here), and check:i18n needs the built CLI. None of them is a verdict on this diff; CI runs them after its full build.

The 87 include:

  • check-adr-0087-registration --base origin/main: 1 declared-breaking changeset, registered cel-predicate-one-value-comparand-refused.
  • check:nul-bytes: 9704 files, no raw control bytes.
  • check:doc-authoring.
  • check:keyed-text-bounds.
  • the spec check:migration-registry, check:spec-changes, check:upgrade-guide, check:api-surface, check:authorable-surface and check:liveness.

CI convergence is not awaited; it is in progress at report time.

Not measured here: live mongod, PostgreSQL and MySQL; the sharing bootstrap skip, which is read from celToFilterOutcome and not driven live.

Acceptance notes (not filed)

  • 2a's unreleased changeset .changeset/19886-formula-array-comparand-refused.md and its entry rls-predicate-array-comparand-refused still say { $field } references "evaluate exactly as before". That is now false for a list-holding column. This PR does not edit them, because editing another stage's changeset is a deliberate correction the seat rules on; this changeset states the delta instead.
  • objectql having with a { $field } against a list-holding groupBy column now refuses 400 on driver-memory, where the row carries the list. driver-sql rows carry the stored JSON text and compare as before (measured, both drivers).
  • The analytics native compiler (compileScopedFilterToSql) bound an array as one SQL parameter for $gt: [...] before this change. Nothing reaches it now because the lowering drops the policy, but the compiler itself still would for a host-supplied scope.

Generated by Claude Code

…ering and the write-check evaluator

The CEL lowering refuses a list under every comparison (ordering operators
included, and on the constant-fold branch), the variable root or an object
under an ordering operator, and an `in` list whose member is itself a list.
The write-check evaluator refuses an array under the ordering operators, an
array member of `$in` / `$nin`, and a `{ $field }` comparison whose column
holds a list or an object on the record being judged.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
…h clauses; register the ADR-0087 entry and changeset

A table-driven pin through the real SecurityPlugin, ObjectQL, driver-sql and
driver-sqlite-wasm: every leak row refuses both inserts and stores nothing,
reads no rows or the driver's own refusal, and hands the analytics bridge the
deny scope; the one-value spellings stay accepted and enforced. The semantic
entry cel-predicate-one-value-comparand-refused is registered under protocol
major 18 and the registry regenerated.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
… (d) policy in this release

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

15 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 443b2f4fdce681f1d3656475c9c72f3ffacfee4c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0f91e546cad4e7469eb7b2b75d1d2d9cca5bb64c — the merge of head 39379b9b98cecfcf76d28a291c4567184d9ec38f into base 443b2f4fdce681f1d3656475c9c72f3ffacfee4c, 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 0f91e546cad4e7469eb7b2b75d1d2d9cca5bb64c && git checkout 0f91e546cad4e7469eb7b2b75d1d2d9cca5bb64c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 443b2f4fdce681f1d3656475c9c72f3ffacfee4c 39379b9b98cecfcf76d28a291c4567184d9ec38f && git checkout -B drift-repro 443b2f4fdce681f1d3656475c9c72f3ffacfee4c && git merge --no-ff 39379b9b98cecfcf76d28a291c4567184d9ec38f

node scripts/docs-audit/affected-docs.mjs --json 443b2f4fdce681f1d3656475c9c72f3ffacfee4c

⚠️ 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: 39379b9b98cecfcf76d28a291c4567184d9ec38f

Read: the PR body, diff (8 files, +871/−37), 4 commits and 35 check runs; card #19886 (body, all 34 comments; in particular 5805254639, 5806608550, 5853764742, 5856326742, 5857675669, 5857706935) and the 2a review 5806589396 on PR #19946; cel-to-filter.ts, matches-filter.ts, rls-compiler.ts 600–830, security-plugin.ts 3100–3290 and getReadFilter, explain-engine.ts 840–880, having-filter.ts 1060–1130, bootstrap-declared-sharing-rules.ts 100–330, validate-rls-predicate-enforceability.ts (probe and reference pass), filter-comparand-shape.ts 260–345, check-changeset-no-major.mjs, AGENTS.md 1060–1095, both changesets and both ADR-0087 entries. Ran, in a detached worktree at this head (deps built with turbo, 18 packages, exit 0; worktree removed): the PR's pins (formula 120, plugin-security 56); formula neighbours (9 files / 325); the 2a/2c/#20189/#20212 plugin-security pins (5 files / 111); lint RLS + sharing rule files (172); sharing-rule.test.ts; objectql having pair (56); check-adr-0087-registration --base origin/main, check-changeset-no-major --base origin/main, spec check:migration-registry; a 126-cell real-stack probe (SecurityPlugin + ObjectQL, driver-sql better-sqlite3 and driver-memory, json column + multiple lookup: 21 predicates × check-insert F/A × by-id-update F/A × using read + getReadFilter, with ground truth read as system); a lowering/evaluator probe over 17 refused spellings, 28 census strings and 10 boundary shapes; a sharing-bootstrap probe; a lint probe; an ablation of each face with the pins, restore proved by blob hash. NOT MEASURED: live mongod, PG, MySQL; driver-sqlite-wasm beyond the PR's own pin.

① Derived judgments

  • 1. Fail-closed on every reachable door — RIGHT. Every leak cell of 5806608550 and 5853764742 is refused through the real write check and the lowering, on both drivers, at both write doors (the dev measured inserts only; I added the by-id update, whose post-image merges the caller pre-image — driver-sql hands the json column back parsed, so the list is seen): (a) record.status != record.tags, !(record.status == record.tags), the mirror record.tags != record.status, record.reviewer != record.watchers (multiple lookup) and record.status > record.tags → INVALID_FILTER/400 on the forbidden AND the allowed insert and update, nothing stored; (b) !(record.status in [['closed','archived']]) and the host-staged !(record.status in current_user.nested_set), (c) record.status > ['m'], ≤ ['m'] (the $lte spelling), > current_user.org_user_ids, current_user.org_user_ids > 'a', (d) record.reviewer > current_user, (e) current_user.org_user_ids != 'x' → PERMISSION_DENIED/403 on every write, nothing stored; using reads return zero rows and getReadFilter hands the analytics bridge RLS_DENY_FILTER. No leak cell admits. Loud: a literal form WARNs [RLS] … has an uncompilable predicate … DROPPED on every compile (b1, c1, c3, d1); a resolved form WARNs [RLS] DENY (fail closed): … (unsupported): the detail when the request denies (b3, c4, c5, e1); (a) is a thrown 400 with the constant message. The before-table re-checked against the dev's cells-before.json (a1/b1/c4/d1/e1 admitted+stored at 3cb84d08; d1 already 403 on merged main via RLS: a policy's compiled filter skips the shared comparand-shape faces, so a null list member or null ordering bound reaches driver-sql, and the read and the write check disagree (the read hides a row its own check admits) #20212) and cells-head.json — consistent with my readings. The one door that stays permissive is declared, not this PR's: (a) at the driver-memory using read returns every row, exactly as the LEGAL control record.status != record.reviewer does there (driver-memory never evaluates { $field }), pointer on [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 (item ③).
  • 2. No over-refusal — RIGHT. Census: 613 using/check/sharing-condition string occurrences across objectstack 39379b9b + cloud 48d7066 (regex over every non-node_modules .ts/.js/.json/.yaml/.md*), 278 non-test; the 28 distinct authored predicates among them (all default-permission-sets.ts spellings, rls.zod.ts examples, verify/rls.ts, security-plugin.ts:3144, showcase permission-sets.ts and sharing-rules.ts, docs, skills, the qa fixture; cloud carries only id == current_user.id in tests and no rowLevelSecurity) all pass isPushdownableCel and compileCelToFilter with a realistic current_user; validateRlsPredicateEnforceability over the shipped 107 default policies: 0 findings. On the real stack the six named neighbours are accepted and enforced on both drivers: record.status != 'closed' (F 403 / A stored / reads r_archived,r_open), record.status != record.reviewer (F 403 / A stored / driver-sql reads r_archived,r_open), !(record.status in ['closed','archived']), record.status > 'm', record.reviewer == current_user.id (reads r_open), record.reviewer in current_user.org_user_ids (reads r_archived,r_open); the compound !(record.status in current_user.org_user_ids) && record.status > 'm' lowers. A legal $or branch still admits: record.reviewer == current_user.id || record.status != record.tags admits the row the caller reviews (short-circuit before the refused arm) and 400s the other — logically right.
  • 3. The { $field } check — RIGHT, and correctly per record. assertComparableReference runs in evalOp before resolveValue, only for $eq/$ne/$gt/$gte/$lt/$lte, and tests getPath(record, $field) and actual with Array.isArray || plain-object (Date excluded), so both sides are judged (mirror a5 and record.status > record.tags measured refused) and the addDays form is judged on its base before the NO_OFFSET_BASE sentinel a $not would invert (pinned). A null or absent referent is NOT refused and compares as before ($ne → true; measured) — correct, a missing value is one value. Callers: four in production — security-plugin.ts:3167 (RLS check, the target), :3438 (tenant wall, platform-derived organization_id filters, never a { $field }), explain-engine.ts:872 (RLS-compiled filter per record: a record whose compared column holds a list now makes explain throw 400 where it answered before — a real but undeclared move, item ③), having-filter.ts:1117 (a three-column probe { value, referent } → on driver-memory a list-holding groupBy/aggregate column is now refused 400; driver-sql rows carry JSON text and compare as before). The having move is correct — refusing beats coercing a list, matches the evaluator's contract — and is declared under @objectstack/objectql; the having pair (56) is green.
  • 4. plugin-sharing — RIGHT on all three. celToFilterOutcome compiles with variables: {} and returns { filter: null, cause: { reason: 'unsupported', detail } } for record.status > ['m'], !(record.status in [['closed']]), ['a'] != 'x'; bootstrapDeclaredSharingRules then continues without defineRule (measured: 2 skipped, 1 seeded, defineRule called once for record.status == 'new') — fail CLOSED, no grant, no over-share (ADR-0049). Logged: logger.warn('[sharing-rule] skipped (missing or untranslatable CEL condition — never seeded as match-all)', { rule, condition, reason: 'unsupported', detail }) measured on both skips. Declared: the changeset's @objectstack/plugin-sharing bullet.
  • 5. Sentences — TRUE for every refused class, one precision flag. The widened arrayComparandError names each class the shape walk and the per-record arm refuse ($ne, equality, ordering, $in/$nin member, { $field } list-or-object either side), its remedies, and the measured failure directions; it names no field, operator or value (pinned with secret columns). 2a's changeset and its registry entry rls-predicate-array-comparand-refused (replacement: "{ $field } references evaluate exactly as before") are now FALSE for a list- or object-holding column; the 2d changeset states the delta in one sentence and the 2d entry's replacement restricts the claim to "single-valued columns" — correct. The 2a entry text itself is not corrected (item ③). Precision flag on the @objectstack/lint bullet, "an ordering comparison against a membership set through the reference pass": measured true only opposite a field (record.reviewer_id > current_user.org_user_ids → rls-predicate-unenforceable); the constant-branch forms current_user.org_user_ids > 'a' / != 'x' / == 'u' are silent at os validate (no lowered field site for the swap to attribute) and are refused per request at runtime with the DENY WARN. Non-blocking: the PR body's "resolved values are refused per request" covers them, the runtime is fail-closed, and the lint file is RLS policies are admitted when they are authored: policy save and objectstack validate / compile judge the lowered read-scope filter with the engine's judge-only method (objectstack#19995 ruling C, second consumer) #20158's.
  • 6. Pins — RIGHT; one flip, declared. Formula: every refused literal form asserted unsupported + shape-refused + isSupportedRlsExpression false; every resolved form unsupported with the shape passing, the variable PATH named and five secrets withheld; each remedy; neighbours (one bound, flat in, scalar folds, field-to-field ==/!=). Evaluator: the SHAPES table gains the four ordering ops and $in/$nin members, run at every depth, empty array and every record; a per-record describe (both sides, negated, ordering, object, offset form, under $or) with a one-value control and a withholding check. plugin-security: leak × door (check F/A with stored(), using read, getReadFilter deny) × 2 drivers, plus four enforced controls. Flip: { tags: { $eq: { $field: 'tags' } } } true → 400, declared in the PR body and the test comment; its two scalar lines kept. No other pin moved: 325 formula, 111 sibling plugin-security, 172 lint, 116 sharing, 56 having green. Ablation from this head: evaluator cut (3 arms) → 57/120 formula red, 8/56 plugin-security red (exactly the four (a) write rows × 2 drivers); lowering cut (4 anchors) → 25/120 red, 28/56 red (every (b)/(c)/(e) row; (d) stays green because RLS: a policy's compiled filter skips the shared comparand-shape faces, so a null list member or null ordering bound reaches driver-sql, and the read and the write check disagree (the read hides a row its own check admits) #20212's judgeCompiledComparands drops it independently); restore → both blobs equal HEAD (1fb6f28c…, 4c6ef215…), rebuilt, 120/120 and 56/56. Identical to the dev's numbers.

② Semver level

  • minor + BREAKING — RIGHT. The diff narrows two published accept sets (compileCelToFilter, matchesFilterCondition); AGENTS.md:1075 makes (narrowing) BREAKING, and check-changeset-no-major.mjs records the launch-window convention that 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) matches the 2a round-0 precedent and fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882. check-changeset-no-major --base origin/main exits 0 (no major; level axis N/A locally, Check Changeset success in CI).
  • Packages — RIGHT. @objectstack/formula (source), @objectstack/spec: patch (a new entry + regenerated registry, the 2a precedent), and four behaviour-moving packages with no source change (plugin-security, plugin-sharing, lint, objectql) — the 2a review's own non-blocking asked for exactly this ("its CHANGELOG will carry no sentence for the 400 an upgrading agent greps; add it to the same changeset"). All six are in the fixed group (69), so no version is moved that would not have moved.
  • registered — RIGHT. 18.cel-predicate-one-value-comparand-refused.ts is new in this diff, resolves at HEAD, and check-adr-0087-registration --base origin/main reads registered cel-predicate-one-value-comparand-refused (new here); check:migration-registry: current (265 semantic). The entry's surface/replacement/reason/acceptanceCriteria name every refused shape and the FROM → TO rewrite, and it correctly adds no D2 conversion (the platform cannot tell which comparison a predicate stood in for).

③ Boundary flags

Implemented-by: claude/issue-19886-stage-2d-same-class-leaks
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 17:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 4d7e740 Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19886-stage-2d-same-class-leaks branch September 27, 2026 17:22
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>
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