Skip to content

docs(security): state the RLS write check as the gate enforces it in ADR-0066 and the data skill - #20298

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-20275-rls-write-check-texts
Sep 28, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-20275-rls-write-check-texts

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20275

Clause-②: no

Two governed texts said the RLS write check wrong. ADR-0066's combination rule (item 3) called row policies "OR-combined (any matching policy admits the row)" for every operation; the published data skill's RLS section called using the read filter and check the write filter, with no stand-in rule, no per-row statement and no refusal rule. Both now say what main enforces, in the wording the schema already carries. #20268 landed the non-governed half (the rls.zod.ts overview and describe texts, the docs pages); this PR is the governed half and waits on the maintainer's hand (Tier H: docs/adr/** and skills/**). No code change.

Provenance of the three facts the skill now states: every-row judging of inserts and updates landed in #19988 and #20012; the refusal of a non-blank check on a select / delete policy in #20167; the per-operation default and the default-posture wording in #20268.

What changed

docs/adr/0066-unified-authorization-model.md — item 3 of "Precedence / combination semantics"

One sentence. It now reads: on a read, the applicable policies' using predicates OR-combine (any matching policy admits the row); on an insert or update, the check is chosen once per operation across the applicable policies — the declared check predicates when any declares one, else each applicable policy's using standing in — and the chosen predicates OR-combine over every row written. The tenant-isolation clause and the superuser-bypass sentence are unchanged; Status, headings and the other items are untouched.

The enforcing code is cited as symbol anchors, so check:adr-symbol-anchors holds them:

  • packages/plugins/plugin-security/src/rls-compiler.ts#RLSCompiler.compileFilter — read side: OR-combines the applicable policies' using (its docblock: "Multiple policies for the same object/operation are OR-combined").
  • packages/plugins/plugin-security/src/security-plugin.ts#writeCheckPolicies — write side: takes the applicable policies that declare check; when none does, the ones that declare using (the platform ownership floor kept exactly when the pre-image gate kept it, keepOwnershipFloor). computeWriteCheckFilter then hands that set to compileFilter with the check clause, which OR-combines.

skills/objectstack-data/rules/security.md — the "Row-Level Security (RLS)" paragraph and its example comments

