Skip to content

fix(security)!: an RLS predicate naming an undeclared column denies in every position - #17115

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-17042-rls-negated-phantom-column-fail-closed
Sep 9, 2026
Merged

fix(security)!: an RLS predicate naming an undeclared column denies in every position#17115
huangyiirene merged 3 commits into
mainfrom
claude/issue-17042-rls-negated-phantom-column-fail-closed

Conversation

@os-trump

@os-trump os-trump commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17042

⚠️ Clause-②: yes — the refusal set on published verbs changes, so this PR carries needs:contract-review and is expected to go green and then park awaiting a CONTRACT_REVIEW_TIER verdict. The body below is written so a reviewer does not have to re-derive anything.


The defect

An RLS predicate naming a column the object does not declare, in a negation-carrying position, did not narrow and did not deny — it widened the policy to every row inside the tenant wall (read face) and permitted the write the policy was authored to refuse (write face).

This is NOT a cross-tenant leak. Tenancy is a separate layer and it holds. What is defeated is the narrowing the policy author wrote inside the wall — an owner-only or private-record policy silently becoming "every row".

Two independent code sites, both on origin/main:

  • Read face. extractTargetField (security-plugin.ts) is a leading-only == / = / in shape match, so nope != "x", !(nope == 1), !(nope in ['a']) and any arm after the first returned null; if (!targetField) return true kept the policy, dropped++ never fired, and the deny sentinel (layer1 == null && dropped > 0) never armed.
  • Write face. computeWriteCheckFilter compiled check clauses with no field-existence net at all, and security-plugin.ts:109/:2896 evaluate that filter against the post-image (ADR-0058 D4 step 3.6).

Deliverable 1 — the previously unmeasured middle column, now closed

Every cell below was run on this branch's parent (91f65c4ea, unfixed) with the same two controls the card used: a real column must still narrow, and the same phantom column in a positive position must still refuse. ⛔ A refusal with no discriminating control is not a reading — the first version of this probe "refused" every cell because the driver failed to boot, and the controls are what caught it.

Read face

predicate matcher semantics (driver-memory / matchesFilterCondition) end-to-end, driver-sql end-to-end, driver-sqlite-wasm
nope != "x" {"nope":{"$ne":"x"}}3 / 3 INVALID_FILTER / 400 INVALID_FILTER / 400
!(nope == 1) {"$not":{"nope":1}}3 / 3 INVALID_FILTER / 400 INVALID_FILTER / 400
!(nope in ["a"]) {"$not":{"nope":{"$in":["a"]}}}3 / 3 INVALID_FILTER / 400 INVALID_FILTER / 400
is_private == false || nope != "x" {"$or":[…]}3 / 3 INVALID_FILTER / 400 INVALID_FILTER / 400
control is_private == false 1 / 3 1 / 3 1 / 3
control nope == false (positive phantom) 0 / 3 0 / 3 0 / 3

Write face — ⭐ SOURCE-ESTABLISHED before, now MEASURED

Single-row insert through a real ObjectQL + real SecurityPlugin, policy using: 'is_private == false' with the shape under test as check. ⚠️ The payload must be a single object, never an array — the 3.6 gate is guarded by !Array.isArray(opCtx.data), so a bulk payload skips it entirely and every cell reads "permitted" for the wrong reason.

check predicate post-image is_private=false post-image is_private=true
nope != "x" PERMITTED PERMITTED
!(nope == 1) PERMITTED PERMITTED
!(nope in ["a"]) PERMITTED PERMITTED
is_private == false || nope != "x" PERMITTED PERMITTED
control is_private == false PERMITTED REFUSED PERMISSION_DENIED / 403
control nope == false (positive phantom) REFUSED PERMISSION_DENIED / 403 REFUSED PERMISSION_DENIED / 403

Identical on both drivers, which is the expected shape: the check is evaluated in-process against the post-image, so the write face is driver-independent. The real-column control discriminating in both directions is what makes the four PERMITTED rows a reading.

driver-sql — ⭐ now MEASURED, and it refines the card

