Skip to content

feat(spec)!: refuse a check on a select / delete row-level security policy - #20167

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19965-rls-check-select-delete-refused
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19965-rls-check-select-delete-refused

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19965

Clause-②: no

What

RowLevelSecurityPolicySchema now refuses a non-blank check on a policy whose operation is select or delete. The refusal is one custom issue at the check path. Its message names the operation and gives both rewrites:

check is never evaluated on a delete policy: it validates the new row an insert or an update writes, and a delete writes none. Remove check from this policy. To limit which rows the policy lets a caller delete, write that predicate as using; to validate rows as they are written, declare the check on a policy whose operation is insert, update or all.

  • The check docblock and .describe() sentence "A check on a select or delete policy is never evaluated" now describe the refusal. The generated reference pages (content/docs/references/security/{rls,permission}.mdx) were regenerated with gen:docs, not edited by hand.
  • ADR-0087: registered as the D3 semantic entry rls-check-on-select-or-delete-policy-refused under 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 is minor for @objectstack/spec, carries a **BREAKING** banner and a FROM → TO table, and has the registered disposition marker.
  • A blank check (empty or whitespace only) is not refused. The runtime reads a blank clause as absent (policyDeclaresClause), and the Studio editor leaves check: '' behind when an author clears the field.
  • Governing text: ADR-0049 (declared ≠ enforced ⇒ enforce or remove), NORTH-STAR priority rule 4, and the stage posture of no transition window. ⛔ No lint transition. packages/lint/src/validate-rls-predicate-enforceability.ts was not touched.

