Skip to content

fix(plugin-security)!: a row-level policy whose compiled filter carries a null list member or null ordering bound fails closed on both clauses, so its read and its write check agree (#20212) - #20243

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20212-rls-composed-filter-faces
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20212-rls-composed-filter-faces

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20212
Clause-②: no (narrowing)

What changes

RLSCompiler.compileFilter (packages/plugins/plugin-security/src/rls-compiler.ts) now runs the two shared comparand faces, assertListComparandShapes and normalizeFilterComparandTypes from @objectstack/spec/data, on every compiled policy filter, for using and check alike, with or without a field guard. A policy the faces refuse joins deniedBy with the new drop reason refused-comparand. That is the per-request route a list under == / != (the type family) already takes. Alone, the clause answers RLS_DENY_FILTER with one [RLS] DENY (fail closed) WARN. Beside a policy that compiles, the refused policy drops out of the OR silently. The faces are called and their verdict is routed; no null rule is written in plugin-security or in a driver. A thrown value without the ADR-0112 envelope is re-thrown, not treated as a verdict.

@objectstack/lint: the rls-predicate-unenforceable message for a null-family comparand said the RLS layer does not run the faces, so the policy survives and analytics refuses every query. This change made that false, so the message now gives the drop and the clause's own consequence, as it already does for a type fault.

Seam: where the RLS filter is composed and where check is evaluated (H1, H2)

  • Composing layer: SecurityPlugin's engine middleware, step 3 "RLS filter injection" (computeLayeredRlsFilter, then RLSCompiler.compileFilter(…, 'using'), then andComposeLayers), writes opCtx.ast.where = { $and: [callerWhere, …] }.
  • Evaluating layer for check: the write gate, step 3.6: computeWriteCheckFilter, then RLSCompiler.compileFilter(…, 'check'), then matchesFilterCondition(image, filter) (@objectstack/formula), in-process.
  • The engine runs the faces only in lowerWhereFilterArray, on the caller's where, before executeWithMiddleware. Nothing judges opCtx.ast.where after the middleware composes it. Measured: at base the null-family filter reached SqlDriver and was executed (P1 read returned r_none), where any face would have thrown INVALID_FILTER. judgeFilter runs the same stage 1 (assertListComparandShapes, assertFilterIsMaterializable, assertTextOperatorTargetsAreStringCapable, assertTemporalComparandsInterpretable, normalizeFilterComparandTypes, or the array branch's parseFilterAST) and stage 2 (resolveWhereFilterTokens), on the caller's where only.
  • Chosen seam: RLSCompiler.compileFilter, for both halves. Both clauses compile there (read middleware, write gate and analytics' getReadFilter), and the type family's DENY (fail closed) route already lives there. See Deviations.
  • The engine's composed-where seam gets no call. The other producers that compose onto ast.where are the Layer 0 tenant wall, controlled-by-parent (id lists), plugin-sharing (ids, owner, self scope), the attachment and comment access hooks, and the engine's own search filter. All are platform-produced; none carries an authored comparand. A call there could only answer a refusal as a 400 the caller cannot act on (the note in objectql's filter-comparand-shape.ts says why injected filters are not judged there), and it would never fire for any producer in this repository.

Measured: base 805af4f290 against head (compiler blob 2438357788)

The real SecurityPlugin + ObjectQL stack, caller usr_member, rows r_open / r_closed / r_none (status NULL), on InMemoryDriver, SqlDriver (better-sqlite3) and SqlDriver on a private PostgreSQL 16 server. Each predicate was run once as using (find / findOne / count) and once as check (insert closed, insert open, by-id update to closed).

predicate using read at base (memory / sqlite / pg) check at base (closed / open / update) head, every driver
P1 !(record.status in ['open', null]) r_closed / r_none / r_none, no WARN ok / 403 / ok 0 rows + WARN; 403 / 403 / 403 + WARN
P2 record.status in ['open', null] r_none r_open / r_open / r_open 403 / ok / 403 0 rows + WARN; 403 / 403 / 403 + WARN
P3 record.status > null ($gt: null) none / none / none, no WARN 403 / 403 / 403, no WARN 0 rows + WARN; 403 / 403 / 403 + WARN
P4 at-most null ($lte: null) r_none / none / none 403 / 403 / 403 0 rows + WARN; 403 / 403 / 403 + WARN
P5 record.status in [null] r_none / none / none 403 / 403 / 403 0 rows + WARN; 403 / 403 / 403 + WARN
control record.status == current_user.positions 0 rows + WARN, all three 403 x3 + WARN byte-identical to base
control record.status != current_user.positions 0 rows + WARN, all three 403 x3 + WARN byte-identical to base

At base, P2 and P5 also admitted a NULL-status insert. At head that insert is refused too.

  • Read and check now agree on every row, and each null-family row answers exactly as the type-family control does, WARN included (reason: 'refused-comparand' against the control's unsupported).
  • Several policies, OR-combined (H3): P1 beside record.status == 'open' reads r_open with no WARN and admits open / refuses closed. That is exactly the control beside the same sibling. At base the P1 pair read r_closed r_open (memory) or r_none r_open (SQL) and admitted closed.
  • The caller's own where (H3), measured with $in: ['open', null], $not of that, $gt: null, $lte: null and the array form: INVALID_FILTER / 400, same message, byte-identical base to head.
  • Null checks (H3): record.status == null and != null are byte-identical base to head.
  • No collateral (H4): 243 cells per side (the three drivers). Every cell whose policy carries no null-family comparand is byte-identical base to head. That covers read rows, write answers, the RLS / Security WARN lines, getReadFilter and explain. The spread: ==, !=, lists without null and their negation, ordering (>, at-most, a two-sided range), == null / != null, startsWith / endsWith / contains, in current_user.org_user_ids and its negation, current_user.id / .email, an || and an && composite, an emptied membership, an undeclared column, an unresolved current_user key, and tenant-scoped objects on SQLite and PostgreSQL (InMemoryDriver refuses multi-tenant objects with MEMORY_MULTI_TENANT_UNSUPPORTED at base and head alike). The only differing cells are the null-family ones: the five, the three multi-policy rows carrying one, and one tenant-scoped null-family row.

Tests (at 2a0d7da0b1 unless stated)

  • New: packages/plugins/plugin-security/src/rls-null-comparand-fails-closed.test.ts. On the real stack, SqlDriver: the five shapes and the two controls, as using and as check; the sibling position for each family; null-check and list controls; the caller's own where. Also new: rls-compiled-comparand-faces.test.ts, a unit test on compileFilter: seven shapes x two clauses x with and without a guard, the sibling, two refused policies, and 18 accepted predicates that compile to exactly compileCelToFilter's output with no WARN. 84 passed.
  • pnpm --filter @objectstack/plugin-security test: 139 files, 2839 tests passed. run typecheck: exit 0, check:test-typecheck OK.
  • pnpm --filter @objectstack/lint test: 109 files, 4232 passed (at f3eb1d06c4). run typecheck: exit 0 (at 4d66d4d572; its lint source is unchanged since).
  • pnpm --filter @objectstack/driver-memory test: 56 files, 1362 passed (at f3eb1d06c4).
  • pnpm --filter @objectstack/objectql test: 318 files, 5762 passed (at 4d66d4d572, before merging origin/main; this diff does not touch objectql).
  • pnpm --filter @objectstack/driver-sql test with OS_TEST_POSTGRES_URL set to a private PostgreSQL 16 server (zone Asia/Shanghai, process TZ=America/New_York): 196 files passed, 3 skipped, 3806 passed, 86 skipped (MySQL cells, not provisioned); live postgres RAN (at 4d66d4d572).
  • Reverse verification at 2a0d7da0b1: scripts/ablation-replace.mjs replaced the one judgeCompiledComparands(outcome.filter) call with a pass-through (anchor 1 to 0, blob 2438357788 to e6a410b0b5). The two new files went 42 failed, 42 passed out of 84: every null-family cell was red, and every control and accepted-predicate cell stayed green. The restore was verified by blob equal to HEAD and an empty git diff HEAD. The subject is imported from src/ by relative path, so no dist/ is on the resolution path.
  • eslint --no-inline-config --format json on the 5 changed TypeScript files: 5 files, 0 errors, 0 warnings. All 5 are inside eslint's population (--print-config resolves each). The config enables no type-aware linting (parserOptions.project / projectService null), so this diff cannot move any untouched file's verdict.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 2a0d7da0b1 derived 63 commands. --ran reconciliation: 63 accounted, 60 run, 3 NOT MEASURED, 0 unrun. Every run command exits 0 except check-empty-changeset --base origin/main (exit 1, by design; see Deliberate corrections). The six families the dispatch named that this diff no longer derives also exit 0: check-engine-split-ratio (both), check:dispatcher-error-vocabulary, check:durability-log-level, check:stack-collection-maps and check:swallow-census-controls. So do check:authz-resolver, check:error-code-casing and check:filter-alias-parity, and node scripts/check-issue-citations.mjs --base 615c468746 (6 citations resolve).

  • NOT MEASURED: check:dual-build-cjs-loads, check:i18n, check:type-check-debt. Reason: PREREQUISITE NOT MET (exit 3), each needs the whole-workspace build; CI builds it.

Deliberate corrections of two pending changesets

This change made one sentence false in each of two pending release notes, and each gets a one-clause correction. check-empty-changeset stays red on both by design, which puts the decision in front of a person. Please confirm.

  • .changeset/19951-rls-lint-per-request-refusals.md said "The RLS layer does not run that check on its own filter, so the backend answers with its own semantics … Every analytics query over the object is refused." It now says the RLS compiler runs that check too and drops the policy: reads return no rows, and check writes are refused 403.
  • .changeset/20018-native-read-scope-faces.md said such a policy "now gets the withheld 500 on every analytics face". It now says the compiler drops the policy, so the analytics faces receive the deny sentinel and answer zero rows.

Deviations

  • The seam is RLSCompiler.compileFilter, not "the one engine seam" in triage's words. The write check never passes the engine's composed where, so a call only there would have flipped the disagreement: the read would deny while the check admits. The compile step is the one place both clauses (and analytics' read scope) pass, and the type family's fail-closed route lives there. The dispatch's ruling section anticipated this choice.
  • The real-stack pin runs on SqlDriver only. Triage's execution note 1 names InMemoryDriver too, and it was measured (table above, base and head), but a new test consumer of @objectstack/driver-memory is a ledgered, maintainer-ruled decision (pnpm check:driver-memory-census: "Is this consumer legitimate? is a maintainer ruling"). A first version of the pin added the devDependency and the census went red, so it was reverted. The open question is in the report.
  • packages/lint is off the claim's file surface. Its finding text read false after this change, so it was corrected in the same PR.

Acceptance notes

  • Analytics (execution note 2; service-analytics: object-form analytics where skips the shared comparand-shape face's other arms ($in null member, $gt: null, null/blank $between bound, scalar $in) that the FilterArray spelling refuses 400 #20010's door is untouched): measured with the three analytics faces (ObjectQL execute, the /analytics/sql echo run on the database, NativeSQL execute). With the base read scope of each null-family policy, all three answer READ_SCOPE_COMPILE_FAILED / 500. With the head read scope (getReadFilter now answers the deny sentinel), all three answer zero rows. So a null-family policy now answers the same on the data path and the analytics path. Beside a granting sibling, analytics moves from refusing the whole query to serving the sibling's rows, as it already did for the type family.
  • explain read verdict: rls: narrows at base, rls: denies at head, the same as the type-family control.
  • driver-memory's "constructively unreachable through the compile face" note in memory-matcher.ts is now true on the RLS path too. The head column shows no null-family filter reaches any driver. That file is not edited.
  • packages/lint/src/validate-rls-predicate-enforceability.ts still says the analytics read-scope compiler runs the faces on every RLS read scope. That stays true, but a null-family policy no longer produces such a scope. Noted, not edited.

Generated by Claude Code

…d RLS policy filter

RLSCompiler.compileFilter now judges each compiled policy filter with
@objectstack/spec/data's assertListComparandShapes and
normalizeFilterComparandTypes. A policy the faces refuse (a null list
member, a null ordering bound) joins the per-request fail-closed route,
so the using clause and the check clause refuse together.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
… RLS denial detail

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…sed on both clauses, SqlDriver and InMemoryDriver

The five shapes of the card, as `using` and as `check`, through the real
SecurityPlugin + ObjectQL stack on SqlDriver and InMemoryDriver, with the
type family (a membership set under == / !=) as the control. A unit pin on
RLSCompiler.compileFilter holds the drop, the WARN reason and the
pass-through of every filter the faces accept.

driver-memory joins plugin-security's devDependencies, aliased to source.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…d as the drop the runtime now makes

The finding's message said the RLS layer does not run the shared comparand
faces on its own filter, so the policy survives and analytics refuses every
query. RLSCompiler now runs them, so the message gives the per-request drop
and the clause's own fail-closed consequence, as it does for a type fault.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…rect two pending entries the change makes false

The 19951 and 20018 entries said the RLS layer does not run the shared
comparand faces on its own filter and that such a policy gets the withheld
500 on every analytics face. Both now read false: one clause each.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…Driver; no new driver-memory test consumer

A new test consumer of @objectstack/driver-memory is a ledgered, ruled
decision (check:driver-memory-census), not one a test file makes. The
refusal happens in the compile step, before any driver is asked, and the
unit pin holds that step; the InMemoryDriver readings are recorded on the PR.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
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

8 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
  • 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 — 18 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 c02fa12767eeee8ffde3ff7e48830c182df2118f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9fd2d2ab03b163ca23946f9de3732a9901578d09 — the merge of head 2a0d7da0b1f5db3feb5d587fb73f7bc2297582f1 into base c02fa12767eeee8ffde3ff7e48830c182df2118f, 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 9fd2d2ab03b163ca23946f9de3732a9901578d09 && git checkout 9fd2d2ab03b163ca23946f9de3732a9901578d09
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c02fa12767eeee8ffde3ff7e48830c182df2118f 2a0d7da0b1f5db3feb5d587fb73f7bc2297582f1 && git checkout -B drift-repro c02fa12767eeee8ffde3ff7e48830c182df2118f && git merge --no-ff 2a0d7da0b1f5db3feb5d587fb73f7bc2297582f1

node scripts/docs-audit/affected-docs.mjs --json c02fa12767eeee8ffde3ff7e48830c182df2118f

⚠️ 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: 2a0d7da0b1f5db3feb5d587fb73f7bc2297582f1

Scope: PR #20243 (card #20212, p2, area:access), 8 files, +570/−27, base = merge base with origin/main 615c468746; read from detached worktrees of head and base, each closure built (turbo run build, exit 0 both). The branch's merge of main at f3eb1d06c4 brought 3 commits (805af4f290..615c468746, 33 files) — intersection with the PR's 8 files: none (the two packages/spec/src/data files it carried are filter.zod.ts / filter-like-code-point.test.ts, not the comparand-shape face). Inputs: card + comments 5854946815 / 5855172230 / 5855751762 / 5856443791 (treated as claims) / 5856466622; PR body, diff, check-runs; contract-review.md, landing-operations.md, AGENTS.md from origin/main. Probe: real SecurityPlugin + ObjectQL, MEMBER usr_member, rows r_open / r_closed / r_none(NULL), 79 cells × {InMemoryDriver (dist), SqlDriver/SQLite, SqlDriver/PostgreSQL 16.13 (private role+db, started with pg_ctlcluster 16 main start)} × {base, head}; results JSON-diffed. Worktrees removed, role/db dropped, cluster stopped.

① Derived judgments

  • Rows (item 1) — bar met on all three drivers. Head, each of !(status in ['open',null]), status in ['open',null], status > null, status <= null, status in [null]: find [], findOne(r_closed) null, count 0, getReadFilter = __rls_deny__ sentinel, explain rls denies, exactly one [RLS] DENY (fail closed) WARN with reason: 'refused-comparand', same WARN template as the control (== current_user.positions → unsupported); check: insert open / closed / NULL and by-id update all PERMISSION_DENIED/403, nothing stored, one DENY WARN (check clause). Read and check agree in every cell; field-for-field equal to the type-family control (assert script: ALL NULL-FAMILY CELLS MEET THE BAR, memory / sqlite / pg). Base reproduces the card: P1 read [r_none] on SQLite and PG (hides r_closed), [r_closed] on memory, while its check admitted closed insert and the update; P2 and P5 admitted a NULL insert. PG answered as SQLite in every base cell.
  • Seam (item 2) — compileFilter covers the three doors. using: computeLayeredRlsFilter → compileFilter(…,'using') (security-plugin.ts:7077); check: computeWriteCheckFilter → compileFilter(…,'check') (:7343) → matchesFilterCondition (:3167); analytics: getReadFilter → computeRlsFilter → same Layer-1 compile (:5240, :6774) — measured: head getReadFilter answers the sentinel for all five. Other ast.where composers, judged from code: tenant Layer 0 {organization_id: id} / {$in: ids} (tenant-layer.ts:177-179), controlled-by-parent {fk: {$in: ids}} (:7741-7767), plugin-sharing buildReadFilter {owner} / {id: {$in: grantedIds}} / {id:'__deny_all__'} (sharing-service.ts:441-484), attachment/comment hooks {parent_object, parent_id: {$in: visible}}, engine search filter from the search string — none carries an authored comparand. Finding (out of scope): a sharing rule's authored CEL condition lowers through the same compileCelToFilter and CAN carry a null list member into criteria_json, but it is consumed as an engine find filter under a system context (sharing-rule-service.ts:1219-1233), i.e. through the caller-where seam — measured on all drivers, base and head: INVALID_FILTER/400, which that method catches as [sharing-rule] criteria query failed and matches no rows (fail closed, inert rule). No authored comparand reaches a driver unjudged.
  • No collateral (item 3). 60 of 79 cells byte-identical base→head per driver (rows, write answers, RLS/Security lines, getReadFilter, explain, analytics faces); the 19 differing cells are exactly the null-family ones (P1–P5 ×2 clauses, P1+sibling ×2, P2+C1 ×2, P3+sibling read, P1+P2 ×2, tenant-scoped P1 ×2). Spread covered ==, !=, lists ± negation, >, <=, range, == null/!= null, startsWith/endsWith/contains, in current_user.org_user_ids ± negation, current_user.id ==/!=, ||, &&, true, emptied membership (empty-membership), undeclared column (unknown-field), unresolved key (unresolved-variable), arithmetic (uncompilable WARN), tenant-scoped object (posture isolated; memory refuses MEMORY_MULTI_TENANT_UNSUPPORTED at base and head alike). Caller's own where with $in [open,null], $not of it, $gt null, $lte null, array form: INVALID_FILTER/400, message byte-identical; {status: null}, $ne null, $null unchanged. Existing DENY paths: 14/14 cells identical WARN count and wording; uncompilable WARN identical (5 lines). Multi-policy: P1+status=='open' at head reads [r_open] with no WARN, check admits open / refuses closed — identical to C1+sibling; P2+C1 and P1+P2 → DENY with two WARN lines (refused-comparand,unsupported / refused-comparand×2), same as C1+C2's two unsupported lines. explain: narrows→denies for P1–P5 on the plain object (= control). Analytics, fed the measured getReadFilter: base scopes → READ_SCOPE_COMPILE_FAILED/500 on the ObjectQL-face judge (assertReadScopeComparandsRunnable) and on compileScopedFilterToSql (native + echo); head → runnable, SQL "t"."id" = ?, aggregate count 0; P1+sibling: base 500 → head sibling rows (count 1). Judged a correction (one policy, one fail-closed answer on the data and the analytics path; the 500 was a withheld server fault for an authored policy) and disclosed: 20212 changeset "Analytics:" bullet, the 20018 correction, PR Acceptance notes.
  • Failure handling (item 4). With @objectstack/spec/data mocked to throw on marker fields: bare Error, Error with code but no status, non-Error object, thrown string → compileFilter throws, returns nothing, logs no WARN; enveloped (Error + string code + numeric status) → RLS_DENY_FILTER + one refused-comparand WARN. Real stack: find and insert reject with the bare error (no silent drop); getReadFilter's pre-existing catch-all still answers the sentinel with an error-level [security] getReadFilter failed … denying (fail-closed) line — unchanged by this PR, not silent. WARN text: … (using clause, refused-comparand): the compiled predicate carries a comparand the platform's shared filter faces refuse (INVALID_FILTER), so the policy was not handed to a driver. In the face's own words, written for a caller's filter: Operator "$in" on field "status" does not accept null as a list member (at where.$not.status.$in[1]). … Every applicable policy dropped, so the request is filtered by RLS_DENY_FILTER … Predicate: !(record.status in ['open', null]) — names operator, field, position and remedy; the existing DENY lines already echo the full predicate and the compiler's detail (variable path, member index, column), so nothing beyond; server-side log only.
  • Pins discriminate (item 5). Unablated: 2 files, 84 passed. Ablation const comparands = judgeCompiledComparands(outcome.filter); → pass-through: blob 2438357788 → e6a410b0b5 (matches the dev), 42 failed / 42 passed of 84, every failure a null-family → RLS_DENY_FILTER, one refused-comparand WARN cell. Restored with git checkout HEAD --: blob 2438357788d9c841de78d83d18694e1308c6b2fa, git status --porcelain clean.
  • Scope and merge (item 8). Diff touches nothing under packages/spec, packages/formula, any driver src/, packages/objectql, packages/services/service-analytics. Added code lines contain no null test on a comparand (judgeCompiledComparands only calls the two faces and routes the envelope; lint adds prose only). git merge-tree --write-tree origin/main(ab820016b3) 2a0d7da0b1 → tree 118167766d59236878d26992bb89d43b7db0ebc5, exit 0, no conflicts.
  • Prose (item 7). PR body: every measured claim checked against my cells — the base table (memory / sqlite / pg), "PostgreSQL answered as SQLite", the P2/P5 NULL-insert line, the multi-policy line, the caller-where line, the ablation numbers, both gate outputs — TRUE; no FALSE sentence found. Changeset 20212: table, "What now answers differently", "Unchanged", "Who is affected" (git grep at head: the only using/check strings naming null outside tests are null checks) — TRUE; one imprecision (below). 19951 correction: old→new is one line (numstat 1/1), the new sentence "The RLS compiler runs that check on its own filter too (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 drops the policy on every request: reads return no rows and check writes are refused 403" — TRUE at head (lone-policy frame, same frame as the type-family sentence above it); every other line byte-identical. 20018 correction: one wrapped sentence (numstat 2/2), "the RLS compiler drops such a policy (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), so the analytics faces receive the deny sentinel and answer zero rows" — TRUE at head (measured sentinel → "t"."id" = ? → 0); every other line byte-identical. Other pending notes (RLS, lint, analytics read-scope, comparand faces: 19974, 19975, 19995, 20010, 20035, 20039, 20068, 20157, analytics-row-scope-, rls-): none asserts the RLS layer skips the faces or that such a policy reaches a backend; none reads FALSE. Lint message at head (printed for using and check): "RLSCompiler DROPS the policy on EVERY request: it runs that same check on every compiled policy filter, before any backend sees it. The shape check passes, so the "uncompilable predicate" WARN is never logged; the only signal is a per-request "DENY (fail closed)" WARN, emitted only when nothing else applicable compiles" + the clause consequence — each clause TRUE against the measured cells; no tracker number in the runtime string. Lint test file: 120 passed at head.
  • The ruling's three acceptances hold on measurement: seam compileFilter (covers using, check, getReadFilter, no other authored-comparand composer); SqlDriver-only real-stack pin beside the unit pin (InMemoryDriver and PG re-measured here, base and head, consistent with the PR table); packages/lint widening (its old message was FALSE at head).

② Semver level

  • Clause-②: no (narrowing) — correct: reads that returned rows return 0, checks that admitted now 403; no new key on a published payload. refused-comparand is a member of the non-exported RlsDropReason type, appears only as a runtime WARN-meta string; dist/index.d.ts / .d.mts carry 0 occurrences; index.ts exports only RLSCompiler, RLS_DENY_FILTER; compileFilter signature unchanged. No widening arm needed.
  • @objectstack/plugin-security: minor with **BREAKING** banner under the launch-window convention; @objectstack/lint: patch (message text of an existing finding, rule id unchanged) — consistent with the changeset. node scripts/check-changeset-no-major.mjs --base 615c468746 --event ev.json (current PR body, 12992 bytes): "✓ This diff introduces no major bump. ✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and it grades a package whose packages/**/src/** it moves at minor or above — the declared narrowing is accounted for: - @objectstack/plugin-security: minor … · direction arm: narrowing — a BREAKING change; during the launch window it ships minor" exit 0.
  • ADR-0087 not-required (no-migration-prescription) — node scripts/check-adr-0087-registration.mjs --base 615c468746: "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/20212-rls-compiled-filter-comparand-faces.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription) reason: an enforcement change in the RLS compiler: no authorable key, spelling or stored shape moves …" exit 0. Consistent with the two shapes having been ruled at the face with no ledger entry.

③ Boundary flags

  • Open question 1 (InMemoryDriver pin) → C confirmed: refusal happens in the compile step; InMemoryDriver measured here base/head (P1 base [r_closed], head [] + WARN), consistent with the PR; a check:driver-memory-census consumer ruling is not worth one cell.
  • Open question 2 (two DELIBERATE CORRECTIONS) → A confirmed: both new sentences TRUE at head, every other line byte-identical; this same-head PASS record is the confirmation check-empty-changeset asks for.
  • Deviations (seam, SqlDriver-only pin, packages/lint off the claim surface): accepted, each judged above.
  • CI at head (item 9): 34 check runs, 30 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in)), 1 failure. Required contexts all success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. The one failure is Check Changeset, red by design on exactly the two corrected notes — log: "This PR adds 1 changeset(s), so it declares a release of its own. … ✓ No empty-frontmatter changeset introduced by this diff … ##[error].changeset/19951-rls-lint-per-request-refusals.md exists on the merge base and was not added by this PR, so changing or deleting it silently replaces somebody else's release note (#17712). … DELIBERATE CORRECTION -- your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it -- say so on the PR and get it confirmed", and the identical line for .changeset/20018-native-read-scope-faces.md. No other failure.

Implemented-by: claude/issue-20212-rls-composed-filter-faces
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

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 size/l tests tooling

Projects

None yet

2 participants