The card recorded driver-sql as NOT MEASURED, "expected to fail closed by raising no such column (sql-driver.ts:703)". Measured:

  • Read face: driver-sql does NOT widen. It fails closed by raising, and the mechanism is not a raw no such column — it is driver-sql's own INVALID_FILTER / 400 envelope (unresolvableFilterColumnError), whose wording is the ingress door's verbatim. driver-sqlite-wasm answers identically.
  • Write face: driver-sql fails OPEN, exactly like every other driver, because the check never reaches SQL.

⇒ The read face is driver-dependent (memory/mongodb widen; SQL raises). The write face is not: it is fail-open everywhere. That makes the write face both the worse one and the one no driver choice mitigates.

Deliverable 2 — both faces RED, then green

Prediction was written first (negated shapes widen/permit; real column narrows; positive phantom refuses) and matched the readings above. The same probe, unchanged, on the fixed tree:

read, driver-sql read, sqlite-wasm write, both drivers
all four negated-phantom shapes 0 / 3 0 / 3 REFUSED PERMISSION_DENIED / 403
control — real column still narrows 1 / 3 1 / 3 PERMITTED on the satisfying post-image, REFUSED on the violating one
control — positive phantom 0 / 3 0 / 3 REFUSED

⭐ The real-column control is the one that catches an over-fix: a change that made every policy deny would pass a naive red→green and break every install. It still narrows to 1 of 3, and it still admits the write it should.

The seam I chose, and the alternative I rejected

Chosen: a positional-agnostic field-existence check inside RLSCompiler.compileFilter, judged on the COMPILED FilterCondition tree.

compileFilter has exactly two production callers — the read layer (using) and the ADR-0058 D4 write gate (check) — so one seam closes both faces and they can never again disagree about what an undeclared column means. A policy naming an undeclared column joins the existing deniedBy collection under a new unknown-field reason, so it reuses the fail-closed RLS_DENY_FILTER sentinel and the existing warnFailClosedDenial observability rather than growing a second parallel mechanism.

Rejected: widening the extractTargetField regex. ⚠️ A shape match that must enumerate every spelling of negation is the same "recognises only what it was told about" defect one level over — the next spelling is the next hole. The compiled tree has no spellings left: cel-to-filter.ts lowers ! to $not, || to $or and && to $and, and every column lands as a plain object key whatever position it was authored in. The guard therefore stays correct when the pushdown compiler learns a new source form, because a new form still has to lower into this same shape. Widening the regex would also have broken the ADR-0095 delta c carve-out, which depends on extractTargetField recognising only the leading shape.

noValueSatisfiesNegation is untouched. memory-matcher.ts's $ne / $nin / $notContains ruling (#13166, shared with driver-mongodb) is correct for an ordinary user query, and re-semanticing every filter in the repo to fix one caller is the blast radius this card must not take. The defect was that the policy compiler lowered an undeclared column into a filter at all; after this change the matcher never sees a phantom. The unguarded matcher reading in the table above is identical before and after, which is the evidence.

Where the walker deliberately differs from the ingress collectors

Two collectors already answer "which columns does this filter name" — collectFilterFieldKeys (metadata-protocol) and collectFilterFieldNames (objectql). This one keeps their combinator rule verbatim and inverts their treatment of an unrecognised $ key: they skip it without descending (right for a gate that must not invent 400s on caller input), this one refuses (null ⇒ deny). The input here is not caller input — it is this compiler's own output, which emits $and / $or / $not and nothing else at node level, so an unmodelled combinator means the tree grew a shape the guard has not been taught, and leaving the columns beneath it unexamined is precisely the fail-open being closed. The depth backstop refuses on overrun for the same reason.

