Skip to content

fix(lint): RLS predicate enforceability reports the per-request refusals the shape check cannot see - #20210

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19951-rls-lint-membership-key-silent
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19951-rls-lint-membership-key-silent

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19951
Clause-②: no

validateRlsPredicateEnforceability judged an RLS predicate's shape with every current_user value replaced by a placeholder, so a predicate the runtime refuses because of a value passed os validate cleanly and enforced nothing. The rule's reference pass now reports two such classes as rls-predicate-unenforceable (error), each with a pasteable rewrite that uses the predicate's own field and key:

  1. A current_user value of the wrong type for its position. Examples: a membership set under == / != or handed to a string method, a one-value key on the right of in, the bare current_user object under in or a string method. The compiler refuses these on every request and RLSCompiler drops the policy.
  2. A lowered comparand the shared filter faces refuse by ruling. A null list member or a null ordering bound. These compile, and the RLS layer never runs the faces on its own filter, so the backend answers with semantics the platform left undefined.

Only packages/lint/src/validate-rls-predicate-enforceability.ts, its test and one changeset change. No runtime source is touched (formula lowering, RLSCompiler and the shared comparand faces are unchanged).

Shape table (Zone 2, item 1)

Lint readings come from a stack that declares the fields. Runtime readings come from a throwaway probe (never committed) built on the rls-list-literal-comparand-fails-closed.test.ts harness: real SecurityPlugin + ObjectQL + SqlDriver on better-sqlite3, caller usr_member, rows r_open (status open) and r_closed (status closed, reviewer usr_member), and check inserts of one open and one closed row. "main" is 560b724c; "this PR" is 77060e6d.

