Repository navigation
feat(spec)!: refuse a check on a select / delete row-level security policy - #20167
objectstack-fleet[bot] merged 2 commits into
Conversation
A `check` judges the post-image of a write, and the write gate only collects policies whose operation is the write's own or `all`, so a `check` on a `select` or `delete` policy was accepted, stored and never evaluated (ADR-0049). RowLevelSecurityPolicySchema now refuses it at the `check` path, naming the operation and both rewrites. Registered as the ADR-0087 D3 semantic entry rls-check-on-select-or-delete-policy-refused. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
…defineStack doors; regenerate reference docs Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d3f42c9200bae1724ac00eace414eabe67ce9180 && git checkout d3f42c9200bae1724ac00eace414eabe67ce9180
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 84880f92243889d3e1daf9d4926d75277c9b5a94 f25e34d08bef76fd97d88c4d4ff90dcee4f05863 && git checkout -B drift-repro 84880f92243889d3e1daf9d4926d75277c9b5a94 && git merge --no-ff f25e34d08bef76fd97d88c4d4ff90dcee4f05863
node scripts/docs-audit/affected-docs.mjs --json 84880f92243889d3e1daf9d4926d75277c9b5a94
|
Contract reviewServed-tier: 82/82 ① Derived judgments
② Semver levelRIGHT.
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…es them (objectstack-ai#20268) Fixes objectstack-ai#19967 Clause-②: no Text only. No schema shape, accepted value or runtime behaviour moves. Every sentence below was re-measured against the code on `origin/main` at `0d3ec47137`, where PR objectstack-ai#19962 (`b5853da1ca`), PR objectstack-ai#19988 (`009da14713`), PR objectstack-ai#20012 (`44639665ee`, objectstack-ai#19989) and PR objectstack-ai#20167 (`b276d4463f`) are all merged. Each `git merge-base --is-ancestor` probe returned exit 0. ## What `main` enforces (the measurement the texts now state) - **Write `check` selection, per operation.** `writeCheckPolicies` (`packages/plugins/plugin-security/src/security-plugin.ts:890-899`) returns the policies that declare `check` when any applicable policy does. Otherwise it returns every applicable policy with a `using`, and keeps the platform ownership floor only where the write's row gate kept it. `compileFilter` (`rls-compiler.ts:600-602`) reads `check` for a policy that declares one and `using` for the rest, then OR-combines (`:676`). - **A USING-only `insert` policy's `using` is the insert check** when no applicable policy declares `check`. Pinned by `rls-check-defaults-to-using.test.ts`, case "an insert-class USING-only policy gates INSERT the same way", which is green on this head. - **A check-only `update` policy is legal and enforced.** The schema accepts it (`rls.zod.ts`, the at-least-one rule). The row gate derives the write scope from `select` when no write-class `using` applies (`security-plugin.ts:7018`), and the post-image check judges the written rows. Pinned by `check-only-write-scope.test.ts`, which is green. The showcase's `invoice_owner_immutable` is one such policy. - **A non-blank `check` on `select` / `delete` is refused** (`rls.zod.ts`, the `superRefine` at the `check` path, from PR objectstack-ai#20167). - **OR-combination.** On reads, and on the rows an update or delete may target, the applicable policies' `using` clauses OR-combine (`compileFilter`). On the written rows, the check is chosen first and then OR-combined. So "most permissive wins" is true on reads and false as a statement about writes. - **Post-image scope: every insert and update shape.** The seam is installed for every insert and for every update (`security-plugin.ts:3191`, `:3261`, `:3508`). The engine runs it: - on each live row of an insert, after `beforeInsert` (`packages/objectql/src/engine.ts:12470`); - on the by-id row merged with the final payload, after `beforeUpdate` (`:14085`, PR objectstack-ai#20012); - on each matched row of a `multi: true` update (`:14345`, PR objectstack-ai#19988). The by-id update is also judged in the middleware on the change set as sent (`security-plugin.ts:3116`). Pinned by `rls-check-multi-row-writes.test.ts`, `rls-check-by-id-update-post-hook.test.ts` and `insert-check-post-image.test.ts`, all green on this head. ## Sites, old text, new text, and the code that makes the new text true | Site | Old text | New text | True because | |---|---|---|---| | `rls.zod.ts` `check` describe | "matched against the new row of a single-record INSERT or a by-id UPDATE ...; an array insert and a `multi: true` update are not post-image checked." | "judged on every row an insert or an update writes ...: each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent." | `engine.ts:12470`, `:14085`, `:14345`; `security-plugin.ts:3116` | | `rls.zod.ts` `check` TSDoc | "Validation of the new row of a single-record INSERT or a by-id UPDATE. An array insert and a `multi: true` update are not post-image checked." | Every row an insert or update writes, with the per-shape image. It also names the engine-filled values that are not on an insert's judged image (autonumber, a `secret` field's stored reference, an absent tenant column). | the same lines; the engine's closed list after the insert seam (`engine.ts`, the comment above `:12470`) | | `rls.zod.ts` `check` TSDoc, floor | "takes part only where the by-id pre-image gate kept it" | "only where the write's own row gate kept it" | `computeWriteCheckFilter`: a `multi: true` update has no by-id gate and keeps the floor unless the OWD yields (`platformFloorYieldsToObjectWriteModel`) | | `rls.zod.ts` `using` describe | "Filter condition for SELECT/UPDATE/DELETE ... Optional for INSERT-only policies." | "Row predicate ...: the rows a `select` policy lets a caller read, and the existing rows an `update` or `delete` policy lets a caller change or remove. On an insert or an update, when no applicable policy for that operation declares `check`, each applicable policy's `using` also stands in as its check on every row written ...; on an `insert` policy that is its only effect. ... Needed on a `select` or `delete` policy (a `check` there is refused); optional on an `insert`, `update` or `all` policy that declares `check`." | `writeCheckPolicies:896`; `getApplicablePolicies:846`; the `check` refusal | | `rls.zod.ts` `using` TSDoc | "For INSERT-only policies, USING is not required (only CHECK is needed). For SELECT/UPDATE/DELETE operations, USING is required." | A per-operation list. `update` does not require `using`: a check-only `update` policy is accepted and enforced, and its target rows come from the other `update` / `all` `using` or from `select`. An `insert` policy's `using` filters nothing, but it is the insert check when none is declared. | `security-plugin.ts:7018`; `check-only-write-scope.test.ts` | | `rls.zod.ts` `superRefine` message | "... For SELECT/UPDATE/DELETE operations, provide "using". For INSERT operations, provide "check"." | The same head. Then: `select` / `delete` take "using"; `insert` takes "check", or a "using" alone as that check when no applicable insert policy declares one; `update` / `all` take either or both. | the schema accepts each prescription (pinned below) | | `rls.zod.ts` schema TSDoc | "combined with OR logic (union of results)" | OR on reads; on writes, the per-operation choice (see `check`) | `compileFilter`; `writeCheckPolicies` | | `rls.zod.ts` `priority` TSDoc | "Applicable policies OR-combine (... the doc above `RLSCompiler.compileFilter` and this schema's own former describe both say most-permissive-wins)" | No outcome depends on an order: OR on reads; on writes, the check is chosen per operation and then OR-combined | the same | | `rls.zod.ts` overview, "Default Deny" | "If no policy matches, access is denied" | Default deny among the policies that apply. When no policy applies, the policies restrict nothing (the tenant wall still applies). | `compileFilter:656` (`applicable === 0` returns `null`, no filter); `content/docs/permissions/rls.mdx` callout | | `migrations/registry.ts` `--from 16` prose (and regenerated `docs/protocol-upgrade-guide.md`) | "applicable policies OR-combine (most permissive wins)" | "no outcome depends on an order: applicable policies OR-combine on reads, and a write's check is chosen once per operation across the applicable policies, then OR-combined" | the same | | `liveness/permission.json` `check` / `using` evidence | `compileFilter` "`(policy as { check?: string }).check ?? policy.using`" (the expression is gone) | `security-plugin.ts#writeCheckPolicies` plus `rls-compiler.ts#compileFilter`, with the live expression | `check:liveness` resolves both anchors (green) | | `security-plugin.ts` `writeCheckPolicies` docblock | "The published contract is `RowLevelSecurityPolicySchema.check`: "defaults to USING clause if not specified"." | It points at `RowLevelSecurityPolicySchema.check` without quoting it, so it cannot go stale. PostgreSQL's per-policy rule is named as the starting point. | the describe itself | | `rls-check-defaults-to-using.test.ts` header | "A policy that declares no `check` holds the write post-image to its `using`, which is what `RowLevelSecurityPolicySchema.check` publishes: "defaults to USING clause if not specified"." | When no applicable policy declares `check`, each `using` stands in. The choice is per operation, not per policy. | `writeCheckPolicies` | | `content/docs/permissions/authorization.mdx:108-109` | "Multiple row policies for the same object/operation OR-combine" | They OR-combine their `using` on reads and on the rows a write may target. The written-row `check` is chosen per operation first. Links the fail-closed contract. | the same | ### In-place fixes beyond the claim's file surface (same defect class: the same stale sentences) The dispatch asked for a grep for other copies of the old sentences. These hits are not governed and not pending changesets, so they are fixed here. **File-surface supplement for the claim:** `content/docs/permissions/rls.mdx`, `content/docs/protocol/objectql/security.mdx` and `packages/spec/src/conversions/registry.ts` (TSDoc only). The two changesets below are also outside it. - `content/docs/permissions/rls.mdx:63`: "after a single-record insert or a by-id update; an array insert and a `multi: true` update are not checked" now says every row an insert or update writes. - `content/docs/protocol/objectql/security.mdx:144`: the same parenthetical. `using` is no longer "for SELECT/UPDATE/DELETE" only. - `packages/spec/src/conversions/registry.ts`: the `permission-rls-priority-removed` TSDoc said "most permissive wins". Its `summary` (which feeds `spec-changes.json`) is unchanged and not false. - `packages/spec/src/security/rls.test.ts`: the `priority` test comment said "(most permissive wins)". ## Pin sweep for the `superRefine` message ① The repo-wide grep for `At least one of` and `provide "using"` finds pins only in `packages/spec/src/security/rls.test.ts`: `toContain('At least one of')` and its negation. Both still hold, because the head is unchanged. No other package, doc or translation carries the message. ② New load-bearing pins, in the `RowLevelSecurityPolicySchema — the "at least one" refusal` block of `rls.test.ts`. For each of the five operations they assert the refusal's `code` (`custom`), `path` (`[]`) and head, that every operation is named, and that the false sentence is absent. They then prove each prescription against the schema itself: `select` / `delete` + `using`, `insert` + `check`, `insert` + `using` alone, and `update` / `all` with check only, using only, and both. All parse. ## Pending release notes corrected (DELIBERATE CORRECTION: `check-empty-changeset` is red by design) - `.changeset/19953-rls-check-default-composition-text.md` (from PR objectstack-ai#19962). It said: "The check runs on the new row of a single-record insert and of a by-id update. An array insert and a `multi: true` update are not post-image checked; those are tracked in objectstack-ai#19964 and objectstack-ai#19950, and the texts now say so". That would ship false. objectstack-ai#19988 and objectstack-ai#20012 land in the same release, and this PR changes the texts it says "now say so". The rewrite dates the old scope to when that change was written, and states the release's scope. - `.changeset/rls-check-defaults-to-using.md` (from PR objectstack-ai#19952). It said: "`RowLevelSecurityPolicySchema.check` reads "defaults to USING clause if not specified"". The describe no longer reads that in the release this note ships in (PR objectstack-ai#19962). Changed to "read ... (it now states the default per operation across the applicable policies, objectstack-ai#19953)". The triage on objectstack-ai#19967 raised this note for the dev to judge. The gate's remedy is to say so here and get it confirmed. Restoring either note from base would put the false sentence back. ## Reported only, not edited - **Governed (Tier H):** - `docs/adr/0066-unified-authorization-model.md:93`: "Multiple row policies for the same object/operation are OR-combined". This is the ADR sentence that `authorization.mdx` paraphrases, and it has the same false-for-writes reading. - `skills/objectstack-data/rules/security.md:72-75`: calls `using` the "read filter" and `check` the "write filter", and does not state the stand-in check. - `docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md:79-81`: about the read filter, and not false. - **Release-owned (never edited in a code PR):** `packages/spec/CHANGELOG.md:46934`, `:73373` and `packages/plugins/plugin-security/CHANGELOG.md:6610`, `:10225`. These say "(the schema's own describe says most-permissive-wins)". That was true of the describe when they shipped. - `packages/spec/spec-changes.json:79`, `:971`: the generated `summary` "policies OR-combine". It is shorthand and not false. It is regenerated from the conversion registry, which this PR leaves as is. ## Published surface - **`@objectstack/spec`**: `patch`. It ships `src/**/*.zod.ts`, `dist`, `liveness` and the `--from 16` prose. - **`@objectstack/plugin-security`**: no changeset. The two edits are a comment in a non-exported function and a test header. Measured: tsup strips in-body comments. A positive control in `packages/objectql/dist/index.js` shows the code line `postHookWriteImageCheck.honoured = true` present (1 hit) and the comment above it, "INSERT POST-IMAGE seam", absent (0 hits). `writeCheckPolicies` is not exported, so no `.d.ts` carries its docblock. ## Verification Every reading below was taken on head `e2bd8f3680`, the final commit, with a clean tree. - **Gate union.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived the list. `--ran` reconciles it: "116 derived famil(ies) accounted for — 112 run, 4 NOT-MEASURED". The 116 includes 2 families the derivation added after the regeneration commit (`pnpm --filter @objectstack/spec run check:generated` and `pnpm check:quick-reference-counts`). Both were run: exit 0. - **111 green.** - **1 red by design:** `node scripts/check-empty-changeset.mjs --base origin/main` exits 1 with the DELIBERATE CORRECTION class, for the two notes named above. - **NOT MEASURED, each one a PREREQUISITE NOT MET (exit 3):** - `check:skill-examples` needs a built `@objectstack/client-react` (a 36-package closure); - `check:dual-build-cjs-loads` needs a full `pnpm build`; - `check:i18n` needs the built CLI closure; - `check:type-check-debt` needs a whole-workspace build. - **`@objectstack/spec` build:** exit 0, DTS included. `check:generated`: 2 of 15 stale (`docs/protocol-upgrade-guide.md`, `content/docs/references/**`). Regenerated with exactly `gen:upgrade-guide && gen:docs`, then "All 15 generated artifacts are up to date". - **`@objectstack/spec` tests:** `vitest run --project local --maxWorkers=2`: "Test Files 547 passed (547) · Tests 16086 passed | 2 todo". - **`@objectstack/plugin-security` tests:** `vitest run --maxWorkers=2`: "Test Files 139 passed (139) · Tests 2839 passed (2839)". This includes the semantics pins cited above: `rls-check-defaults-to-using` (22 cases), `check-only-write-scope` (21), `rls-check-multi-row-writes` (32), `rls-check-by-id-update-post-hook` (24) and `insert-check-post-image` (25), all green. - **`@objectstack/plugin-security` typecheck:** exit 0, test layer included ("check:test-typecheck: OK"). - **`@objectstack/spec` typecheck: declared narrowing.** The full `pnpm --filter @objectstack/spec typecheck` never got a turn on the shared verify lock in about 40 minutes of queueing: 8 attempts, each exit 99. Its three legs are covered as follows: - the src program: the build's DTS pass compiles the entry closure, which contains `rls.zod.ts`, `migrations/registry.ts` and `conversions/registry.ts`; - the test program: `check:test-typecheck` is green inside `check:generated` on this head, and `tsconfig.test.json` includes `src/**/*.test.ts`; - `check:scripts-typecheck`: no spec script is touched. CI's `TypeScript Type Check` runs the full command. - **eslint, a proven narrowing:** - ① Population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus build output, which contains all 6 changed `.ts` files. - ② `pnpm exec eslint --no-inline-config --format json` over those 6 files reports 6 files, 0 errors, 0 warnings, exit 0. - ③ No config object sets `parserOptions.project`, so no rule is type-aware, and this diff cannot move a verdict on an untouched file. - **Control bytes:** `check:nul-bytes` is green. The self-scan `grep -naP` for C0 control characters and DEL over the changed files exits 1 with no match. ## Acceptance notes - **Whitespace-only `check`: the schema and the runtime disagree** (observation, not filed; no public-door measurement, no named producer). The at-least-one rule tests `!data.check`, so `check: ' '` satisfies it. The runtime and the `select` / `delete` refusal read a blank clause as absent (`policyDeclaresClause`). So a policy with only a whitespace `check` parses and is inert. The `using` describe's "needed on a `select` or `delete` policy" is worded as the runtime reads it. - **`@objectstack/lint` `rls-predicate-*` messages understate the scope** (`packages/lint/src/validate-rls-predicate-enforceability.ts:272`, `:318`, `:943`, `:969`). They say "the single-record INSERT check" and "every single-record insert and by-id update ... fails". Since PR objectstack-ai#19988 and PR objectstack-ai#20012 this is true but narrow: array inserts and `multi: true` updates are refused too. These are not false, and they are outside this card's file surface. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ADR-0066 and the data skill (objectstack-ai#20298) Fixes objectstack-ai#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. objectstack-ai#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 objectstack-ai#19988 and objectstack-ai#20012; the refusal of a non-blank `check` on a `select` / `delete` policy in objectstack-ai#20167; the per-operation default and the default-posture wording in objectstack-ai#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 说错一句等于产品缺陷)。执行代码本身是对的,非受管文本已由 objectstack-ai#20268 修正;本 PR 只改受管的两处,不动代码。 **风险与代价(含回滚)**:纯文本;不改 schema、不改运行时。`security.md` 的 token 上限为 2543、余量 0,新增内容以删除同文件重复句付账(2543 → 2541),上限未动、未挪内容到别的文件。29 个派生门禁全绿,ADR 符号锚经反向验证(改坏符号名即变红)。回滚即 revert 本 PR 的单个 commit,无迁移、无数据影响。 **席位意见**:(留空) **你要做的**:审阅两处措辞后在本 PR 上 Approve(Tier H);落地由席位执行。 --- _Generated by [Claude Code](https://claude.ai/code/session_01MjvgiFAmjHqsxy1XLiVYfH)_ Co-authored-by: objectstack-fleet[bot] <noreply@anthropic.com>
Fixes #19965
Clause-②: no
What
RowLevelSecurityPolicySchemanow refuses a non-blankcheckon a policy whoseoperationisselectordelete. The refusal is onecustomissue at thecheckpath. Its message names the operation and gives both rewrites:checkdocblock and.describe()sentence "Acheckon aselectordeletepolicy is never evaluated" now describe the refusal. The generated reference pages (content/docs/references/security/{rls,permission}.mdx) were regenerated withgen:docs, not edited by hand.rls-check-on-select-or-delete-policy-refusedunder protocol 18. There is no D2 conversion because no lossless rewrite exists: dropping the key discards the predicate, and moving it changes which rows the policy admits. The changeset isminorfor@objectstack/spec, carries a**BREAKING**banner and a FROM → TO table, and has theregistereddisposition marker.check(empty or whitespace only) is not refused. The runtime reads a blank clause as absent (policyDeclaresClause), and the Studio editor leavescheck: ''behind when an author clears the field.packages/lint/src/validate-rls-predicate-enforceability.tswas not touched.Premise, re-measured on
origin/main8d1f7abcheckonly on insert/update): holds.function writeCheckPolicies(is atsecurity-plugin.ts:889.computeWriteCheckFilter, is reached from step 3.6, which is guarded byopCtx.operation === 'insert' || opCtx.operation === 'update'(:3111-3112).getApplicablePolicies(rls-compiler.ts:733-775), which admitspolicy.operation === rlsOporalland nothing else.checkreader inplugin-security,runtime,objectql,verifyandlintfound onlyrls-compiler.ts:536(theclause: 'check'compile) andsecurity-plugin.ts:893. Both sit behind that gate.packages/ examples/ skills/ content/ apps/ scripts/ docs/ .changeset/at8d1f7ab: 9332 files, 264operation: select|deleteliterals, 2 with a siblingcheck. Both are tests, and both were triaged as "the operation was incidental" (rewritten, not re-declared):packages/spec/src/security/rls.test.ts:48("complete policy with all fields") is nowoperation: 'all', where both clauses are read.packages/plugins/plugin-security/src/rls-reserved-membership-keys.test.ts:189(acompileFiltercheck-face fixture;compileFilterignoresoperation) is nowoperation: 'insert'.operationbesidecheckfound 1 candidate. It is a false positive: a.findpredicate indefault-permission-sets.test.ts:127.f8a9d0fb05: 8314 files, 17 occurrences, 0 withcheck. objectui atorigin/main5c61e52: 8798 files, 17 occurrences, 0 withcheck.PermissionSetSchema.rowLevelSecurityisz.array(RowLevelSecurityPolicySchema)(permission.zod.ts:920), and the stackpermissionsisz.array(PermissionSetSchema)(stack.zod.ts:461).permissiontype validates againstPermissionSetSchema(metadata-type-schemas.ts:164), andos validateparsesObjectStackDefinitionSchema(validate.ts:283).defineStackrefuses withcode: 'STACK_SCHEMA_INVALID',status: 422and exactly one issue atpermissions.0.rowLevelSecurity.0.check. The same stack without thecheckis accepted.os validateand a live metadata save were not measured by a run (reach established by reading the code only).select/deletepolicy gets exactly this one issue, not the "At least one of using or check" issue.using) parses. Both are pinned.check-adr-0087-registrationpasses with:✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.Pins (
packages/spec/src/security/rls.test.ts)These pins assert the issue code, path and message head, never a bare throw:
select+checkanddelete+checkare refused.checkis left to the existing rule.insert/update/allwithcheckparse.selectwithusingparses.rowLevelSecurity[1].check, sole issue) and thedefineStackdoor (envelopecode+status).Evidence, at
f25e34d@objectstack/spectest@objectstack/spectypecheck@objectstack/plugin-securitytest / typecheck@objectstack/linttest@objectstack/runtimetest@objectstack/verifytest@objectstack/spec check:generated(+ each derived spec gate: api-surface, authorable-surface, docs, liveness, strictness-ledger, spec-changes, upgrade-guide, migration-registry, skill-examples …)✓ All 15 generated artifacts are up to datefalse &&viascripts/ablation-replace.mjs(subject imported fromsrc, nodistin the path)6b2ec57ec014equals HEAD,git diff HEADempty, then 58 / 58.tsfiles**/*.{ts,…}block. Invariance:eslint.config.mjsenables no type-aware linting, so this diff cannot move a verdict on an untouched fileThe consumer readings are five downstream consumers of
@objectstack/spec(the PM's lead list), not the full...@objectstack/specset.packages/clihas one RLS fixture,test/migrate-meta.e2e.test.ts:117-124. It is aselectpolicy withusingand nocheck. That file isintegrationtier and is left to CI. Theunittier was NOT MEASURED when this PR opened: its dependency closure was still waiting on the shared verify lock. If that reading is taken, it is in theos-dev-reportcomment on the card.packages/qa/dogfoodcensus: 0 hits. It is left to the Dogfood Regression Gate.dispatch-gates --ran): 115 derived, 38 run (all exit 0), 0 NOT-MEASURED, 77 unrun. The 77 unrun are left to CI as the declared narrowing.Acceptance notes
PermissionAdvancedFacets.tsx) renders acheckinput for every operation. Its client validation parses@objectstack/spec/securityPermissionSetSchema, so after the next objectui spec bump the refusal also shows in the editor. Hiding the input onselect/deletewould be polish. Owner: none.superRefine). Thecheckdescription does now state it..changeset/19953-rls-check-default-composition-text.md(unreleased) still reads "Acheckon aselectordeletepolicy is never evaluated". It will ship in the same train as this refusal. It was left untouched because it is another PR's changeset.usingdocblock. It says "For SELECT/UPDATE/DELETE operations, USING is required". With this change that holds forselect/delete(checkis refused and one predicate is required). It still does not hold forupdate, where a check-only policy is legal and enforced. This is prose drift, not addressed here.Generated by Claude Code