Scope

  • ⛔ Nothing relaxes. The ADR-0095 delta c carve-out (tenancyDisabled && targetField === 'organization_id' → skip, not deny) is deliberately not replicated into the compiler pass: replicating it would turn a case that denies today into one that applies no restriction at all, which is a relaxation. It stays at the call site, on the leading shape it was written for, unchanged.
  • A schema that cannot be loaded (getObjectFieldNamesnull) passes no guard and behaves exactly as before. A boot-time schema miss must not manufacture denials.
  • In scope alongside (director's F2): PR feat(lint): rls-predicate-unknown-field / rls-predicate-unknown-user-variable — the reference half of the RLS predicate gate #17036's landed linter prose is corrected to match what is measured here. ⛔ The linter's detection is unchanged — all four shapes are still reported.

Still NOT MEASURED


Verification — all figures from 05f5c96df (the final commit, origin/main merged in)

Ablation — the suite can actually fail. Both fix files reverted to the pre-fix base under a shell trap … EXIT INT TERM; mutation proved on disk before the run (judgeCompiledFields count 0 in rls-compiler.ts, 17042 count 0 in security-plugin.ts, and the blob hash differing from HEAD's), never by an exit code.

  • ablated: 36 of 50 cells FAIL
  • restored: blob equality against the HEAD blob YES for both files, and git diff HEAD empty

⭐ The 14 cells that held on the ablated tree are exactly the controls — the real column narrowing, the positive phantom, the untouched matcher ruling, and the no-guard identity. Controls that flip are not controls.

Reverse type verification — proving the new guard type is compiled, not cached: a probe passing { declared: ['a','b'] } (an array) where ReadonlySet<string> is required turns tsc red with TS2739: Type 'string[]' is missing the following properties from type 'ReadonlySet<string>': has, size. Probe removed, git status clean.

Suites

result
@objectstack/plugin-security pnpm test 2018 / 2018 pass (106 files)
@objectstack/lint pnpm test 3666 / 3666 pass (103 files)
both packages pnpm typecheck pass; plugin-security's test layer at 0 files / 0 errors / 0 pinned signatures of debt
downstream sweep pnpm --filter '...@objectstack/plugin-security' typecheck 28 consumer packages, 0 errors

⚠️ The downstream sweep first reported packages/rest red with TS2307: Cannot find module '@objectstack/service-package'. That was an unbuilt sibling, not this change: service-package had no dist/ in this worktree and is not in the diff. Re-run on a fully built closure it is clean. Recorded because an unbuilt dependency reads exactly like a broken import.

Gatesnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, derived from the merged head (no stale-tree warning), then reconciled with --ran:

Run reconciliation — 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN.

Two returned exit 3 — PREREQUISITE NOT MET, which is NOT MEASURED and neither a pass nor a finding — and both were then measured rather than reported away:

  • check:dual-build-cjs-loads — four packages outside this diff's build closure had no dist/. Built them; re-run green (104 require entry points across 67 packages).
  • check:type-check-debt — a V8 OOM at a 4 GB heap under shared-box contention. Re-run at 8 GB; green, 5 ledger entries re-measured, none above its recorded number.

Lint — the whole-repo scan was run, not narrowed: eslint . --no-inline-config --format json over the population eslint itself resolved — 6420 files — 0 errors, 0 warnings. (The narrowing argument is therefore moot, but for the record the config states its own invariance verbatim at eslint.config.mjs:328: "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not.") The five linted files of this diff: 0 errors, 0 warnings.

Control charactersgrep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over every file in the diff: no hits.

In-flight work on security-plugin.ts — all 18 open PRs were enumerated and their file lists scanned for plugin-security, rls-compiler, memory-matcher and validate-rls-predicate: no open PR touches any of them. #16861 is in the same package but in bootstrap-platform-admin.ts, a different file, and is not among the open PRs' changed files either. security-plugin.ts carries no SINGLE_CLAIM_PATHS fence.

Changesetminor on @objectstack/plugin-security, patch on @objectstack/lint, with a **BREAKING** banner. ⚠️ minor-with-a-breaking-banner is not a hedge, it is this repo's own settled convention for a security narrowing during the launch window — the same grading .changeset/insert-check-post-image.md used for the insert-side check narrowing and .changeset/memory-driver-tenant-scope-refusal.md for the driver-memory refusal, and check:changeset-no-major refuses major outright. check-adr-0087-registration recognises the disposition: [BREAKING+bang] not-required (no-migration-prescription).

🤖 Generated with Claude Code

https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37


Generated by Claude Code

…n every position

A predicate naming a column the object does not declare could not narrow, and
in a negation-carrying position it did not deny either -- it WIDENED the policy
to every row inside the tenant wall (read face) and PERMITTED the write the
policy was authored to refuse (write face).

Read face: `extractTargetField` is a LEADING `==`/`=`/`in` shape match, so
`nope != "x"`, `!(nope == 1)`, `!(nope in [...])` and any arm after the first
returned `null`, `if (!targetField) return true` KEPT the policy, `dropped`
never incremented and the deny sentinel never armed. Write face:
`computeWriteCheckFilter` compiled `check` clauses with no field-existence net
at all.

The repair is one seam, not two: `RLSCompiler.compileFilter` -- which both the
read layer and the ADR-0058 D4 write gate already pass through -- now takes the
object's declared-column set and judges every column the policy names on the
COMPILED FilterCondition tree. That is positional-agnostic by construction: the
pushdown compiler lowers `!` to `$not`, `||` to `$or` and `&&` to `$and`, so a
column lands as a plain object key whatever position it was authored in, and
there is no spelling of negation left for a shape match to miss.

The matcher's include-direction ruling (`noValueSatisfiesNegation`,
driver-memory / driver-mongodb) is deliberately UNTOUCHED -- it is correct for
an ordinary user query. The defect was that the policy compiler lowered an
undeclared column into a filter at all; the matcher now never sees a phantom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/plugin-security, touching 13 documentable anchor(s).

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

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/permissions-matrix.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • content/docs/permissions/sharing-rules.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via other_column (literal, a string literal on a changed line))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))

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 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

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 50b6f17d4140bcb3d161137a58aeaf7f49cc22bapackageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 50b6f17d4140bcb3d161137a58aeaf7f49cc22ba