Premise, re-measured on origin/main 8d1f7ab

  • H1 (runtime evaluates check only on insert/update): holds.
    • function writeCheckPolicies( is at security-plugin.ts:889.
    • Its only caller, computeWriteCheckFilter, is reached from step 3.6, which is guarded by opCtx.operation === 'insert' || opCtx.operation === 'update' (:3111-3112).
    • The policy set it gets comes from getApplicablePolicies (rls-compiler.ts:733-775), which admits policy.operation === rlsOp or all and nothing else.
    • A repo read of every check reader in plugin-security, runtime, objectql, verify and lint found only rls-compiler.ts:536 (the clause: 'check' compile) and security-plugin.ts:893. Both sit behind that gate.
  • H2 (no shipped or authored policy carries it): holds for shipped and authored metadata. Two test fixtures carried it.
    • Census over objectstack packages/ examples/ skills/ content/ apps/ scripts/ docs/ .changeset/ at 8d1f7ab: 9332 files, 264 operation: select|delete literals, 2 with a sibling check. 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 now operation: 'all', where both clauses are read.
      • packages/plugins/plugin-security/src/rls-reserved-membership-keys.test.ts:189 (a compileFilter check-face fixture; compileFilter ignores operation) is now operation: 'insert'.
    • A second scan for a non-literal operation beside check found 1 candidate. It is a false positive: a .find predicate in default-permission-sets.test.ts:127.
    • objectui at the pin f8a9d0fb05: 8314 files, 17 occurrences, 0 with check. objectui at origin/main 5c61e52: 8798 files, 17 occurrences, 0 with check.
  • H3 (every authoring door reaches the refinement): holds by construction. One door is proven by a pin.
    • PermissionSetSchema.rowLevelSecurity is z.array(RowLevelSecurityPolicySchema) (permission.zod.ts:920), and the stack permissions is z.array(PermissionSetSchema) (stack.zod.ts:461).
    • The metadata permission type validates against PermissionSetSchema (metadata-type-schemas.ts:164), and os validate parses ObjectStackDefinitionSchema (validate.ts:283).
    • Pinned: defineStack refuses with code: 'STACK_SCHEMA_INVALID', status: 422 and exactly one issue at permissions.0.rowLevelSecurity.0.check. The same stack without the check is accepted.
    • os validate and a live metadata save were not measured by a run (reach established by reading the code only).
  • H4 (the prescription must be actionable): measured.
    • A check-only select / delete policy gets exactly this one issue, not the "At least one of using or check" issue.
    • The rewrite it prescribes (the predicate as using) parses. Both are pinned.
  • H5 (ADR-0087 owed): registered as described above. check-adr-0087-registration passes 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 + check and delete + check are refused.
  • A check-only policy gets this refusal and not the "at least one" refusal.
  • The prescribed rewrite parses.
  • A blank check is left to the existing rule.
  • insert / update / all with check parse.
  • select with using parses.
  • The refusal reaches the permission set (rowLevelSecurity[1].check, sole issue) and the defineStack door (envelope code + status).

Evidence, at f25e34d

reading result
@objectstack/spec test 540 files, 15809 passed, 2 todo
@objectstack/spec typecheck exit 0
@objectstack/plugin-security test / typecheck 135 files, 2697 passed / exit 0
@objectstack/lint test 108 files, 4144 passed
@objectstack/runtime test 279 files, 3906 passed, 1 skipped
@objectstack/verify test 15 files, 116 passed
@objectstack/spec check:generated (+ each derived spec gate: api-surface, authorable-surface, docs, liveness, strictness-ledger, spec-changes, upgrade-guide, migration-registry, skill-examples …) 22 of 22 exit 0; ✓ All 15 generated artifacts are up to date
ablation: refusal gate replaced with false && via scripts/ablation-replace.mjs (subject imported from src, no dist in the path) 6 failed / 52 passed, exactly the six refusal and reach pins; restored blob 6b2ec57ec014 equals HEAD, git diff HEAD empty, then 58 / 58
eslint, narrowed to the 5 changed .ts files 5 files linted, 0 errors, 0 warnings. Population is the config's **/*.{ts,…} block. Invariance: eslint.config.mjs enables no type-aware linting, so this diff cannot move a verdict on an untouched file

The consumer readings are five downstream consumers of @objectstack/spec (the PM's lead list), not the full ...@objectstack/spec set.

  • packages/cli has one RLS fixture, test/migrate-meta.e2e.test.ts:117-124. It is a select policy with using and no check. That file is integration tier and is left to CI. The unit tier 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 the os-dev-report comment on the card.
  • packages/qa/dogfood census: 0 hits. It is left to the Dogfood Regression Gate.
  • Gate reconciliation (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

  • Studio editor. The Studio RLS editor (objectui PermissionAdvancedFacets.tsx) renders a check input for every operation. Its client validation parses @objectstack/spec/security PermissionSetSchema, so after the next objectui spec bump the refusal also shows in the editor. Hiding the input on select / delete would be polish. Owner: none.
  • JSON Schema. The published JSON Schema cannot express this cross-field refusal (it is a superRefine). The check description does now state it.
  • Pending changeset. .changeset/19953-rls-check-default-composition-text.md (unreleased) still reads "A check on a select or delete policy is never evaluated". It will ship in the same train as this refusal. It was left untouched because it is another PR's changeset.
  • using docblock. It says "For SELECT/UPDATE/DELETE operations, USING is required". With this change that holds for select / delete (check is refused and one predicate is required). It still does not hold for update, where a check-only policy is legal and enforced. This is prose drift, not addressed here.

Generated by Claude Code

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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/protocol/objectql/security.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))

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

  • content/docs/releases/v13.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))
  • content/docs/releases/v17/17-0.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))

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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 98 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 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 84880f92243889d3e1daf9d4926d75277c9b5a94 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d3f42c9200bae1724ac00eace414eabe67ce9180 — the merge of head f25e34d08bef76fd97d88c4d4ff90dcee4f05863 into base 84880f92243889d3e1daf9d4926d75277c9b5a94, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 82/82 CONTRACT_REVIEW_TIER
Head-sha: f25e34d08bef76fd97d88c4d4ff90dcee4f05863

① Derived judgments

  1. Refusal set — RIGHT. rls.zod.ts:430-434 refuses only operation in {select, delete} AND typeof check === 'string' && check.trim() !== '', with or without using. insert / update / all with check parse. The runtime confirms the set:
    • rls-compiler.ts:733-775 getApplicablePolicies admits policy.operation === rlsOp or 'all'.
    • The only post-image gate is security-plugin.ts:3112 (opCtx.operation === 'insert' || opCtx.operation === 'update'), feeding computeWriteCheckFilter.
    • The sole check readers are security-plugin.ts:893 and rls-compiler.ts:536. The delete branch at :1930 is an epoch bump, not a check read.
  2. Blank-check exemption — JUSTIFIED. rls-compiler.ts:385-390 policyDeclaresClause returns typeof predicate === 'string' && predicate.trim() !== '', and both runtime readers go through it. Non-blocking: the sibling rule at rls.zod.ts:503 is if (!data.using && !data.check) (truthiness), so check: ' ' alone still parses. It is identical on origin/main for using, and this PR did not introduce it.
  3. Reach — every claim TRUE.
    • defineStack: stack.zod.ts:1625 spreads STACK_DEFINITION_COLLECTIONS_SHAPE inside ObjectStackDefinitionSchema; :461 permissions: z.array(PermissionSetSchema); permission.zod.ts:920 rowLevelSecurity: z.array(RowLevelSecurityPolicySchema).
    • os validate: cli/src/commands/validate.ts:283 ObjectStackDefinitionSchema.safeParse(lowering.lowered).
    • Metadata save: metadata-protocol/src/protocol.ts:16133 resolveOverlaySchema → :705 getMetadataTypeSchema(singular) → metadata-type-schemas.ts:164 permission: PermissionSetSchema, refused at :16154 「failed spec validation」.
    • A further door: definePermissionSet parses (permission.zod.ts:985).
  4. Shipped text — no false sentence.
    • The TSDoc, .describe(), message, entry and changeset were each checked against the sources above.
    • The .describe() alone omits the blank caveat. A blank string is not a validation condition, so the sentence is not false; the TSDoc, entry and changeset state the caveat.
    • The generated rls.mdx / permission.mdx are byte-identical to the describe (3 sites, grep "Refused on a policy whose").
    • The changeset's 「refused the next time it is parsed … on its next save」 is accurate: no non-test load-path PermissionSetSchema.parse|safeParse exists (grep), and stored rows load unparsed.
  5. ADR-0087 route — RIGHT.
    • ADR-0087 :152 「Semantic changes are excluded … it goes to D3」, :198 structured TODO, and :382-386 the pre-GA chain step 「a semantic TODO when not」 lossless.
    • One file, entries/semantic/18.rls-check-on-select-or-delete-policy-refused.ts. The registry region is regenerated and id-sorted: rest-server-config-dead-keys-retired (11784), then rls-check-... (11833), then rls-predicate-array-comparand-refused (11885).
    • spec-changes.json and docs/protocol-upgrade-guide.md project no step-18 ids (0 hits for the sibling ui-action-undoable-unfulfillable-refused), so there is no stale projection.
    • The registered marker names an id new in the diff, and the gate token /\*\*BREAKING/i (check-adr-0087-registration.mjs:632) matches the banner.
  6. Old acceptance pins — NONE.
    • A repo-wide scan (ts, tsx, js, mjs, json, yaml, md, mdx; quoted keys included) finds 0 select / delete policies with a sibling check outside this PR's own tests and changeset. skills/objectstack-data/rules/security.md:85's check sits on the all policy.
    • The old describe sentence survives only in the stock .changeset/19953-...md:17.
    • The two fixture moves are as stated.
  7. CI at head: 38 check runs, all success or skipped (re-polled).

② Semver level

RIGHT.

  • The ADR-0087 amendment of 2026-09-13 reads 「Pre-GA, a metadata-facing retirement or break ships minor, carrying the **BREAKING** banner and its ADR-0087 disposition entry」, and check-changeset-no-major.mjs forbids major in the launch window. @objectstack/spec: minor plus the banner is the prescribed shape.
  • Clause-②: no is consistent with a narrowing.
  • Check Changeset and Lint & Repo Gates are green.

③ Boundary flags

Implemented-by: claude/issue-19965-rls-check-select-delete-refused
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 05:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit b276d44 Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19965-rls-check-select-delete-refused branch September 27, 2026 05:29
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
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/m tests tooling

Projects

None yet

2 participants