Skip to content

feat(spec,lint)!: refuse an RLS or sharing-rule comparison between two fields of different comparison classes when it is authored (#20347) - #20403

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20347-rls-cross-class-comparison-refused
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20347-rls-cross-class-comparison-refused

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20347
Clause-②: yes (narrowing)

The spec half of the #20347 triage split (5862073027), dispatched on claim 5863797885. Base eee09742, head bef47d1d. The engine half is #20355, which stays open and reads the export this PR adds. The changeset declares Clause-②: yes (narrowing), BREAKING, minor on both @objectstack/spec (new exports, a widening) and @objectstack/lint (a new authoring refusal, a narrowing).

What changes

  • One classification, exported once (packages/spec/src/data/filter-cross-field-comparison-class.ts, re-exported from @objectstack/spec/data, beside filter-text-operator-declared-type.ts).
  • Parity with driver-sql, run against both (packages/drivers/driver-sql/src/sql-driver-20347-cross-field-class-parity.test.ts). One object declares every FieldType member (49), plus the 6 multi-capable members flagged multiple: 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 equal crossFieldComparisonVerdict(a, b) === 'comparable' on every pair. A refusal counts only in the cross-field boundary's own withheld INVALID_FILTER / 400 form (withheldFilterDiagnosticOf non-null), never by prose.
  • The authoring door (packages/lint).
    • validateRlsPredicateEnforceability gains a cross-class arm. crossClassComparisons reads the lowered filter's { $field } sites against the declared field map. It reports rls-predicate-unenforceable for every comparison whose two columns are not comparable: ==, !=, >, >=, <, <=, either side, under ! too.
    • It covers using and check on every operation.
    • validateSharingRuleEnforceability reads the same function and reports sharing-rule-unlowerable-condition on a sharing rule's lowered condition.
    • A comparison against a list or an object stays the existing [finding] $ne with 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.
    • The finding names each comparison, each column's declared type and class (or why it has none), and the clause's measured run-time consequence. The hint lists every class with the declared types it holds, derived from the spec table.

Measured before (lint as on main), then after

Real os validate (packages/cli/bin/run-dev.js validate on a probe stack), plus the real plugin-security + ObjectQL on driver-sql (better-sqlite3 :memory:, one RLS policy on a text / number / image / formula object).

predicate os validate before find (using) insert (check) insert (using as check) by-id update / delete (using) os validate after
record.status != record.amount (text vs number) valid, exit 0 INVALID_FILTER / 400 admitted, stored admitted, stored 403 / 403 rls-predicate-unenforceable, exit 1
record.status != record.photo (text vs image) valid, exit 0 400 admitted, stored admitted, stored 403 / 403 refused, exit 1
record.status != record.is_open (text vs formula; the card's NOT MEASURED cell) valid, exit 0 400 admitted, stored admitted, stored 403 / 403 refused, exit 1
record.amount > record.status (number vs text) — 400 403 (JS 5 > 'open' is false) 403 403 / 403 refused (lint unit and door pins)
control record.status != record.note (text vs text) valid, exit 0 rows [r1] admitted admitted updated / deleted valid, exit 0

The check rows on insert read the same at os validate: valid before, rls-predicate-unenforceable after. Sharing-rule conditions, measured at the real os validate, first with the arm ablated (the before-state) and then restored: record.status != record.amount and record.status != record.photo went from valid (exit 0) to sharing-rule-unlowerable-condition (exit 1). The control record.status != record.note stayed 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 at bef47d1d) extracts every using / check / condition string literal: 163. It lowers each through the real compileCelToFilter (RLS through sqlPredicateToCel first); 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, both number.
  • 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 through scripts/ablation-replace.mjs. That tool landed each mutation (anchor count 1 to 0, blob changed) and restored it (the blob equals HEAD, and git diff HEAD is empty). A shell trap re-checked each restore by hash. The direction observed is the normal one: red.

  1. The lint arm. The guard line in crossClassComparisons was replaced with an unconditional continue, so the arm reports nothing. ablation-dist-preflight found the marker in 4 built @objectstack/lint files, so the mutation reached the dist/ the CLI consumes.
    • lint unit, the four cross-class and list-holding files: 525 failed / 485 passed of 1,010. Restored: 1,010 / 1,010 passed.
    • CLI integration rls-policy-authoring-admission.test.ts: 6 failed / 33 passed. The 6 are exactly the new REFUSED rows. Restored: 39 / 39 passed.
    • Real os validate, 9 cells. Ablated: all nine exit 0 with no finding, which is the before-state, sharing cells included. Restored: the 3 RLS using cells, the 2 RLS check cells and the 2 sharing cells exit 1, each with exactly one finding; both controls exit 0.
    • On restore, ablation-dist-preflight --absent passed its dist/ 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.
  2. The driver half of the parity pin. Temporarily, never committed: in sql-driver.ts's crossFieldComparisonClass, 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 are f_datetime and f_time, naming exactly f_datetime vs f_time: spec says cross-class, driver admitted and its mirror. Restored: 56 / 56 passed, blob equal to HEAD.

Tests (at bef47d1d)

All at bef47d1d, after the last commit, on a shared box.

  • @objectstack/spec
    • vitest 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/lint
    • pnpm test: 115 files, 5,314 passed.
    • typecheck (with the test layer): exit 0.
  • @objectstack/driver-sql
    • The parity test plus the two existing cross-field suites (sql-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.
    • The unit tier is declared to CI: no CLI source file and no unit-tier file changed.
  • Real os validate over the examples: app-crm, app-multi-package and app-todo exit 0, with 0 rls-predicate-* / sharing-rule-* findings. app-showcase is 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.
  • Spec generated artifacts: check:generated named api-surface/ and export-origins/ stale, both additive only. Both were regenerated with their generators, and check:api-surface and check:export-origins are green.
  • Gates: dispatch-gates --ran accounts for 88 of 88 derived families. 86 exited 0. Two are NOT MEASURED, and CI owns both:
    • check:dual-build-cjs-loads answered PREREQUISITE NOT MET: it needs a full pnpm build.
    • check:type-check-debt: its --re-measure passed the 400 s local timeout. The kill left packages/spec/dist without declarations, so the spec was rebuilt (64 .d.ts) before every lint, driver-sql and cli reading above.
  • The derivation warned that the tree is behind origin/main by one family file (scripts/cross-package-test-inputs.mjs). check:cross-package-test-inputs was run from this tree and is green.

Decisions

  • Formula has no class, whatever its returnType. That is driver-sql's answer: a formula is virtual, with no column to reference. The text-operator door reads returnType, but a column-to-column comparison needs a column on both sides. The measured runtime agrees (400 on the read).
  • The file family is refused by name. That is driver-sql's answer too (the ADR-0104 dual-encoding window), so image == image is refused as well.
  • A type outside FieldType is unjudged. A driver's aliases (integer, object, the absent-type string default) stay layered in the driver, as field-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.
  • Registry-injected columns are judged by the definition the registry provisions. record.status != record.created_at is refused (text vs datetime), because the driver sees the same column. id has no definition in the graph, so it is not judged.
  • Same rule ids as the list arm. The author's edit is the same kind: rewrite which two columns are compared.
  • Two existing pins changed, one in each [finding] $ne with 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-valued file field is one value" asserted no finding at all for record.status != record.subject with subject a single file. 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 / user keep the no-finding pin.
  • File surface beyond the claim, both required by the dispatch. The driver-sql parity test: the classification can only be run "against both" there, and it adds no line to sql-driver.ts. And validate-sharing-rule-enforceability.ts plus its tests: the direction covers sharing conditions, and that rule is where they are judged.

Acceptance notes


Generated by Claude Code

…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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 40 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/queries.mdx (via REFERENCE_VALUE_TYPES (literal, a string literal in CROSS_FIELD_COMPARISON_TYPE_CLASSES))
  • content/docs/deployment/validating-metadata.mdx (via validateSharingRuleEnforceability (symbol, a top-level function))
  • content/docs/protocol/objectql/query-syntax.mdx (via REFERENCE_VALUE_TYPES (literal, a string literal in CROSS_FIELD_COMPARISON_TYPE_CLASSES))
What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts) — pages documenting those are invisible to this run
  • 14 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 — 136 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 df3ba164a588805c6a3b07ec8d91c94737c47b52 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4ccd2487e884626c9d4640a04bbe71116c9abb7f — the merge of head bef47d1d68dbb52bc4db2c1d42a007bb41764fd2 into base df3ba164a588805c6a3b07ec8d91c94737c47b52, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs df3ba164a588805c6a3b07ec8d91c94737c47b52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 92/92 CONTRACT_REVIEW_TIER
Head-sha: bef47d1d68dbb52bc4db2c1d42a007bb41764fd2

① Derived judgments

  1. Classification true to driver-sql. Diffed case for case against sql-driver.ts:2772 (crossFieldComparisonClass, unchanged between merge base eee09742 and current origin/main): multi (isMultiValueField) / formula / JSON_COLUMN_TYPES (= STRUCTURED_JSON ∪ MULTI_OPTION + aliases) / FILE_REFERENCE_TYPES → null ⇔ spec no-class; NUMERIC_SCALAR_TYPES (= NUMERIC_VALUE_TYPES + aliases) → numeric; boolean/toggle, date, datetime, time; everything else text ⇔ STRING ∪ autonumber ∪ SINGLE_OPTION ∪ REFERENCE. Driver aliases (integer/int/float/object/array, absent-type string) are the only difference and are unjudged, as declared. Table union re-measured: 13 rows, 49 unique members = FieldType (49), no overlap, rows hold the set objects by reference (pinned toBe). Parity test: 55 columns (49 + 6 multiple: true), 3,025 ordered pairs on a real :memory: driver, expected = verdict === 'comparable'; it cannot pass vacuously (a coverage pin fixes the column set; all-refused or all-admitted mismatches by construction). One inaccuracy, non-blocking: the header says withheldFilterDiagnosticOf is non-null "only for that family", but 22 builders in sql-driver.ts use withheldFilterError; the guard is "any withheld INVALID_FILTER/400", which is what the PR body states. Over this fixture the only other reachable one is the JSON-target refusal, whose pairs the spec already calls no-class, and the mirrored pair still catches drift. Tightening if wanted: also match the diagnostic on compares against another field.
  2. Export surface. filter-cross-field-comparison-class.ts beside filter-text-operator-declared-type.ts, same shape (classes const, type table, field-meta slice, verdict fn); index.ts re-export; api-surface/data.json and export-origins/data.json +11/−0 each. Every export has a consumer (lint reads the classes, the table and both verdicts; the parity test the verdict). crossFieldColumnVerdict answering undefined off-vocabulary is the shape 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 needs to layer driver aliases above it. Lint's new module exports (crossClassComparisons, CROSS_CLASS_*, CROSS_FIELD_CLASS_LISTING) are not in packages/lint/src/index.ts, so no public lint surface moved; CROSS_FIELD_CLASS_LISTING is only read in-file (nit).
  3. Lint arms. crossClassComparisons reads loweredSites, which walks $and/$or/$not and every $eq/$ne/$gt/$gte/$lt/$lte with a { $field } operand, target or referent; 336-row pin covers 8 clause×operation cells × 7 operators (incl. !(==)) × 3 cells × both orders, plus 13×13 class-pair matrix, created_at registry column, self-compare. Comparable pairs, literal/null/current_user, undeclared column (unknown-field finding alone), id, and type: 'integer' are pinned clean. One finding per clause (dedup by written form); list-holding sides skipped (listHoldingDeclaration), and referenceFindings hands nothing to the engine judge when findings.length > 0 (rls.ts:1596). Save door named: saveMetaItem → assertRuntimeAuthoringRules (metadata-protocol/src/protocol.ts:16254) → evaluateRuntimeAuthoringGate (runtime-authoring-gate.ts:681) → rule validateRlsPredicateEnforceability, runtimeTypes: ['permission'] (authoring-rules.ts:1864); CLI integration pins 422 INVALID_METADATA. The two [finding] $ne with 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 file pins moved to the truth (driver refuses the family by name), not weakened; select/lookup/user keep [].
  4. Findings text. matches-filter.ts evalOp: $ne → !looseEq (strict ===), $gt → JS a > b; so "compares the two raw values … admitted and stored whenever that comparison happens to hold" is today's write check. By-id using fails closed: the pre-image gate catches the scoped find and throws PermissionDeniedError (security-plugin.ts ≈2660–2690). Sharing: findMatchingRows catches, warns [sharing-rule] criteria query failed, returns []. No tracker numbers in the new runtime strings (ADR-0058 D4 only, as the list arm already carries).
  5. Census re-measured over git ls-files examples packages (non-test): 4 identifier-to-identifier sites, all condition: hooks :88 (currency>currency), project.object.ts:114 (date<date) and :122 (currency>currency×1.2, both P-tagged templates the PR's literal extractor missed), gate fixture :1396. None RLS or sharing, all same-class: 0 stands; the PR's "2" is an under-count only.
  6. Changeset. Sentences check out (items 3–5). minor on both, **BREAKING** banner present, Clause-②: yes (narrowing) on its own line 10. Body carries no FROM→TO block or migration heading, so not-required (no-migration-prescription) is honest and passes the gate's prescription refusal; it matches the precedent (enforcement narrowing, remedy is a policy change, objectstack migrate meta has nothing to rewrite).

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

@objectstack/spec gains exports only → widening → minor. @objectstack/lint narrows an accept set → BREAKING, shipped minor under the launch-window convention, exactly as .changeset/rls-check-defaults-to-using.md. yes (narrowing) is clause2-line.mjs's "widens one surface and narrows another" reading. Correct.

③ Boundary flags

Implemented-by: claude/issue-20347-rls-cross-class-comparison-refused
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS

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 protocol:data size/xl tests tooling

Projects

None yet

2 participants