authored predicate lint on main lint, this PR using read (runtime) check write (runtime)
record.reviewer_id != current_user.org_user_ids 0 unenforceable 0 rows, DENY (fail closed) WARN 403 / 403
!(record.reviewer_id == current_user.org_user_ids) 0 unenforceable 0 rows, DENY (fail closed) 403 / 403
record.reviewer_id == current_user.positions 0 unenforceable 0 rows, DENY (fail closed) 403 / 403
record.reviewer_id in current_user.id 0 unenforceable 0 rows, DENY (fail closed) 403 / 403
record.reviewer_id in current_user 0 unenforceable 0 rows, DENY (fail closed) 403 / 403
record.reviewer_id.startsWith(current_user) 0 unenforceable 0 rows, DENY (fail closed) 403 / 403
record.reviewer_id.contains(current_user) 0 unenforceable 0 rows, DENY (fail closed) 403 / 403
record.reviewer_id.startsWith(current_user.org_user_ids) 0 unenforceable 0 rows, DENY (fail closed) 403 / 403
record.status in ['open', null] 0 unenforceable r_open (acts as in ['open']), no WARN open admitted / closed 403
record.status in [null] 0 unenforceable 0 rows, no WARN 403 / 403
!(record.status in ['open', null]) 0 unenforceable 0 rows (r_closed hidden), no WARN open 403 / closed admitted
record.status > null 0 unenforceable 0 rows, no WARN 403 / 403
record.status <= null 0 unenforceable 0 rows, no WARN 403 / 403
record.reviewer_id != current_user unenforceable (since #20189) unenforceable (unchanged) 0 rows, uncompilable predicate WARN 403 / 403
CONTROL record.status != ['closed', 'archived'] unenforceable unenforceable (unchanged) 0 rows, uncompilable predicate WARN 403 / 403
CONTROL record.reviewer_id == current_user.id 0 0 r_closed per value
CONTROL !(record.reviewer_id in current_user.org_user_ids) 0 0 r_open admitted
CONTROL (probe artefact) current_user.email == 'member@example.com' 0 0 every row for that caller admitted
CONTROL (probe artefact) current_user.email == 'someone@else.com' 0 0 0 rows, DENY (fail closed) 403

Reading of Zone 2's assumption 1:

  • It holds for the type family: the runtime refuses those predicates on every request while the lint reports 0.
  • It is falsified for the null family. The null shapes are not refused on the RLS data path: they compile, reach the driver, and are answered with SQL null semantics. Within one policy, the using read and the check evaluator disagree on !(record.status in ['open', null]). The shared faces refuse the same comparands wherever those faces run: the engine seam on every caller-supplied filter, and assertReadScopeComparandsRunnable in analytics (read from code, service-analytics/src/read-scope-sql.ts; not re-measured here). The data-path gap is in the Acceptance notes and the report, not addressed here.
  • f != current_user was already moved by fix(formula): refuse == / != against the bare current_user root at the CEL lowering #20189 and stays reported by the shape branch.

The discriminator (Zone 2, item 2)

Type family: a one-reference type swap must make the same compile lower (attributeTypeFault). When the reference pass's probe compile is refused unsupported, the rule rebinds ONE reference to a value of the other runtime type and compiles again. The candidates are each kernel key (scalar to list, or list to scalar) and the bare root (as a list that still carries the keys, then as one string). If that single swap makes the predicate lower, and the swapped value lands in a lowered field position, the refusal came from that reference's type. The type is not the probe's to choose: ExecutionContextSchema declares it for the kernel keys (read via kernelKeyProbe), and the root is always the whole context object. So the runtime refuses the same position on every request.

A refusal no single swap cures reports nothing. That is the probe-artefact class. A constant comparison with no field, such as current_user.email == 'ops@acme.com', folds on the value: it lowers to "no restriction" for the caller it names and is refused for everyone else. The swap value begins with the probe string and never equals it, so ordering folds and equality folds stay refused under every swap. The pinned control asserts compileCelToFilter really answers { ok: true, filter: {} } for the named caller and unsupported for another, then asserts the lint stays silent on both clauses, alone and inside || record.owner_id == current_user.id.

Null family: the verdict of the same two faces analytics runs (sharedFaceRefusal): assertListComparandShapes + normalizeFilterComparandTypes from @objectstack/spec/data, run on the probe-compiled filter and graded by the refusal's code (INVALID_FILTER), never its prose. It is not a probe artefact, because the probe binds only strings and string lists, which both faces accept by construction; a probe carrying a nested record is not judged. The face-probe control record.assigned_to_id in current_user.org_user_ids stays clean.

Conservative by construction, and recorded in the docblock: a predicate with two type faults (no single swap cures it) stays silent.

New author-facing text (quoted verbatim; KEY stands where the shipped string spells an angle-bracketed placeholder)

Type-family message: RLS CLAUSE `SOURCE` passes the shape check, but the compiler refuses it for the value `VARIABLE` holds on every request (COMPILER_DETAIL). HOLDING_SENTENCE + the existing clause consequence with a new preamble:

so RLSCompiler DROPS the policy on EVERY request (a request that resolves no value drops it too). 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, and nothing reports it at authoring time.

Holding sentences:

current_user.org_user_ids is a membership set: ExecutionContext declares it, and the kernel resolves it, as a LIST on every request, so no request can ever put one value in this position.

current_user.id holds ONE value: ExecutionContext declares it, and the kernel resolves it, as a scalar on every request, so no request can ever put a list in this position.

current_user alone is the whole caller context object on every request, never one value and never one set.

Hint leads, one per holding:

== / != compare ONE value and a string method takes ONE string; a membership set is tested with in — "one of these" is x in set, "none of these" is !(x in set).

in tests membership in a SET; one value is compared with == / !=.

current_user is the caller context, not a value: name the key that holds what you mean.

Each hint lead is followed by In this predicate: and the rewrites, for example:

  • replace `record.reviewer_id != current_user.org_user_ids` with `!(record.reviewer_id in current_user.org_user_ids)`
  • replace `record.reviewer_id == current_user.org_user_ids` with `record.reviewer_id in current_user.org_user_ids`
  • replace `record.reviewer_id in current_user.id` with `record.reviewer_id == current_user.id` (and `!(record.reviewer_id in current_user.id)` with `record.reviewer_id != current_user.id`); for a set, name a membership key: `record.reviewer_id in current_user.accessible_org_ids` / … `org_user_ids` / … `positions`
  • replace `record.reviewer_id in current_user` with the key that holds the set: `record.reviewer_id in current_user.accessible_org_ids` / … `org_user_ids` / … `positions`, or an app-staged §7.3.1 set `record.reviewer_id in current_user.KEY`
  • replace `record.reviewer_id.startsWith(current_user)` with the key that holds the string: `record.reviewer_id.startsWith(current_user.email)` / … `(current_user.id)` / … `(current_user.organization_id)`

The key lists are derived from the declared types (kernelKeysHolding) and are not transcribed.

Null-family message: RLS CLAUSE `SOURCE` lowers, but to a comparand the platform's shared filter check refuses (FACE_FIRST_SENTENCE). + this consequence:

The RLS layer does not pass its own filter through that check, so the policy is NOT dropped: the refused comparand reaches the backend, which answers it with semantics the platform has ruled undefined — backends disagree, and on the SQL drivers a null member or bound matches no row, so a negated list holding null or an ordering against null can admit NOTHING. One policy can even disagree with itself: a using read on a SQL driver hides rows that its check evaluator admits on write. Every analytics query over the object is refused outright, because its read-scope compiler runs the same check.

Null-family hint:

null has no place inside a list or opposite an ordering operator — the platform refuses both in every filter it is sent, because no two backends agree on what they match. Test for no value with == null and for a value with != null: replace record.status in ["open", null] with (record.status in ["open"] || record.status == null) to keep matching a missing value, or drop the member: record.status in ["open"].

Its other rewrites are replace `record.status in [null]` with `record.status == null` and replace `record.status > null` with `record.status != null` (has a value) or `record.status == null` (has none), or compare against a real bound. Every rewrite is a local replacement, so it stays correct under any enclosing !, && or ||. If the face refuses something the locator cannot place, the hint falls back to the existing lowerable-subset sentence.

The existing consequence(clause) text for uncompilable predicates is byte-identical; it only became parameterised.

Pins

  • Flipped: accepts every kernel-resolved key in BOTH positions asserted [] for owner_id == current_user.KEY and owner_id in current_user.KEY for every kernel key, which wrote the blind spot down as a pin. It is now never reports a kernel-resolved key as UNKNOWN — in its own position it is clean, in the other it is a type fault. It asserts, per key and by its runtime type (the table hoisted to KERNEL_KEY_RUNTIME_TYPE), [] in the key's own position and exactly [rls-predicate-unenforceable] in the other, and never rls-predicate-unknown-user-variable.
  • Pin sweep, repo-wide: outside packages/lint, no test asserts an rls-predicate-* id. The one hit is prose in packages/qa/dogfood/test/authz-conformance.matrix.ts. The shape-branch pins (literal list, bare root, size(), SQL AND, over-budget), the verdict-parity corpus and the disjointness test are unchanged and green. A genuinely illegal shape keeps its refusal verbatim.
  • New block the refusals the SHAPE check cannot see are reported (#19951), table-driven over both clauses:
    • 8 type-fault rows and 7 null rows; each asserts isSupportedRlsExpression(source) === true (why it was silent), then exactly [[rls-predicate-unenforceable, permissions[0].rowLevelSecurity[0].CLAUSE]], and that the hint contains the pasteable rewrite.
    • Message substance for each family: a DROP with RLS_DENY_FILTER on using and PermissionDeniedError on check, versus "NOT dropped".
    • Every site rewritten in one finding.
    • Controls: the literal list keeps one finding, and every spelling the hints point at is clean on both clauses.
    • The probe-artefact control.
    • Reach through runAuthoringRules('validate', …).

Verification (at 77060e6d, the merge of origin/main 0bd11261)

  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 --reporter=verbose src/validate-rls-predicate-enforceability.test.ts: Tests 120 passed (120).
  • pnpm --filter @objectstack/lint test: Test Files 108 passed (108), Tests 4204 passed (4204). This is the whole package, the rule's only home.
  • pnpm --filter @objectstack/lint typecheck: exit 0. tsc --noEmit, then check:test-typecheck OK against tsconfig.test.json. --listFiles includes the test file, and it carries 0 type errors; the 6 ledgered test-layer errors live in two other files.
  • pnpm --filter @objectstack/lint build: exit 0.
  • Ablations, one-time and never left in the tree. Each went through node scripts/ablation-replace.mjs in WRAP mode, with the anchor hit once (1 to 0), a new blob, and the restore proven (blob == HEAD 8589ec8c, git diff HEAD empty). The subject is imported from src, so no dist step applies.
    • A. attributeTypeFault(...) replaced by ?? FAKE_FAULT (every refusal reported). Result: 2 failed / 118. Red: the probe-artefact control, and §7.3.1 … a key used in BOTH positions takes the membership answer. That is the discriminator is load-bearing.
    • B. sharedFaceRefusal made to return null. Result: 8 failed / 112, the 7 null rows plus "NOT reported as a drop".
    • C. The swap may never land (sites.length > 0 becomes < 0). Result: 12 failed / 108: the 8 type rows, the flipped kernel-key pin, the message test, the every-site test, and the os validate reach.
  • Census (Zone 2, item 4): the BASE rule (git show 560b724c:…, blob cb850a12, as a throwaway module) and this PR's rule were run in one process over every using / check literal. Outside tests: 126 predicates, 77 of them current_user (the note's control of 77 reproduced), 0 changed. Including every test fixture: 327 predicates, 205 current_user, 0 changed. The 4 template-literal predicates the extraction skipped (${ownerField} == current_user.id and similar) and the one JSON fixture are all type-correct. No in-repo producer is newly flagged.
  • Interplay (Zone 2, item 5): the null-family check calls the shared face assertListComparandShapes (packages/spec/src/data/filter-comparand-shape.ts) as it stands on main. [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 stage 2b edits that file's $ne / array arm. The lint only ever hands it filters the compiler already accepted, and the compiler refuses == / != against a list, so a $ne array never reaches the face from here. Measured on main; not waited on.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived at 77060e6d (59 commands, a superset of the lead's 52 once the changeset exists), all run. Reconciled with --ran: 59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN; all 57 exited 0. NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt. Both exit 3, PREREQUISITE NOT MET: they need every package's dist/ (a whole-repo build), which is CI's.
  • Consumer suites (cli, mcp, metadata-protocol, platform-objects, cloud-connection): not run locally and declared to CI. The public surface is byte-identical: no export, no wire shape. The census above measures every in-repo predicate, fixtures included, at 0 flips, and no suite outside packages/lint asserts an rls-predicate-* id.
  • Dogfood: the showcase predicates (owner == current_user.email and similar) are inside the 0-flip census.

Choices settled here (four axes)

  1. Report the null family although the data path does not refuse it.
    • Business need: measured. !(record.status in ['open', null]) hides every closed row on read while its own check admits the write, and an AI writing a blocklist "neither closed nor missing" lands exactly here.
    • Long-term soundness: the contract (the null rulings) already refuses these comparands at every face that judges them. The lint agrees with the contract rather than with the one path that skips it.
    • Guarding against AI metadata mistakes: a loud authoring refusal, with a rewrite, instead of backend-dependent silence.
    • Startup scope: no new capability, no runtime change.
    • Not reporting it would leave "validated clean, behaves per backend" in place.
  2. Same id (rls-predicate-unenforceable), not a new one. Zone 1's direction names the id for the compile-refusal family, and the null family's fix is the same kind of edit (rewrite inside the enforceable subset). An allowlist keyed on the id already means "this predicate enforces nothing", and a new public id would widen @objectstack/lint's exported surface for no new decision. The message says which class it is.
  3. The discriminator as above: asked of the compiler, the way userVariableIsScalarPositioned already asks it, and never by grading prose.

Acceptance notes

  • Runtime gap (reported, not addressed here): the RLS data path does not run the shared comparand faces on its own compiled filter. A null list member or null ordering bound in a policy reaches the driver and the check evaluator, which disagree.
    • Measured through the real plugin on driver-sql: !(record.status in ['open', null]) as using reads 0 rows (the closed row is hidden). As check, it admits a closed insert and refuses an open one.
    • record.status in ['open', null] reads r_open only.
    • record.status > null reads 0 rows and refuses both inserts.
    • The contract's own text calls these cells "constructively unreachable through the compile face" (driver-memory/src/memory-matcher.ts), and they are reachable through RLS.
    • Handed to the PM seat for the filing gate; this PR changes no runtime source.
  • packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts says "The authoring lint reports a list literal as rls-predicate-unenforceable; a membership set holds its value only per request, so that form is refused at request time." That is still true, but no longer complete: the lint now reports the membership-set form too. Carrier: none.
  • An ordering against a membership set (record.f > current_user.positions) lowers to $gt over a list. Neither the compiler nor the faces refuse it, and its runtime answer was not exercised here. Carrier: none.
  • Conservative limits, by design: a predicate with two type faults stays silent. So does an unknown app key in a scalar position when a later type fault masks it; that is userVariableIsScalarPositioned's existing behaviour.

Generated by Claude Code

@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

This PR changes 1 package(s): @objectstack/lint, touching 24 documentable anchor(s).

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

  • content/docs/api/data-api.mdx (via INVALID_FILTER (literal, a string literal in sharedFaceRefusal))
  • content/docs/api/error-catalog.mdx (via INVALID_FILTER (literal, a string literal in sharedFaceRefusal))
  • content/docs/data-modeling/formulas.mdx (via endsWith (literal, a string literal in STRING_METHOD_OF), startsWith (literal, a string literal in STRING_METHOD_OF))
  • content/docs/data-modeling/validation.mdx (via endsWith (literal, a string literal in STRING_METHOD_OF), startsWith (literal, a string literal in STRING_METHOD_OF))
  • content/docs/protocol/objectql/query-syntax.mdx (via INVALID_FILTER (literal, a string literal in sharedFaceRefusal))
  • content/docs/protocol/objectql/schema.mdx (via endsWith (literal, a string literal in STRING_METHOD_OF), startsWith (literal, a string literal in STRING_METHOD_OF))
  • content/docs/protocol/objectql/types.mdx (via startsWith (literal, a string literal in STRING_METHOD_OF))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via INVALID_FILTER (literal, a string literal in sharedFaceRefusal))
  • content/docs/releases/v17/17-4.mdx (via INVALID_FILTER (literal, a string literal in sharedFaceRefusal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: current_user (literal, 31 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 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; 98 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 — 4 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 1c8b320a894b31dcfc76c33c8e7a62b8986d893a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1c8b320a894b31dcfc76c33c8e7a62b8986d893a

⚠️ 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 1c8b320a894b31dcfc76c33c8e7a62b8986d893a → 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: CONTRACT_REVIEW_TIER
Head-sha: 77060e6d067b0b114ee17b07079f7fe388592900

① Derived judgments

  • Scope, checked against the tree. Delta vs merge-base 0bd11261 is exactly 3 files: the rule (packages/lint/src/validate-rls-predicate-enforceability.ts), its test, one @objectstack/lint changeset. git diff origin/main..77060e6d -- packages/formula/src/cel-to-filter.ts packages/spec/src/data/filter-comparand-shape.ts packages/plugins/plugin-security/src/rls-compiler.ts is empty: no runtime, compiler or shared-face source moves. @objectstack/spec/data is already a lint dependency (4 other lint files import it) and exports both faces (data/index.ts:15,23). CI at the head: 32 success / 6 skipped / 0 failing.
  • Type family (class 1): RIGHT. Measured in a detached worktree at the head (pnpm --filter @objectstack/lint exec vitest run on the PR test file plus a review probe: 2 files, 126 passed, the PR's own 120 included). 16 spellings — !=/==/!(==) against org_user_ids/positions/accessible_org_ids, in against id/email/organization_id and its negation, in current_user, startsWith/contains/endsWith(current_user), a string method on a membership key, and the SQL-bridge spellings reviewer_id != current_user.org_user_ids, reviewer_id IN current_user.id, reviewer_id IN (current_user.id), reviewer_id = current_user.positions — each yields exactly one finding per clause: rls-predicate-unenforceable at permissions[0].rowLevelSecurity[0].using / .check, using/check parity holds, and every hint carries a pasteable CEL rewrite with the predicate's own field and key (e.g. !(record.reviewer_id in current_user.org_user_ids), record.reviewer_id == current_user.id, the derived key menus for the bare root). Ground truth: compileCelToFilter with runtime-typed bindings refuses each (\in` requires an array/list on the right, `!=` compares one value, but its comparand … resolves to a list, "startsWith()" argument must be a string literal); ExecutionContextSchema fixes the types (userId/tenantId/emailstrings,positions/org_user_ids/accessible_org_idsstring arrays) andRLSCompiler.compileFilter (rls-compiler.ts:445-466) binds exactly those keys, merging rlsMembershiparrays only, so the refusal recurs on every request. The newDROPPED_EVERY_REQUESTtext matches the runtime:isSupportedRlsExpression is true so the "uncompilable predicate" WARN branch (:556) is skipped, the policy joins deniedBy, and warnFailClosedDenial (:613-640) fires only when filters.length === 0. The existing consequence()` default text is byte-identical.
  • Null family (class 2): RIGHT, with the truthful qualifier the message carries. 7 shapes (in ['open', null], in [null], !(in ['open', null]), > null, >= null, < null, <= null) plus in ['open', null] || owner_id == current_user.id each give exactly one finding per clause under the same id; the message says the policy is NOT dropped, which is the measured runtime (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): my compiler probe confirms in ['a', null] lowers to $in: ['a', null] and > null to $gt: null, so the RLS data path executes them with SQL null semantics. The judge is the ruled contract, not a guess: assertListComparandShapes (null-member arm, null-ordering arm) and normalizeFilterComparandTypes, graded on code === 'INVALID_FILTER'; service-analytics/src/read-scope-sql.ts:7,687,890 runs the identical pair on every RLS read scope, so every analytics query over the object is refused. Is error at os validate truthful for a policy that "works"? Yes: in ['open', null] on SQL silently behaves as in ['open'] (not as written), !(… null) hides every row while its own check admits the write, in [null] reads nothing on SQL and the null rows on memory/mongo. No policy that behaves as written on every backend is refused, and the card's direction (5823840908) asks for exactly this refusal. The rewrites are local and correct under an enclosing !: !(status in ["open"] || status == null) is "neither open nor missing".
  • False-positive hunt: none found. 50 sources × 2 clauses, head identical to base and all clean: record.owner_id == current_user.id (CEL, bare, SQL =, and !=); in against every membership key and !(in …); current_user.email == 'lit' and != 'lit' alone, in || and beside a membership test; == null / != null / id == null; literal lists of strings, numbers, booleans, the empty [] and its negation; ordering against numbers, strings, and scalar keys (> current_user.id); string methods with literals and scalar keys; nested && / || / !; §7.3.1 app keys under in (team_member_ids, territory_account_ids); 1 == 1; true; field-to-field. record.f == current_user.nope keeps rls-predicate-unknown-user-variable; record.f.startsWith(current_user.team_member_ids) keeps it too (app keys are arrays). The only 2 "hits" in my table are field = null → rls-predicate-unknown-field on base AND head — my synthetic stack does not declare a field named field.
  • Discriminator soundness: sound in the false-positive direction. A predicate the runtime accepts compiles under the runtime-typed probe, so refusal is null and attributeTypeFault never runs. The one accepted class that IS refused under the probe — a value-dependent constant fold — needs two things no single swap gives it: the swapped compile must succeed AND the marker must land in a lowered field position. SWAP_SCALAR keeps the folds refused (probed: current_user.id > '__objectstack_lint_probe__' and current_user.email != '__objectstack_lint_probe__' both silent). Ablation A in the PR (2 red incl. the probe-artefact control) is consistent with what I read. The cost is in the other direction, all measured and all fail-closed at runtime: (a) two type faults in one predicate (record.a in current_user.id && record.b in current_user.email) → silent; (b) a type fault beside a value-dependent fold → silent; (c) ORDER dependence: record.f in current_user.id || record.g in current_user.team_ids → silent, while record.g in current_user.team_ids || record.f in current_user.id → reported (an app key discovered after the fault never gets bound, so no swap compiles); (d) record.f == current_user.nope && record.g in current_user.id → silent on base and head (the later fault masks the unknown-variable finding — pre-existing). (a), (b), (d) are in the docblock / Acceptance notes; (c) is not written down.
  • Census: reproduced at 0. My own extraction at the head (git grep for quoted using: / check: literals over the whole tree; 303 predicates, 124 outside tests; examples/app-showcase/src/security/permission-sets.ts and the packages/qa/dogfood fixtures are in it; no unquoted YAML/JSON policy exists in the tree) run through the merge-base rule copy (blob cb850a12 == 0bd11261's) and the head rule in one process: 0 changed, 0 newly flagged. A direct grep for every flagged spelling ((!=|==) current_user.SET, in current_user.SCALAR, in current_user bare, string methods on the root or a set, null inside a list, ordering against null) finds 0 producers outside the changeset and the rule.
  • Reach and plumbing. runAuthoringRules('validate', …) returns the finding at error; the rule is tier: 'gating', commands: ALL (authoring-rules.ts:1831-1843), and validate.ts:377-414 exits 1 on any error — so os validate / build / lint now refuse these. Multi-policy paths and where are correct (permissions[1].rowLevelSecurity[2].using, permission set "b" policy "q3" on object "crm_opportunity"). A policy on an object the stack does not declare still gets the object-independent type/null findings. A disabled policy is still judged (the rule's documented design).

② Semver level

  • Declared: @objectstack/lint minor, no BREAKING banner, PR body Clause-②: no (bare), no Clause-② line in the changeset body.
  • Gates, from the scripts at origin/main (not rerun): check-changeset-no-major.mjs — minor is not major; the level axis reads a bare no as not-declared and stands down. check-adr-0087-registration.mjs — breakingDeclaration() signals are a major bump, **BREAKING / BREAKING CHANGE, a !: summary, or a Clause-② arm reading narrowing; this changeset carries none, so it is not judged. Both satisfied mechanically.
  • minor is right. The exact precedent is on main, in this rule's own file: .changeset/rls-predicate-references.md (lint: an RLS predicate naming a non-existent field, or an un-pre-resolved current_user.* variable, is reported by nothing — both fail CLOSED at runtime #16119) added two new error ids and states that a stack "built clean before and now fails os validate" — graded minor, no banner, bare declaration. The 2026-09-04 WHICH LEVEL ruling sets minor as the floor for a widening and never forbids it for a behaviour change; patch would be the under-grade for new gating findings.
  • Banner: acceptable, noted. The repo also carries the opposite reading (validate-refuses-blank-structural-condition.md, lint CHANGELOG 8640fb2: "BREAKING accept-set change on the os validate gating tier", banner + adr-0087 marker). For the type family nothing narrows: the compiler already refuses every one of these, and the membership-set form is registered as cel-predicate-list-comparand-refused under protocol major 18 with its FROM→TO; in against a scalar or the root, and a string method on a non-string, were never accepted. For the null family the os validate refusal is new for a shape the RLS data path executes, and the changeset says so in bold with a FROM→TO per shape and the 0-flip census — the information a banner exists to carry. Under the lint: an RLS predicate naming a non-existent field, or an un-pre-resolved current_user.* variable, is reported by nothing — both fail CLOSED at runtime #16119 precedent this is not blocking. The seat's ACCEPT prose "it narrows" beside the bare Clause-②: no is a wording tension only: the arm's single reader (scripts/pm/clause2-line.mjs) gives a bare no the non-breaking reading, and no gate reads the prose.

③ Boundary flags

  • 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 (filed, open, bare): the RLS data path skips the shared comparand faces, so a null member / null bound reaches driver-sql and read vs check disagree. Consequence for this PR: on the null family the lint and the runtime data path now disagree BY DESIGN (lint refuses, runtime executes per backend); the lint agrees with the contract faces, the engine seam and analytics. The changeset's "refuses both in every filter it is sent" is true of the faces, not of the RLS path — the finding's message states the exception correctly.
  • 18.cel-predicate-list-comparand-refused.ts (packages/spec/src/migrations/entries/semantic/): "The authoring lint reports a list literal as rls-predicate-unenforceable; a membership set holds its value only per request, so that form is refused at request time" is now incomplete (the lint reports the membership-set form too). No pin test guards that sentence (grep). Carrier: none, as the dev recorded.
  • Ordering against a set or the root — unexercised, partly mis-messaged. record.f > current_user.positions lowers to { f: { $gt: ['…'] } }; the shape door checks ordering slots for null only and the type door keeps arrays, so the lint is silent and the runtime answer is unmeasured (dev's flag confirmed). record.f > current_user (root) lowers to $gt: the whole context object and IS reported through the face family — but firstSentence(faced) quotes the lint's probe into the author-facing message (… is a plain object ({"id":"__objectstack_lint_probe__","org…), which no driver can compare) and the null-specific FACE_REFUSED_CONSEQUENCE does not describe this shape; the hint falls back to the generic subset sentence. 0 producers in the tree. Non-blocking; a follow-up should redact the quoted comparand on a non-null face refusal.
  • Id documentation drift in the same file: RLS_PREDICATE_UNENFORCEABLE's JSDoc ("the policy enforces nothing", line 184) and the "three ids, and why not one" paragraph (lines 83–98) predate the null family, whose own message says the policy is NOT dropped. The lint: an RLS predicate naming a non-existent field, or an un-pre-resolved current_user.* variable, is reported by nothing — both fail CLOSED at runtime #16119 block was updated for lint: validate-rls-predicate-enforceability is silent on == / != against a kernel membership key (current_user.org_user_ids …), a policy the runtime drops on every request once PR #19947 lands #19951; these two were not. Non-blocking.
  • Conservative misses (real per-request refusals that escape): two type faults in one predicate; a fault beside a value-dependent fold; a fault after an undiscovered app key (order-dependent, not documented); an unknown key masked by a later fault (pre-existing). All fail closed at runtime; none is a false positive.
  • Hint spelling: for a bare-identifier or SQL-bridge source the "replace record.reviewer_id in current_user.id" clause names the normalised CEL spelling rather than the author's literal text; the "with …" half is valid CEL the bridge accepts unchanged. Non-blocking.
  • Nothing else: no export, wire shape or rule id is added; no test outside packages/lint asserts an rls-predicate-* id (grep: prose only in packages/qa/dogfood/test/authz-conformance.matrix.ts).

Implemented-by: claude/issue-19951-rls-lint-membership-key-silent
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 09:47
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit af32cf9 Sep 27, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19951-rls-lint-membership-key-silent branch September 27, 2026 10:08
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