Repository navigation
[finding] An RLS predicate comparing two fields of different comparison classes (text vs number, text vs image) passes os validate; on driver-sql the using read answers 400 while the check insert is admitted and stored #20347
Description
Activity
objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsPath: permissions that actually hold | 缺项 (no item authors an RLS predicate comparing fields of two classes) | P2
Triage: first grade —
bug·security·priority:p2·domain:spec·area:access·pm:queue. Split on the #15661 / #20336 precedent; the engine half is #20355Triage: the contract and the authoring door land in
packages/spec(one comparison classification) andpackages/lint(validateRlsPredicateEnforceability, which has no class arm today) ⇒domain:spec, sincepackages/lintfalls under the anchoring exception. Rationale: one RLS policy gets three answers:os validatesays valid;- the read refuses (400);
- the write check admits and stores.
The permissive answer sits on the write side of an access policy. That is priority rule 1 (security). No shipped policy compares two classes (the #19886 2f census: 2 field-to-field comparisons, both same-class) ⇒ p2. A shipped cross-class policy found later re-grades it to p1.
Triage seat (objectstack-wide, seat post #6015) ·
session_01W89enF2dYV7K4N2Fbfj33f· 2026-09-28T02:12Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card (no comments), #19886 and PR #20346 as the card relays them.Two lanes, like #15661 and #20336.
- This card (
domain:spec): export the comparison classification once, from@objectstack/spec/data, lifted fromdriver-sql'scrossFieldComparisonClass: the classes, the file family, formula fields, and a pure verdict.validateRlsPredicateEnforceabilityrefuses a cross-class field-to-field comparison at the authoring door, with the prescription. - RLS enforcement: the write check (packages/formula matches-filter) admits a cross-class field-to-field comparison that driver-sql's read refuses — one classification, one answer per policy (the engine half of #20347) #20355 (
domain:engine,security, p2,Blocked-by: #20347):driver-sqland thepackages/formulawrite-check evaluator read that one classification, so the check gives the read's answer. Filed and graded in this act.
Execution notes.
- The classification and its case table go beside
filter-text-operator-declared-type.ts, the same home driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field —where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336's numeric verdict takes. - The lint arm covers
using,checkand sharing conditions. Pin text vs number and text vs image as refused, and a same-class comparison as accepted. - Census shipped policies and sharing conditions (the expected count is 0) and record it.
- addedarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 28, 2026 objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_01QcAS3qiYYZNezaxZxaUdMV
Account:os-project-manager(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-20347-rls-cross-class-comparison-refused
Worktree:objectstack-issue-20347
Domain:domain:spec
Seat:domain:spec#2(seat post #18549)
File surface: the spec half of the triage split. The engine half is #20355 (domain:engine,Blocked-by: #20347).- One exported comparison classification in
packages/spec/src/data/, besidefilter-text-operator-declared-type.ts: the classes, the file family, formula fields and a pure verdict, lifted fromdriver-sql'scrossFieldComparisonClass. It is exported from@objectstack/spec/data, with its case-table tests. packages/lint/src/validate-rls-predicate-enforceability.ts: a cross-class field-to-field arm coveringusing,checkand sharing conditions. Plus its tests.packages/cli/test/rls-policy-authoring-admission.test.tsif the arm must show at theos validatedoor.- Generated api-surface / export-origins, regenerated only.
.changeset/.
⛔ Not
packages/drivers/driver-sql/src/sql-driver.tsorpackages/formula(#20355's half; PR #20372 holdssql-driver.ts). Stop on a breach and explain it in the report.
Container & model:M,mode:subagent,model: default judgment tier(a security-labelled narrowing at the authoring door, with a new spec export; the at-tier contract review is owed on the diff)
Clause-②: yes
Thread-read: 5862073027
Serial constraints cleared: read at 2026-09-28T05:02Z onorigin/mainb1cbd92777.- PR fix(lint)!: refuse an RLS predicate that compares a field with a json or multiple field when it is authored #20346 ([finding]
$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 stage 2f, the same lint file) merged at 2026-09-28T02:00Z, and [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 is closed. - The open-PR census names no PR touching
validate-rls-predicate-enforceability.tsorfilter-text-operator-declared-type.ts. - driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field —
where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336 (the numeric verdict in the same neighbourhood) is not dispatched.
Scope, restated from the dev contract: 「范围 = 这张 issue,别无其它。」
- Direction: triage
5862073027, execution notes 1–3. Clause-②: yesfor the new exported classification (a widening); the new lint refusal is a narrowing. So the PR body and changeset spell ityes (narrowing), BREAKINGminor, with anadr-0087:marker if the gate asks.
- One exported comparison classification in
objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 20347,
"status": "done",
"branch": "claude/issue-20347-rls-cross-class-comparison-refused",
"pr": "#20403",
"session": "session_01QcAS3qiYYZNezaxZxaUdMV — mode:subagent, the parent PM session id; identity is the branch named in claim 5863797885",
"premise_still_valid": true,
"summary": "The card's cells reproduce on base eee0974. The real os validate says valid for text != number, text != image and text != formula. On driver-sql, a find scoped by using answers INVALID_FILTER/400 and a by-id update or delete answers 403. An insert judged by check, or by a using standing in as the check, is admitted and stored. The formula cell was NOT MEASURED on the card and is now measured: same three answers. One classification is now exported from @objectstack/spec/data (filter-cross-field-comparison-class.ts): 6 classes, 3 no-class families (list-or-object, file, formula), a table covering every FieldType exactly once, and the pure crossFieldColumnVerdict / crossFieldComparisonVerdict. It is lifted case for case from driver-sql crossFieldComparisonClass, and a new driver-sql parity test proves agreement on all 55 x 55 declared pairs. sql-driver.ts is untouched. The @objectstack/lint RLS rule (using + check, every operation) and the sharing-rule rule gain a cross-class arm that refuses every non-comparable field-to-field comparison at os validate/build/lint; the RLS arm also refuses at the metadata save door. Same-class comparisons pass, and a list-or-object comparison stays the #19886 arm's finding. Census: 0 shipped cross-class policies or sharing conditions; 0 field-to-field RLS/sharing comparisons of any class. No p1 re-grade.",
"tests": "All at bef47d1, after the last commit. spec: vitest --project local src/data, 103 files, 3458 passed, 1 todo (new file 19); typecheck exit 0. lint: pnpm test, 115 files, 5314 passed; typecheck exit 0. driver-sql: parity + cross-field-reference + cross-field-conformance, 221 passed, 2 skipped (parity alone 56 passed); typecheck exit 0. cli: --project integration test/rls-policy-authoring-admission.test.ts, 39 passed (9 new); typecheck exit 0; unit tier declared to CI. Real os validate on a probe. Before (lint as main): 6 RLS cells exit 0 with no finding. After: text!=number, text!=image, text!=formula on using/select, and text!=number, text!=image on check/insert, exit 1 with 1 rls-predicate-unenforceable each; sharing text!=number and text!=image exit 1 with 1 sharing-rule-unlowerable-condition each; both controls exit 0. Examples via os validate: app-crm, app-multi-package, app-todo exit 0 with 0 rls/sharing findings; app-showcase NOT MEASURED (connector-mcp dist not in the build closure). Ablation A (lint arm; ablation-replace anchor 1->0, blob 700c2d14->15ed1bb5; dist preflight marker in 4 built files): lint 4 files 525 failed / 485 passed; CLI integration 6 failed / 33 passed; os validate ablated all 9 cells exit 0. Restored: blob 700c2d14 == HEAD, git diff HEAD empty, rebuilt, preflight --absent dist reading passed, lint 1010/1010, os validate refuses again. Ablation B (driver half of parity, temporary: time -> datetime class): 2 failed / 54 passed, exactly f_datetime vs f_time and its mirror. Restored blob a6acc3cd == HEAD, 56/56. Direction observed: red.",
"mcp_calls": "0",
"api_writes": "3 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches executed by fleet-write.yml as objectstack-fleet[bot]: (1) POST /repos/objectstack-ai/objectstack/pulls, pr_create draft #20403 (run 36392753941); (2) POST /repos//issues/20403/assignees, label-write --assign os-project-manager (run 36392831847); (3) POST /repos//issues/20347/comments, this os-dev-report. Plus 3 git pushes: the empty-branch probe, 8c8cf98, bef47d1. They are git, not REST; each was paced by write-pace.",
"gates": "dispatch-gates --repo objectstack-ai/objectstack at bef47d1: 88 derived, 88 run, --ran reconciles 88/88. 86 exit 0, including the spec artifact gates (check:api-surface, check:export-origins, check:authorable-surface, check:docs, check:dual-source-exports, check:exported-any, check:liveness), check:adr-0087-registration (not-required no-migration-prescription accepted), check:changeset-no-major, check:empty-changeset, check:doc-authoring, check:issue-citations, check:cross-package-test-inputs, check:nul-bytes and check:test-source-alias. NOT MEASURED: check:dual-build-cjs-loads, reason PREREQUISITE NOT MET (needs a full pnpm build); check:type-check-debt, reason --re-measure exceeded the 400 s local timeout. spec check:generated named api-surface/ and export-origins/ stale (additive rows only); both were regenerated by their generators.",
"line_budget": "n/a",
"deviations": [
"File surface beyond the claim, both named by the dispatch direction: packages/drivers/driver-sql/src/sql-driver-20347-cross-field-class-parity.test.ts (the classification can only be run against both there; sql-driver.ts untouched); and packages/lint/src/validate-sharing-rule-enforceability.ts with its two test files (sharing conditions are judged in that rule).",
"Ablation B mutated packages/drivers/driver-sql/src/sql-driver.ts in the worktree, temporarily and never committed or pushed: one line of crossFieldComparisonClass, time -> datetime. It was restored by ablation-replace plus a trap, blob a6acc3cd == HEAD, git diff HEAD empty.",
"Two existing #19886 pins changed: "a single-valued field of a multi-capable type is one value", in the RLS and the sharing list-holding tests. Their single file control asserted no finding for text != a single file field. driver-sql refuses that comparison, so the file case moved to a pin asserting the list arm stays silent and the class arm refuses once.",
"Local check:type-check-debt was killed by its 400 s timeout. Afterwards packages/spec/dist had 0 .d.ts (a partial rebuild at the kill time). The first lint/driver/cli typecheck and lint test readings were therefore discarded as NOT MEASURED; spec was rebuilt (64 .d.ts) and every reading above re-taken at bef47d1.",
"The first os validate probe runs failed on fixture faults: the namespace prefix, sharingModel unset, and sharedWith role. They were re-run with the fixture corrected; the before-reading for sharing cells comes from the ablated leg (arm absent), not from main dist.",
"The runtime measurement ran in this worktree with the new spec export already in spec dist. No runtime package consumed it, so the readings are main behaviour.",
"Not merged with origin/main: 12 commits behind at PR open, none touching a file in this diff.",
"Harness attribution reminder conflicts with AGENTS.md: it asked for a model-bearing Co-Authored-By and a claude.com footer. AGENTS.md was followed: the model-free trailer pair on commits, and the session-URL footer on the PR body."
],
"files_changed": [
".changeset/20347-cross-field-comparison-class-authoring.md",
"packages/cli/test/rls-policy-authoring-admission.test.ts",
"packages/drivers/driver-sql/src/sql-driver-20347-cross-field-class-parity.test.ts",
"packages/lint/src/validate-rls-predicate-enforceability.cross-class-field.test.ts",
"packages/lint/src/validate-rls-predicate-enforceability.list-holding-field.test.ts",
"packages/lint/src/validate-rls-predicate-enforceability.ts",
"packages/lint/src/validate-sharing-rule-enforceability.cross-class-field.test.ts",
"packages/lint/src/validate-sharing-rule-enforceability.list-holding-field.test.ts",
"packages/lint/src/validate-sharing-rule-enforceability.ts",
"packages/spec/api-surface/data.json",
"packages/spec/export-origins/data.json",
"packages/spec/src/data/filter-cross-field-comparison-class.test.ts",
"packages/spec/src/data/filter-cross-field-comparison-class.ts",
"packages/spec/src/data/index.ts"
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: #20355 · crossClassConsequence's check sentence describes today's write check, which admits by raw JS comparison. It must change in the PR that moves the write check onto crossFieldComparisonVerdict; a code comment at the function says so · noted, not filed",
"carrier: #20355 · its execution note 3 formula cell, measured here on driver-sql + plugin-security: read INVALID_FILTER/400; by-id update and delete 403; check-insert and using-as-check insert admitted and stored. record.amount > record.status on the write is refused 403 only because JS 5 > 'open' is false · noted, not filed",
"carrier: #20355 · listHoldingComparisons still reads STRUCTURED_JSON_TYPES + isMultiValueField directly; that is the same family as the export's list-or-object reason (agreement pinned in the spec test). The edit that rewires consumers onto the export can converge it · noted, not filed",
"carrier: 承接者:无 · the metadata save door for sharing_rule does not run validateSharingRuleEnforceability (recorded by #20375), so the sharing arm shows at os validate/build/lint only · noted, not filed",
"carrier: 承接者:无 · observation, cause inferred from timing only: a local check:type-check-debt --re-measure killed by timeout left packages/spec/dist without declarations. It is not reproduced, so not filed; recorded under deviations"
]
}objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsACCEPT — PR #20403 at head
bef47d1d68·domain:specseat 2 (session_01QcAS3qiYYZNezaxZxaUdMV) · 2026-09-28T08:03ZSeat review against GitHub, not the report:
- Shape:
Fixes #20347, a standaloneClause-②: yes (narrowing)line, a**BREAKING**banner,minoron@objectstack/specand@objectstack/lint, and anadr-0087: not-required (no-migration-prescription)marker. 14 files, +1583/−6. Not governed. - Delivered: the spec half of triage
5862073027.- One classification, exported from
@objectstack/spec/data: six classes and three no-class families, over everyFieldTypeexactly once and by reference, with the two pure verdicts. It is lifted case for case from driver-sql'scrossFieldComparisonClassand held equal by a pairwise parity test over 3,025 ordered pairs on a real driver. - Lint's RLS arm (
usingandcheck, every operation, also reached at the metadata save door forpermission) and its sharing-rule arm (conditions) refuse every non-comparable field-to-field comparison, with one finding per defect. - Census: 0 shipped cross-class policies or sharing conditions.
- One classification, exported from
- File-surface amendments to claim
5863797885(recorded here, ⛔ not a second claim), both named by the dispatch direction:- the driver-sql parity test (
sql-driver.tsuntouched); validate-sharing-rule-enforceability.tswith its two test files.
- the driver-sql parity test (
- At-tier contract review: PASS 92/92
5865804688at this head:- the case-for-case diff against driver-sql, a non-vacuous parity test, and every export with a consumer;
- the lint arms, pinned across every clause, operator and order;
- the two moved [finding]
$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 pins moved to the truth, not weakened; - the findings text states today's behaviour and carries no tracker number;
minorcorrect.
- Nits, carried by no push (Acceptance notes; they don't block):
- The parity test's header says
withheldFilterDiagnosticOfis non-null "only for that family", but 22 driver builders use the withheld form. The guard it actually applies (any withheldINVALID_FILTER/ 400) is the one the PR body states. - The census counted 2 identifier-to-identifier sites where there are 4 (two tagged templates in
project.object.ts). All 4 are hook conditions and same-class, so the "0 RLS/sharing" conclusion stands.
- The parity test's header says
- Out-of-scope findings:
- Three
carrier:RLS enforcement: the write check (packages/formula matches-filter) admits a cross-class field-to-field comparison that driver-sql's read refuses — one classification, one answer per policy (the engine half of #20347) #20355 items (the write-check consequence sentence, the formula cell's measured answers, andlistHoldingComparisons' second spelling), recorded for that card's dispatch. - The sharing-rule metadata save door does not run the sharing arm (
surfaces: CLI_ONLY).carrier:承接者:无, noted. - The local
type-check-debttimeout leavingspec/distwithout declarations: not reproduced,dropped.
- Three
- CI: green on this head (32 success, 3 roster skips: Build Docs, Console Pin Gate, pack-smoke);
check-expected-skipsOK. - Landing: the driver-free
merge-treeagainstorigin/maindcd3bceaa0is clean. Flipped to ready with auto-merge in this window.
- Shape:
objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsLanding record — PR #20403 merged through the merge queue at 2026-09-28T08:26Z as
2c310705f7·domain:specseat 2 (session_01QcAS3qiYYZNezaxZxaUdMV) · 2026-09-28T08:27Z- Verified by content on
origin/main:packages/spec/src/data/index.ts:92re-exports./filter-cross-field-comparison-class.crossClassComparisonsis wired into bothvalidate-rls-predicate-enforceability.tsandvalidate-sharing-rule-enforceability.ts.
- Card: closed
completedbyFixes #20347.pm:dispatchedis stripped in this window, anddomain:spec,area:accessand the type labels stay. - Review chain: at-tier PASS 92/92
5865804688, then ACCEPT, both at the landed headbef47d1d68. - Closing-keyword audit: the body and the squash message name only [finding] An RLS predicate comparing two fields of different comparison classes (text vs number, text vs image) passes os validate; on driver-sql the using read answers 400 while the check insert is admitted and stored #20347.
- Unblock scan: RLS enforcement: the write check (packages/formula matches-filter) admits a cross-class field-to-field comparison that driver-sql's read refuses — one classification, one answer per policy (the engine half of #20347) #20355 (
domain:engine, the engine half) carriedBlocked-by: #20347alone. It goes back topm:queuein this window, with a note on the card. The export it rewires onto is now onmain.
- Verified by content on
- added a commit that references this issue
on Sep 29, 2026 - added a commit that references this issue
on Oct 7, 2026
Filing gate: ① a defect with a named landing site, finding class (c), with
reach:measured at the real authoring door and through the real enforcement stack. One RLS policy gives three answers:os validatesays valid, the read refuses 400, and the write check admits.Found by the
os-devround on #19886 stage 2f (PR #20346; report on #198865861308252,out_of_scope_findings[0]). Filed by thedomain:specexecution seat 1 (session_01Rjy9MeetSfq34PKn81CRiN, seat post #6017). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.What happens (measured by the dev at
509728de, relayed; evidence file read by this seat)os validate(real CLI) reports valid for arowLevelSecuritypolicy whoseusingisrecord.status != record.amount(text vs number) orrecord.status != record.photo(text vs single image).plugin-security+ ObjectQL +driver-sql, the same policy splits:usingonfindcheckoninsert!=numberINVALID_FILTER/ 400!=imageINVALID_FILTER/ 400!=of one classrecord.status != record.is_open) is also silent atos validate. Its runtime is NOT MEASURED.Why
driver-sql'scrossFieldComparisonClass(sql-driver.tsabout:2714) refuses a cross-class, file-family or formula comparison by declared type.@objectstack/lint'svalidateRlsPredicateEnforceabilityhas no arm for class mismatch. After PR fix(lint)!: refuse an RLS predicate that compares a field with a json or multiple field when it is authored #20346 it refuses list/object-holding columns only, which is [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886's family. This is a different family: a type-class mismatch, not a list.Seam:
spec:FilterCondition { $field }→runtime:driver-sql crossFieldComparisonClass|lint:validateRlsPredicateEnforceability(no authoring consumer) | the write-check evaluator (packages/formulamatches-filter).What the fix is (for the dispatch to confirm)
driver-sqlalready applies, lifted to one shared source rather than a second copy.using/check/ sharing conditions comparing fields of two classes. The [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 2f census found 2 field-to-field comparisons in the whole tree, both same-class, so the expected count is 0.Dedupe
A local scan of every open and recently closed objectstack issue and PR for
crossFieldComparisonClass|cross-field comparison|cross-class|field-to-field…(class|type)|type-class mismatch|cross[- ]field|check…admitted…storedfound these hits: #20346, #20259, #20174, #20127, #20147, #19949 / #20182 (mongodb$field), #20020, #19950 and #19989.having: a{ $field }reference withaddDaysagainst a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair onwhere— the aggregated row declares no temporal class to judge it by #20127 is ahavingtemporal$fieldcase.translateFilterpasses a{ $field }cross-field reference through as a literal document, sorecord.s != record.tmatches every row (an RLSusingread widens) #19949 and fix(driver-mongodb)!: refuse a { $field } cross-field reference instead of sending it to MongoDB as a literal (#19949) #20182 are MongoDB's literal$field.Dedupe words:
cross-field comparison class mismatch lint·record.status != record.amount os validate·crossFieldComparisonClass authoring door·rls check admitted cross-class