The paragraph now states, in this order:

  1. using admits rows — what a select policy lets the caller read, and the existing rows an update/delete policy lets it change or remove; on a read the applicable using OR-combine, then AND into the query.
  2. check is judged on every row an insert/update writes (array inserts and multi: true included; one failing row refuses the write), chosen per operation: when any applicable policy declares check, only those decide (OR-combined); else each applicable using stands in.
  3. A non-blank check on a select/delete policy is refused.
  4. The default posture, in the schema overview's words ("Default deny, among the policies that apply … when none applies the policies restrict nothing (the tenant wall still applies)"), plus the one clause the review of the non-governed half named: an update/delete target with no write-class using is bounded by the caller's select policies (the by-id pre-image gate derives its scope from the caller's SELECT narrowing when no write-class using applies; not for insert, not under the read-side superuser bypass).

The example's two comments (// read scope / // write scope) now read // rows readable / targetable and // every row written.

Wording follows the RowLevelSecurityPolicySchema.using / .check describe texts and the rls.zod.ts overview as landed in 3f86dc52; no second phrasing of the same fact was introduced. No issue or PR number appears in the skill text (check:doc-authoring refuses one under skills/).

Paying the token ceiling

check:skills-token-ratchet holds security.md at 2543 tokens with headroom 0. The RLS paragraph grew by 687 bytes and the two comments by 22; the difference is paid in the same file by removing sentences the file already states elsewhere — nothing moved to another file, the ceiling is untouched:

  • the permissions-vs-permissionSets bullet no longer repeats the code comment two lines above it (the refusal text, the ObjectStackDefinitionSchema source and "never a silent drop" stay);
  • the permission_set_id warning no longer says "record id" twice;
  • the RLS source line cites rls.zod.ts once (policy shape, grammar, check composition) instead of permission.zod.ts again (already cited under RBAC);
  • the owner-scoping bullet, the requiredPermissions paragraph (its enforcer was already named under maskingRule), the platform-global paragraph and its blockquote lose filler words, no facts.

Readings

Line/token budget (tokens = ceil(utf8 bytes / 4), the ratchet's own unit; 3f86dc52 → 4b330f38):

surface lines before → after tokens before → after
skills/objectstack-data/rules/security.md 214 → 216 2543 → 2541 (ceiling 2543, headroom 2)
skills/objectstack-data/** (17 files) 3807 → 3809 40934 → 40932
skills/**/SKILL.md (10 files) 4402 → 4402 51903 → 51903
whole skills/ tree (65 files) 13427 → 13429 155990 → 155988
ratchet "bundle total (whole shipped tree)" — 153970 → 153968
docs/adr/0066-unified-authorization-model.md 114 → 114 5096 → 5224 (no token ratchet on docs/adr/**)

The +2 lines in the skill file are the natural wrapping of a longer paragraph; the gate that prices skills/** is the token ratchet, and it went down.

Gates — derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands off the merge base at 4b330f38: 29 commands; every exit code captured before any pipe; --ran reconciliation: "29 derived, 29 run, 0 NOT-MEASURED, 0 UNRUN … all 29 recorded an exit code and none of them is 3".

  • node scripts/check-skills-token-ratchet.mjs → 0 — "skills/objectstack-data/rules/security.md is 2541 tokens (ceiling 2543; headroom 2)"; --self-test → 0 (65 cases)
  • node scripts/check-adr-symbol-anchors.mjs → 0 — "2125 anchors across 140 records resolve"; --self-test → 0
  • node scripts/check-adr-links.mjs → 0; --self-test → 0
  • node scripts/check-ci-filter-parity.mjs → 0
  • node scripts/check-closing-keyword-parity.mjs → 0; --self-test → 0
  • node scripts/check-comment-mask-corpus.mjs → 0
  • node scripts/check-doc-route-spelling.mjs --advisory → 0; --self-test → 0
  • pnpm --filter @objectstack/lint run check:doc-formula-expressions → first run exit 3 (PREREQUISITE NOT MET: @objectstack/formula and @objectstack/lint not built — a refusal, not a measurement); after turbo run build --filter=@objectstack/formula --filter=@objectstack/lint under os-verify-lock.sh (VERDICT command-exit 0, held 240s) → 0
  • pnpm check:adr-anchors → 0 · check:agent-test-spelling → 0 · check:corpus-claim-drift → 0 · check:cross-package-test-inputs → 0 · check:doc-authoring → 0 · check:driver-memory-census → 0 · check:gitlink-declared → 0 · check:nul-bytes → 0 · check:pm-governed-merges → 0 · check:pm-prior-rulings → 0 · check:refd-timer-probe → 0 · check:role-word → 0 · check:skill-compatibility → 0 · check:skill-frame-sync → 0 · check:skill-identifier-liveness → 0 · check:watch-hint-literal → 0

Reverse verification of the ADR anchors (the gate lists only findings, so resolution was proven by failure): with writeCheckPolicies mutated to writeCheckPoliciesNOPE in the committed ADR, check-adr-symbol-anchors exits 1 with [unresolved-symbol] docs/adr/0066-unified-authorization-model.md:93; restored with git checkout HEAD -- PATH, git hash-object of the path equals the HEAD blob (01878adb…) and git diff HEAD is empty.

Package tests / typecheck: none owed — the diff touches no packages/** file, so there is no ① dependency closure and no ② package suite; the whole-repo pnpm lint sweep is CI's.

Changeset: skip-changeset applies. Both paths are outside every published package: 0 of the 69 non-private package.json manifests list skills/, docs/adr or a parent path in files[]; positive control — RowLevelSecurityPolicySchema is found in packages/spec/dist/security/index.d.ts (grep exit 0) and the schema's describe phrase "decided per operation across the applicable policies" in 9 spec dist files (exit 0); negative — the new skill sentence "chosen per operation across the applicable" is in no built output under packages/ (exit 1). docs/adr/** is on the fast track (never published).

Acceptance notes

  • The dispatch order's suggested ADR phrasing named "the using of the applicable insert-class policies" as the stand-in. The code is wider: writeCheckPolicies runs for insert and update alike (an all policy included), and when no applicable policy declares check, every applicable policy's using stands in for that operation. The landed text says "each applicable policy's using", matching RowLevelSecurityPolicySchema.using ("on an insert or an update, when no applicable policy for that operation declares check, each applicable policy's using also stands in"). The triage note's "an insert policy's using stands in when no check is declared" is one instance of that rule, not the whole rule.
  • The example policy org_isolation (a hand-written organization_id == current_user.organization_id select policy) sits beside the file's own Multi-tenancy section ("⛔ never single + your own RLS") and duplicates the Layer 0 wall. Left as is: not this card, and the token ceiling was paid without touching it. Observation only, nothing to file.
  • check-doc-formula-expressions refuses (exit 3) on a fresh worktree until @objectstack/formula and @objectstack/lint are built; its refusal text names the fix. Not a finding.

维护者速读(草稿)

改了什么:两处受管文本各改一段。ADR-0066「优先级/组合语义」第 3 项那一句,从「同一对象/操作的多条行策略 OR 合并(任一匹配即放行)」改为:读侧按 using OR 合并;写侧(insert/update)先按操作在适用策略中选出检查——有声明 check 的只用它们,否则每条适用策略的 using 顶上——再 OR 合并,逐行判定写出的每一行;并以符号锚引用 compileFilter 与 writeCheckPolicies。发布技能包 objectstack-data 的 RLS 段改写为四句:using 放行哪些行;check 逐行判定 insert/update 写出的每一行(数组插入与 multi: true 包含)、按操作选定、无 check 时 using 顶上;select/delete 策略上的非空 check 被拒;默认姿态(只在有策略适用时默认拒绝;无策略适用则不限制,租户墙照旧;update/delete 目标行在没有写侧 using 时受调用者的 select 策略约束)。示例块两行注释同步。

为什么改:这两段是 AI 写 RLS 策略时读的唯一说明,原文把 check 当成读过滤的对偶、且不写顶替规则,按它写出的策略与运行时真实执行不一致(NORTH-STAR 优先级规则 4:写给 AI 的文档与 skills 说错一句等于产品缺陷)。执行代码本身是对的,非受管文本已由 #20268 修正;本 PR 只改受管的两处,不动代码。

风险与代价(含回滚):纯文本;不改 schema、不改运行时。security.md 的 token 上限为 2543、余量 0,新增内容以删除同文件重复句付账(2543 → 2541),上限未动、未挪内容到别的文件。29 个派生门禁全绿,ADR 符号锚经反向验证(改坏符号名即变红)。回滚即 revert 本 PR 的单个 commit,无迁移、无数据影响。

席位意见:(留空)

你要做的:审阅两处措辞后在本 PR 上 Approve(Tier H);落地由席位执行。


Generated by Claude Code

ADR-0066 item 3 said row policies for one object/operation are OR-combined
for every operation. That holds on a read: RLSCompiler.compileFilter
OR-combines the applicable policies' `using`. On an insert or update the
check is chosen once per operation across the applicable policies first
(writeCheckPolicies: the declared `check` predicates when any declares
one, else each applicable policy's `using` standing in), and the chosen
predicates OR-combine over every row written. The sentence now says both
halves and cites the two enforcing symbols.

The published data skill's RLS section called `using` the read filter and
`check` the write filter with no stand-in rule. It now states what each
clause admits per operation, that `check` judges every row an insert or
update writes (array inserts and `multi: true` included), that `using`
stands in when no applicable policy declares `check`, that a non-blank
`check` on a `select` / `delete` policy is refused, and the default
posture in the schema's own words. The token ceiling on that file has zero
headroom, so the additions are paid by de-duplicating sentences the same
file already states elsewhere.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjvgiFAmjHqsxy1XLiVYfH
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation labels Sep 27, 2026
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 27, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4b330f38a288a29c12b0958105bb3e2bf981348d
Local-runs: none

Face: docs/adr/** (ADR-0066, the authorization model of record) and skills/** (objectstack-data/rules/security.md, shipped to customer projects). Reviewed in-seat at tier by the dispatching seat, on the diff (two files, +43 / −41), the card, and the head's check-runs; every enforcing-code reading below was taken on origin/main 3f86dc52, the PR's base.

① Derived judgments

  • ADR-0066 item 3: the one rewritten sentence is true of the code. Reads: RLSCompiler.compileFilter OR-combines the applicable policies' using (its own docblock says so). Writes: writeCheckPolicies (security-plugin.ts) returns the applicable policies that declare check, else the applicable ones that declare using (the platform ownership floor kept or dropped by keepOwnershipFloor), and the caller OR-combines them over every row written. The two symbol anchors resolve (check:adr-symbol-anchors, 2125 anchors; the dev's reverse test with a mutated anchor reds :93). The ADR-0058 D4 citation is right: D4 is 「Enforce RLS check (write-side validation)… defaulting to using when omitted」. Nothing else in the ADR moved; Status untouched.
  • Skill RLS paragraph, sentence by sentence against the schema of record at 3f86dc52: using = 「the rows a select policy lets a caller read, and the existing rows an update or delete policy lets a caller change or remove」 and the per-operation stand-in are the RowLevelSecurityPolicySchema.using describe (rls.zod.ts:420); every-row judgment, array inserts, multi: true, 「One failing row refuses the whole write」 and the per-operation choice (declared checks only, else each applicable using) are the .check describe (:483); the select/delete refusal is the schema's superRefine (:546–:568); 「Default deny, among the policies that apply … when none applies the policies restrict nothing (the tenant wall still applies)」 is the overview (:94–:99) as PR fix(spec): state the RLS using / check texts as the write gate enforces them #20268 landed it. The one clause beyond the schema text — an update/delete target with no write-class using is bounded by the caller's select policies — is enforced: security-plugin.ts:2540–:2545 (「an empty write-class collection now derives its scope from the caller's SELECT narrowing inside computeLayeredRlsFilter」, [security] A by-id write is not gated by record visibility — a contributor mutates records they cannot read, when only select-scope RLS is authored #7665). No second spelling of any fact was introduced. The example comments now read as the semantics (// rows readable / targetable, // every row written).
  • The dev's correction of the dispatch order's suggested wording (stand-in = each applicable policy's using for that operation, not only insert-class policies) is right by the same describe text and by writeCheckPolicies, which runs for insert and update alike.
  • Payment of the token ceiling: security.md 2543 → 2541 tokens (10163 bytes, 216 lines; ceiling 2543, headroom 2), paid inside the file by shortening sentences whose facts stay stated (the permissions key refusal, the record-id warning, the window, the field gate, the platform-global paragraph). Two pointers left the file: 「objectstack-platform lists every top-level key」 and permission.zod.ts as an RLS source — the latter is right to drop, RowLevelSecurityPolicySchema lives in rls.zod.ts (:221); the former is navigation, not a rule. No fact about behaviour was removed; nothing moved to another file; no ceiling raised.
  • Reach: text only; no schema, runtime, renderer or public surface change. No issue or PR number in the skill text. Family sweep: the ADR sentence and the skill paragraph were the two remaining false sites named by PR fix(spec): state the RLS using / check texts as the write gate enforces them #20268's review; the non-governed sites landed there.

② Semver level

docs/adr/** and skills/** only, in no workspace files[] (the dev's positive control: RowLevelSecurityPolicySchema and the check describe phrase present in spec's dist; negative: the new skill sentence in no built output) ⇒ no changeset; skip-changeset correct; consistent with Clause-②: no.

③ Boundary flags

No open_questions. Deviations accepted: the wording correction above (code over the order's suggestion); check:doc-formula-expressions built its two prerequisites under the verify lock before answering (a prerequisite, not a finding); the first --ran file re-recorded with exit codes (the reconciler refused the claimed zero, as designed); +2 lines from the wrap of a longer paragraph while the priced unit went −2. The org_isolation example duplicating the tenant wall stays out (different defect, Acceptance notes, carrier none). Derived gates 29 / 29 run, 0 NOT MEASURED, a DERIVED zero. No flag remains open.

Implemented-by: claude/issue-20275-rls-write-check-texts
Reviewed-by: session_01MjvgiFAmjHqsxy1XLiVYfH

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)— PR #20298

改了什么:两处受管文本各改一段,不碰代码。ADR-0066「优先级/组合语义」第 3 项那一句,从「同一对象/操作的多条行策略 OR 合并(任一匹配即放行)」改为:读侧按各策略的 using OR 合并;写侧(insert/update)先按操作在适用策略里选出检查——有策略声明 check 就只用声明的,否则各适用策略的 using 顶上——再对写出的每一行 OR 合并;租户隔离策略仍在其上 AND。skills/objectstack-data/rules/security.md 的 RLS 段改为按 schema 自己的说法写四件事:using 决定可读/可作为目标的行;check 对 insert/update 写出的每一行判定(数组插入与 multi: true 包含,一行不过整笔拒绝),按操作选出、没声明就由 using 顶上;select/delete 策略上非空 check 被拒;默认姿态——有策略适用时未被任何策略放行的行拒绝,没有策略适用时 RLS 不限制(租户墙照常),没有写侧 using 的 update/delete 目标由调用者的 select 策略圈定。示例注释同步。

为什么改:这两段是 AI 写 RLS 策略时读的唯一说明。原文把 check 说成「写过滤」、写侧说成「任一匹配即放行」、且不写顶替规则——按它写出的策略,运行时执行的是另一回事(优先级规则 4:写给 AI 的文档与 skills 说错一句等于产品缺陷)。执行代码本来就是对的,非受管的同类文本已由 PR #20268 修正;这两处因受管而留到本 PR。

风险与代价(含回滚):纯文本,不改 schema、运行时或任何门禁。security.md token 上限 2543、余量 0,新增内容以同文件删重复句付账(2543 → 2541),上限未动、没有内容挪去别的文件、没有事实被删。回滚 = revert 本 PR。29 个派生门禁全部跑过、全绿;ADR 里两个符号锚经反向验证(改坏符号名即红)。

席位意见:已逐句对 rls.zod.ts 的 describe 文本与概述(PR #20268 落地的那版)、writeCheckPolicies 与 #7665 的 select 圈定注释核过——技能段没有引入第二种说法,每句都能指到执行代码;ADR 那句引的 ADR-0058 D4 正是 check 的执行决策。dev 把派发令里「只有 insert 类策略的 using 顶上」纠正为「各适用策略的 using 顶上」,以代码为准,是对的。契约复核记录 PASS 在本 PR 上。建议批准。

你要做的:在本 PR 上 Approve。之后由本席清标、翻 ready、挂 auto-merge 走队列落地。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 28, 2026 02:18
@hotlong
hotlong enabled auto-merge September 28, 2026 02:18
@hotlong
hotlong added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 2972097 Sep 28, 2026
44 checks passed
@hotlong
hotlong deleted the claude/issue-20275-rls-write-check-texts branch September 28, 2026 02:43
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/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants