feat(spec,lint)!: refuse an RLS or sharing-rule comparison between two fields of different comparison classes when it is authored (#20347) - #20403
Conversation
…e RLS and sharing-rule authoring arms (#20347) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
…o fields of different comparison classes when it is authored; regenerate api-surface and export-origins (#20347) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4ccd2487e884626c9d4640a04bbe71116c9abb7f && git checkout 4ccd2487e884626c9d4640a04bbe71116c9abb7f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin df3ba164a588805c6a3b07ec8d91c94737c47b52 bef47d1d68dbb52bc4db2c1d42a007bb41764fd2 && git checkout -B drift-repro df3ba164a588805c6a3b07ec8d91c94737c47b52 && git merge --no-ff bef47d1d68dbb52bc4db2c1d42a007bb41764fd2
node scripts/docs-audit/affected-docs.mjs --json df3ba164a588805c6a3b07ec8d91c94737c47b52
|
Contract reviewServed-tier: 92/92 ① Derived judgments
CI at head (live head unchanged): 25 success, 3 skipped, 5 in progress (Test Core 3/5/6, Lint & Repo Gates, Type Check · workspace); Check Changeset, Governed Surface Queue Guard, Spec property liveness green. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20347
Clause-②: yes (narrowing)
The spec half of the #20347 triage split (
5862073027), dispatched on claim5863797885. Baseeee09742, headbef47d1d. The engine half is #20355, which stays open and reads the export this PR adds. The changeset declaresClause-②: yes (narrowing), BREAKING,minoron both@objectstack/spec(new exports, a widening) and@objectstack/lint(a new authoring refusal, a narrowing).What changes
packages/spec/src/data/filter-cross-field-comparison-class.ts, re-exported from@objectstack/spec/data, besidefilter-text-operator-declared-type.ts).CROSS_FIELD_COMPARISON_CLASSES:numeric,text,boolean,date,datetime,time) and three families with none (CROSS_FIELD_NO_CLASS_REASONS:list-or-object,file,formula).CROSS_FIELD_COMPARISON_TYPE_CLASSESclassifies everyFieldTypemember exactly once, by reference to the existingfield-value.zod.tssets. Nothing is re-listed.crossFieldColumnVerdict(field)answers one declared column;multiple: trueon a multi-capable type holds a list.crossFieldComparisonVerdict(left, right)answers two:comparable,cross-class,no-class, orunjudgedfor a type outsideFieldType.crossFieldComparisonClass(the [spec] SqlDriver 将$field编译为列对列比较(cross-field comparison push-down) #5222 boundary).sql-driver.tsis untouched: 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 rewires it, and PR fix(driver-sql): aggregate count / count_distinct / sum / avg answer numbers on PostgreSQL and MySQL #20372 holds that file.packages/drivers/driver-sql/src/sql-driver-20347-cross-field-class-parity.test.ts). One object declares everyFieldTypemember (49), plus the 6 multi-capable members flaggedmultiple: true. Every ordered pair (55 × 55 = 3,025) is compiled as{ a: { $eq: { $field: b } } }on a real:memory:SQLite driver. The driver's admit or refuse must equalcrossFieldComparisonVerdict(a, b) === 'comparable'on every pair. A refusal counts only in the cross-field boundary's own withheldINVALID_FILTER/ 400 form (withheldFilterDiagnosticOfnon-null), never by prose.packages/lint).validateRlsPredicateEnforceabilitygains a cross-class arm.crossClassComparisonsreads the lowered filter's{ $field }sites against the declared field map. It reportsrls-predicate-unenforceablefor every comparison whose two columns are notcomparable:==,!=,>,>=,<,<=, either side, under!too.usingandcheckon every operation.validateSharingRuleEnforceabilityreads the same function and reportssharing-rule-unlowerable-conditionon a sharing rule's loweredcondition.$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 arm's finding, so no comparison is reported twice. The new arm runs ahead of the engine-judge pass, like the list arm: one defect, one finding.Measured before (lint as on
main), then afterReal
os validate(packages/cli/bin/run-dev.js validateon a probe stack), plus the real plugin-security + ObjectQL on driver-sql (better-sqlite3:memory:, one RLS policy on atext/number/image/formulaobject).os validatebeforefind(using)check)usingas check)using)os validateafterrecord.status != record.amount(text vs number)INVALID_FILTER/ 400rls-predicate-unenforceable, exit 1record.status != record.photo(text vs image)record.status != record.is_open(text vs formula; the card's NOT MEASURED cell)record.amount > record.status(number vs text)5 > 'open'is false)record.status != record.note(text vs text)[r1]The
checkrows oninsertread the same atos validate: valid before,rls-predicate-unenforceableafter. Sharing-rule conditions, measured at the realos validate, first with the arm ablated (the before-state) and then restored:record.status != record.amountandrecord.status != record.photowent from valid (exit 0) tosharing-rule-unlowerable-condition(exit 1). The controlrecord.status != record.notestayed valid. At run time the seeded rule's criteria query meets the same driver-sql refusal the list-holding class meets (#20375 measured that path).The write-check answer is whatever JavaScript's comparison of the two raw values gives, so the permissive side of the policy is the write. That half is #20355's.
Census (expected 0): 0
A script over
git ls-files examples packages(tests, fixtures, docs, generated bundles excluded; 3,140 files atbef47d1d) extracts everyusing/check/conditionstring literal: 163. It lowers each through the realcompileCelToFilter(RLS throughsqlPredicateToCelfirst); 105 lower. It then lists every{ $field }comparison: 2.examples/app-showcase/src/data/hooks/index.ts:88:record.spent > record.budget, a hook condition, bothnumber.packages/lint/scripts/check-doc-formula-expressions.mjs:1396:record.a > record.b, a gate fixture.Neither is an RLS predicate or a sharing-rule condition, and both are same-class. The only programmatic predicate constant is
OWNERSHIP_FLOOR_PREDICATE(created_by == current_user.id), which is not field-to-field. So no shipped policy or sharing condition moves, and nothing re-grades to p1. The cloud repository was not in this session: NOT MEASURED.Ablation (one-time proof, committed state
bef47d1d)Two ablations, both run from the committed state
bef47d1d, each throughscripts/ablation-replace.mjs. That tool landed each mutation (anchor count 1 to 0, blob changed) and restored it (the blob equalsHEAD, andgit diff HEADis empty). A shelltrapre-checked each restore by hash. The direction observed is the normal one: red.crossClassComparisonswas replaced with an unconditionalcontinue, so the arm reports nothing.ablation-dist-preflightfound the marker in 4 built@objectstack/lintfiles, so the mutation reached thedist/the CLI consumes.rls-policy-authoring-admission.test.ts: 6 failed / 33 passed. The 6 are exactly the new REFUSED rows. Restored: 39 / 39 passed.os validate, 9 cells. Ablated: all nine exit 0 with no finding, which is the before-state, sharing cells included. Restored: the 3 RLSusingcells, the 2 RLScheckcells and the 2 sharing cells exit 1, each with exactly one finding; both controls exit 0.ablation-dist-preflight --absentpassed itsdist/reading (the marker is absent from all 14 built files). Its tree reading exited 3 only because two untracked scratch files were present at that moment; both are deleted now.sql-driver.ts'scrossFieldComparisonClass,if (type === 'time') return 'time'was changed to return'datetime'. The parity test imports driver source, so no build was needed. Result: 2 failed / 54 passed. The two aref_datetimeandf_time, naming exactlyf_datetime vs f_time: spec says cross-class, driver admittedand its mirror. Restored: 56 / 56 passed, blob equal toHEAD.Tests (at
bef47d1d)All at
bef47d1d, after the last commit, on a shared box.@objectstack/specvitest run --project local src/data: 103 files, 3,458 passed, 1 todo. The new classification test contributes 19.typecheck(tsc, scripts and the test layer): exit 0.@objectstack/lintpnpm test: 115 files, 5,314 passed.typecheck(with the test layer): exit 0.@objectstack/driver-sqlsql-driver-cross-field-reference,sql-driver-cross-field-conformance): 221 passed, 2 skipped. The parity test alone: 56 passed, one test per probe column (55 × 55 pairs), plus the coverage pin.typecheck: exit 0.@objectstack/cli--project integration test/rls-policy-authoring-admission.test.ts, the only CLI file touched (integration tier): 39 passed, 9 of them new.typecheck: exit 0.os validateover the examples:app-crm,app-multi-packageandapp-todoexit 0, with 0rls-predicate-*/sharing-rule-*findings.app-showcaseis NOT MEASURED this way: its config imports@objectstack/connector-mcp, which is outside this worktree's build closure. Its security files are in the text census above.check:generatednamedapi-surface/andexport-origins/stale, both additive only. Both were regenerated with their generators, andcheck:api-surfaceandcheck:export-originsare green.dispatch-gates --ranaccounts for 88 of 88 derived families. 86 exited 0. Two are NOT MEASURED, and CI owns both:check:dual-build-cjs-loadsanswered PREREQUISITE NOT MET: it needs a fullpnpm build.check:type-check-debt: its--re-measurepassed the 400 s local timeout. The kill leftpackages/spec/distwithout declarations, so the spec was rebuilt (64.d.ts) before every lint, driver-sql and cli reading above.origin/mainby one family file (scripts/cross-package-test-inputs.mjs).check:cross-package-test-inputswas run from this tree and is green.Decisions
returnType. That is driver-sql's answer: a formula is virtual, with no column to reference. The text-operator door readsreturnType, but a column-to-column comparison needs a column on both sides. The measured runtime agrees (400 on the read).image == imageis refused as well.FieldTypeisunjudged. A driver's aliases (integer,object, the absent-typestringdefault) stay layered in the driver, asfield-value.zod.ts's header says every alias does. 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's rewire keeps those aliases above the export. At the door, an out-of-vocabulary type is Zod's to refuse, and the arm reports nothing.record.status != record.created_atis refused (text vs datetime), because the driver sees the same column.idhas no definition in the graph, so it is not judged.$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 list-holding test. "A single-valuedfilefield is one value" asserted no finding at all forrecord.status != record.subjectwithsubjecta singlefile. driver-sql refuses that comparison (the file family has no class), so the no-finding reading was never the runtime's. Each pin now asserts that the list arm stays silent and the class arm refuses once.select/lookup/userkeep the no-finding pin.sql-driver.ts. Andvalidate-sharing-rule-enforceability.tsplus its tests: the direction covers sharing conditions, and that rule is where they are judged.Acceptance notes
listHoldingComparisonsstill readsSTRUCTURED_JSON_TYPES+isMultiValueFielddirectly. That is the same family as the export'slist-or-objectreason, and the two agree by construction (pinned in the spec test), but it is two spellings. Converging it ontocrossFieldColumnVerdictis the natural edit for whoever next touches that function (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 or the next [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-family change). Noted, not filed.sharing_ruledoes not runvalidateSharingRuleEnforceability, as fix(lint)!: refuse a sharing-rule condition that compares a field with a json or multiple field when it is authored #20375 recorded. The new sharing arm therefore shows atos validate/os build/os lintonly, like the list arm. Noted, not filed.checkconsequence sentence describes today's write check, which admits by raw comparison. When 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 moves the write check onto this classification, that sentence changes in the same change (a code comment atcrossClassConsequencesays so).Generated by Claude Code