⚠️ 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 50b6f17d4140bcb3d161137a58aeaf7f49cc22ba → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… and correct the linter's consequence prose

The regression suite runs the four negation shapes through the compiler seam
and end-to-end through a real ObjectQL + SecurityPlugin on both SQL drivers,
on the read face and the write face, each against the two controls that make a
reading a reading: a real column must still narrow, and the same phantom column
in a positive position must still refuse. Ablated against the pre-fix source:
36 of 50 cells fail, and the 14 that hold are exactly the controls.

It also pins the include-direction ruling as UNCHANGED -- the raw matcher still
admits 3 of 3 rows for the same filter -- so a later reader can see that what
moved is that the policy compiler stopped producing the filter, not what the
matcher does with one.

The linter's detection is untouched. Its consequence text was stale in one half
and misattributed in the other: it described the field miss as having two
directions decided by position, and it credited the write leg's fail-closed to
a safety net `computeWriteCheckFilter` never had. It now states one direction
for both clauses and records the older runtime's fail-open write behaviour
explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: PASS WITH FINDINGS (audit reading; director seat, summon #18 segment 4, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T10:4xZ)

PR #17115 · head ef4511306d4f1afe2de715e684e82527b8e97dd5 (re-read at posting 10:44:43Z; unchanged since 10:25Z) · reviewed 10:36Z–10:43Z.

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (63 harness model stamps, all claude-fable-5-1, zero residue; positive control 56 assistant / 41 user role tokens), adopted verbatim below. Fed only the card [finding] an RLS predicate naming an unknown column in a NEGATION position widens the policy to every row in the tenant instead of denying — the field-existence safety net is -only #17042 and its comments, this PR, the CI check-runs, and the checked-out tree.
  • Implemented-by: the domain:services seat's dev session_012zTkyNHJ7TkuN2oXtP5x37 (matches the newest Claim: on the card), branch claude/issue-17042-rls-negated-phantom-column-fail-closed. Distinct sessions ⇒ not a self-review.
  • Reading for the seat: p1 security fix, fail-closed verified position by position on both faces; nothing blocking. F1 (two more compiler-face pins) is the one worth a rider before landing; F2 is a follow-up card candidate (schema-lookup miss still keeps all policies), not this PR's. ⛔ This seat cleared no carrier and touched no PR state; the domain:services seat owns the release (adoption record → carriers off with provenance → ready → enqueue).

Verdict: PASS WITH FINDINGS (no blocking finding)

Head reviewed: ef4511306d4f1afe2de715e684e82527b8e97dd5 (unchanged on re-poll at review end; PR updated_at still 10:25:36Z; 2 commits over merge-base 91f65c4ea)

Clause-② reading: yes — the refusal set of the published RLSCompiler.compileFilter (@objectstack/plugin-security, private:false, barrel-exported at src/index.ts:12) changes: a compiled predicate naming an undeclared column now yields RLS_DENY_FILTER in every position/polarity on both using and check (rls-compiler.ts:473-478); the published signature gains an optional 4th parameter fieldGuard?: RlsFieldGuard (:418-423); the WARN line's reason vocabulary gains 'unknown-field' (:25). Claim matches: yes, and the body derives it correctly. check-clause2-carriers --pair 17115 → exit 0 (both carriers agree).

Governed surface / protocol label / breaking marker:

  • Governed: none. Register (GOVERNED_SURFACES) = docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md; the 6 changed files are .changeset/*, packages/lint/src/*, packages/plugins/plugin-security/src/*.
  • protocol:*: none owed. .github/labeler.yml:13-34 maps protocol:data|ui|system|ai only to packages/spec/src/** (untouched). protocol:breaking exists as a repo label, but no in-tree mechanism assigns it (labeler, workflows, pm-dispatch skill, AGENTS.md all silent) and this is an enforcement change, not a spec surface.
  • ! requires (repo convention): breaking ships as minormajor is refused by scripts/check-changeset-no-major.mjs (launch-window guard); the changeset must carry a breaking declaration (!/**BREAKING**) and exactly one <!-- adr-0087: … --> disposition (scripts/check-adr-0087-registration.mjs); a FROM→TO migration only when an authorable key/export is removed or renamed (AGENTS.md:1038-1049). Met: @objectstack/plugin-security: minor, @objectstack/lint: patch, fix(plugin-security)!: summary + **BREAKING** + not-required (no-migration-prescription); both gates run locally on the ref → exit 0 each; grading is identical to the precedent .changeset/insert-check-post-image.md. The "Who is affected" paragraph states which previously-accepted predicates now deny (any predicate naming an undeclared column, read: zero rows; write: refused) and the remedy (fix the column name; linter reports it).

CI on head: 33 check-runs — 30 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke: opt-in/conditional), 0 red, 0 in progress. Lint & Repo Gates success 10:40:29Z, TypeScript Type Check success 10:36:57Z, Test Core 1-6/6 all success, Check Changeset success.

Security correctness (verified on the diff):

  • Deny in every position: the guard walks the compiled tree (rls-compiler.ts:137-165): $and/$or arms and $not bodies recursed; any non-$ key collected; any other node-level $ key, an array/non-object node, or depth >32 → null → refuse. cel-to-filter.ts:296-302,326-336 emits only $and/$or/$not at node level and {} for always-true, so nothing lowers to an unmodelled shape. Right-hand { $field } references collected (:168-183). Both enforcement callers pass the guard (security-plugin.ts:6058-6063 read, :6310-6316 write); git grep finds no third compileFilter/compileExpression caller outside the compiler.
  • The deny is a deny: the policy joins deniedBy with reason unknown-field; sole policy → RLS_DENY_FILTER + WARN (:516-524); with a granting sibling it is dropped from the $or, never OR'd as allow-all (pinned at test :178-190).
  • noValueSatisfiesNegation untouched (memory-matcher not in the changed set); matcher 3/3 pinned as unchanged (test :112-131). ADR-0095 carve-out (pass 1) unchanged (security-plugin.ts:6046-6056).
  • Lint face: diff confined to referenceConsequence prose + docblock; detection code untouched; it.each still pins 5 shapes with a declared-column negative control (test :845-862).
  • Tests: compiler face pins 4 card shapes × both clauses + positive phantom + real-column control (:139-160); e2e on driver-sql and driver-sqlite-wasm, read (0/3, control 1/3) and write (PERMISSION_DENIED/403 + nothing stored, both post-image polarities, real-column control admits and refuses) (:283-375).

Findings

  • F1 — non-blockingpackages/plugins/plugin-security/src/rls-phantom-column-negation.test.ts:121-126. The compiler-face PHANTOM_NEGATIONS covers the four card shapes only; the lint face additionally pins a trailing && arm, and nothing on the compiler face pins a nested negation (!(a || !(nope == 1))) or a field-on-the-right membership. Denial there holds by construction of the walker, but the pin set is what stops a future walker edit regressing it. Fix: add is_private == false && nope != "x" and one nested $not-under-$or case to the array.
  • F2 — non-blocking, residual by designsecurity-plugin.ts:6058-6063, :6310-6316; rls-compiler.ts:473. When getObjectFieldNames answers null (schema unloadable), no guard is passed and a negated phantom still widens. This is the pre-existing pass-1 contract ("schema-lookup failure keeps all policies"), stated openly in the PR body and changeset, bounded to boot/unregistered objects (fieldNamesCache caches positives only, :7640-7647). Out of card scope; if the maintainer wants a schema miss to fail closed, that is a follow-up card, not this PR.
  • F3 — non-blockingpackages/lint/src/validate-rls-predicate-enforceability.ts:543-546: "all DROP the policy at request time, with one WARN line as the only signal". warnFailClosedDenial fires only when the clause actually denies (rls-compiler.ts:516-524); when a sibling policy grants, the drop is silent. The unchanged variable half (:524-527) carries the same wording, so the PR mirrors a pre-existing overstatement rather than introducing one. Fix (both halves): "one WARN line when the clause denies; no line when a sibling grants."
  • F4 — non-blockingrls-compiler.ts:84 exports RlsFieldGuard, but src/index.ts:12 re-exports only RLSCompiler, RLS_DENY_FILTER while the type now appears in the published compileFilter signature. Structural typing makes it callable; a barrel export would be the consistent shape. Optional.
  • F5 — non-blocking, docs — no content/docs/** edit, and none is strictly required: the accepted grammar (content/docs/permissions/rls.mdx:95-104) did not change; the fail-closed contract item 3 (rls.mdx:176, "references a column the object doesn't have → deny") was false for negated positions and is now true on both faces. Grep of content/docs (excluding releases/) for phantom/undeclared/unknown-column/fail-open finds no statement contradicting the new behaviour. Optional: one clause at rls.mdx:176 saying "in every position, for check as well as using".

Acceptance notes

  • Scope = the card exactly: runtime fix on both faces + the director's in-scope F2 (linter consequence prose). No widening, no narrowing, no unrelated files, no content/docs/releases/ edit. Fixes #17042 is correct (card fully executed; no closing keyword adjacent to any other open card number in the body).
  • The card's "STOP if major/protocol:breaking" fence was not triggered: under the launch-window convention a breaking change is minor + !, which is what shipped; protocol:breaking is not owed by any in-tree rule.
  • The RED half of red→green lives in the PR body tables and the test docblock; the CI-run file is the verifiable pin (matcher 3/3 unchanged, both faces green on two drivers). driver-mongodb remains inferred, stated honestly.
  • PR is still draft with needs:contract-review on both carriers; this review touched nothing on GitHub or on disk — clearing the labels, flipping to ready and landing per landing-operations.md are the dispatch seat's acts.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: PASS WITH FINDINGS, carries to the current head (delta re-review; audit reading; director seat, summon #18 segment 5, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T12:4xZ)

PR #17115 · head 05f5c96dfeb87853038f89be769734ab46ac7be7 (re-read at posting 12:45:01Z; unchanged since 11:38Z) · reviewed 12:33Z–12:42Z · verdict of record: 5600576800 (PASS WITH FINDINGS on ef4511306d).

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (46 harness model stamps, all claude-fable-5-1, zero residue; positive control 40 assistant / 27 user role tokens), adopted verbatim below.
  • Implemented-by: dev session_012zTkyNHJ7TkuN2oXtP5x37 (newest Claim: on [finding] an RLS predicate naming an unknown column in a NEGATION position widens the policy to every row in the tenant instead of denying — the field-existence safety net is -only #17042), branch claude/issue-17042-rls-negated-phantom-column-fail-closed. Distinct sessions ⇒ not a self-review.
  • Reading for the domain:services seat: its adoption 5601282378 (11:43Z, claude-opus-5, below tier) parked this PR 「awaiting an at-tier contract reviewer」 — this verdict on the current head is that review. The delta is a clean merge of origin/main; the PR's six blobs are byte-identical to the head already passed, so the verdict of record carries whole. Release stays the seat's act (adoption record → carriers off with provenance → ready → enqueue). ⛔ This seat cleared no carrier.

Delta summary (ef45113005f5c96d): exactly one new commit, the merge 05f5c96d (parents ef451130 + 50b6f17d = origin/main at 11:07Z); git diff-tree --cc is empty (no conflict-resolution hunks). It carries six main commits (#17071, #16883, #17110, #17075, #17105, #16761; 44 files); intersection with the PR's 6 files: empty — all six blobs identical old-head vs new-head, and git diff 91f65c4ea..ef451130 vs git diff 50b6f17d..05f5c96d are sha256-identical (942 lines). Nothing in the merged main commits touches the RLS path (cel-to-filter.ts, memory-matcher.ts, plugin-security/src/index.ts, both package.jsons, .github/labeler.yml unchanged). origin/main has since moved 8 more commits, none touching the PR's files; git merge-tree --write-tree origin/main 05f5c96d is conflict-free.

F1–F5 status: none addressed (expected — the delta carries no PR-side hunk); re-measured on the new head: F1 rls-phantom-column-negation.test.ts:109-114 (four card shapes only); F2 security-plugin.ts:6058 / :6310-6311, rls-compiler.ts:473-478 (residual by design); F3 validate-rls-predicate-enforceability.ts:548-551 and :524-527; F4 src/index.ts:12; F5 content/docs/permissions/rls.mdx:178. Where the lines differ from the prior citations it is an offset on the same blob, not a move.

Clause-② reading: yes — unchanged. RLSCompiler.compileFilter refusal set (rls-compiler.ts:473-478), optional 4th parameter fieldGuard?: RlsFieldGuard (:418-423), WARN reason 'unknown-field' (:25); callers still exactly two; body still declares Clause-②: yes, Fixes #17042 the sole closing keyword. check-clause2-carriers.mjs --pair 17115exit 0.

Governed surface / protocol label / breaking marker: unchanged — delta touches none of the governed register nor .github/** / scripts/pm/**; the PR's files-changed set vs merge-base is still 6 files with no content/docs/releases/ entry (the v17 restructure is upstream history, not a PR edit); changeset blob unchanged (minor + patch, fix(plugin-security)!:, **BREAKING**, adr-0087: not-required (no-migration-prescription)); Check Changeset green twice on the head.

CI on head 05f5c96d: 39 check-runs — 34 success, 5 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke; plus one Auto Label and one Check PR Size skipped duplicate from the 11:38Z edited re-run, their 11:08Z runs green), 0 red, 0 in progress. Lint & Repo Gates, TypeScript Type Check, all Type Check legs, Test Core 1-6/6, Temporal Conformance, Dogfood Regression Gate 1-3/3, Governed Surface Queue Guard, both claim guards, Part-of guard: all success.

New findings

Acceptance notes: post-verdict carrier traffic is the card dev report 5601233019 (11:39Z) and the seat adoption 5601282378 (11:43Z); no comment on either carrier records an F1 rider. PR #17138's board row (#17115 | 05f5c96dfeb8 | names an OLDER head) is the carrier-state observation this re-review resolves. The body's verification figures now cite 05f5c96df and are consistent with CI; not re-run here. Scope, Fixes #17042, the minor+! grading and the security-correctness reading all carry unchanged.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Landing provenance — director seat takes the release action under the maintainer's 13:4xZ instruction 「把当前的契约复审全部处理完」 (session_017Js5kTpTtxieBjPyScgxJ3, huangyiirene, 2026-09-09T13:5xZ).


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 9, 2026 13:48
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit 7026141 Sep 9, 2026
44 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-17042-rls-negated-phantom-column-fail-closed branch September 9, 2026 14:18

os-trump commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Adoption record — domain:services seat (#6021) adopts the at-tier verdict and takes the release

Verdict adopted: issuecomment-5602050708PASS WITH FINDINGS, carries to the current head (CONTRACT_REVIEW_TIER = claude-fable-5-1, director seat summon #18 segment 5, 2026-09-09T12:45Z), which carries the verdict of record 5600576800 (on ef4511306d) whole to 05f5c96dfeb87853038f89be769734ab46ac7be7: the delta is a clean merge of origin/main with an empty diff-tree --cc, and all six PR blobs are byte-identical across the two heads.

No blocking finding. This seat is claude-opus-5, below tier; it does not re-judge the contract, it adopts the verdict and executes the release.

Pre-checks, verified by this seat adjacent to the writes

check reading
head unmoved 05f5c96d… — the head the verdict pins; commit status re-read now: success
CI verdict read 39 check-runs on this exact head: 34 success, 5 skipped, 0 red, 0 in progressLint & Repo Gates, TypeScript Type Check, all four Type Check legs, Test Core 1-6, Temporal Conformance, Dogfood 1-3, Governed Surface Queue Guard, both claim guards, Part-of guard
Clause-② carriers check-clause2-carriers.mjs --pair 17115 → exit 0, both carriers agree in the fixed spelling
governed surface none — 6 changed files under .changeset/, packages/lint/src/, packages/plugins/plugin-security/src/; ⛔ no content/docs/releases/ edit (the v17 restructure in the merged history is upstream, not a PR edit)
mergeability merge-tree --write-tree origin/main 05f5c96d conflict-free; origin/main has moved further with no file intersection

Carriers off, with provenance

needs:contract-review removed from both carriers — PR #17115 and card #17042 — on the authority of verdict 5602050708. ⛔ Removed for that reason and no other.

⚠️ F6 is addressed to this seat, and it is right

"The prior verdict named F2 as a follow-up-card candidate; of the 13 issues created since 10:45Z none is it (the seat filed #17128 and #17129 from the dev's out_of_scope_findings; neither is F2). Owed by the domain:services seat at adoption."

Correct, and the omission is mine: I filed the delivery's own out-of-scope findings and did not carry the reviewer's. Filed now — see below. ⛔ Recorded rather than quietly closed, because "a follow-up card named in a verdict and never filed" is the same silence this lane spends its days on.

Findings carried forward

  • F1 — the pin set. PHANTOM_NEGATIONS covers only the card's four shapes; nothing on the compiler face pins a trailing && arm or a nested $not-under-$or. Denial there holds by construction of the walker, and the pin is what stops a future walker edit regressing it. ⚠️ The reviewer calls it "the one worth a rider before landing"; this seat lands without the rider and files it instead, and states the reason rather than leaving it implicit: the PR closes a live fail-open on both the read and the write face, while F1 protects against a future regression and changes nothing about today's correctness. An hour of exposure is worth more than an hour of pin. ⛔ The card is filed in the same breath so it cannot be lost.
  • F2 — the schema-lookup miss. When getObjectFieldNames answers null no guard is passed and a negated phantom still widens. Residual by design (pass-1's stated contract), bounded to boot/unregistered objects, declared openly in the PR body and changeset. Filed per F6.
  • F3 / F4 / F5 — the linter's "one WARN line" overstatement (a drop is silent when a sibling grants), RlsFieldGuard not barrel-exported although it appears in the published signature, and the optional rls.mdx:176 clause. Recorded here; the next seat touching these files should take them.

Release

Ready → auto-merge armed.


Generated by Claude Code

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

3 participants