Skip to content

fix(plugin-security): a USING-only RLS policy holds INSERT and UPDATE rows to its using - #19952

Merged
huangyiirene merged 9 commits into
mainfrom
claude/issue-19942-rls-check-defaults-to-using
Sep 24, 2026
Merged

huangyiirene merged 9 commits into
mainfrom
claude/issue-19942-rls-check-defaults-to-using

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Closes #19942
Clause-②: no (narrowing)

What changed

The runtime now does what the published contract says. When an applicable policy declares no check, the write post-image check (step 3.6, computeWriteCheckFilter) compiles that policy's using as its check. This applies to the INSERT row, which is judged after beforeInsert, and to the new row of a by-id UPDATE. The contract is RowLevelSecurityPolicySchema.check: "defaults to USING clause if not specified". PostgreSQL has the same rule for a policy without WITH CHECK.

Before this change, the gate compiled only policies that declared check. Measured on the repro: with using: "record.status != 'closed'", an INSERT of status = 'closed' was admitted and stored.

Changes to packages/plugins/plugin-security:

  • security-plugin.ts: a new selector, writeCheckPolicies, decides which applicable policies the post-image check compiles.
  • security-plugin.ts: step 2.7 (the by-id pre-image gate) records its ownership-floor decision (preImageFloorOpts), and step 3.6 reads it.
  • security-plugin.ts: the two early floor drops in computeLayeredRlsFilter (the public_read_write OWD and the covering controlled-by-parent master gate) now ask a single predicate, platformFloorYieldsToObjectWriteModel. The post-image check asks the same predicate. The pre-image behaviour is byte-identical.
  • rls-compiler.ts: the compileFilter comment ("defaulting to using when omitted") now matches a reachable call path. "Declares check" is now tested with policyDeclaresClause, the same test the selector uses.

Zero edits under packages/spec, content/docs/** and content/docs/releases/**.

How a defaulted using composes, exactly

This covers one principal's check. The delegator's check (ADR-0090 D10) and the Layer 0 tenant post-image check are still AND-ed with it, unchanged.

  1. Some applicable policy for the operation declares check. Only the declared checks take part, OR-combined. That is today's result, byte for byte. A USING-only sibling adds nothing to the check.
    • Under PostgreSQL's permissive OR, the sibling's using would be OR-ed in. That can only widen the check: a grant spelled id != null would erase every declared check beside it.
    • The narrower reading was taken. It is an open question for the seat; see the report.
  2. No applicable policy declares check. Every applicable write-class policy with a using takes part, its using compiled as its check, OR-combined.
  3. The platform ownership floor (owner_only_writes) takes part in case 2 exactly when the by-id pre-image gate kept it.
  4. Unchanged.
    • select policies never gate a write's new row.
    • A bulk update with no single id is still not post-image checked.
    • The modifyAllRecords bypass on private and platform-global objects returns before the selector runs.

Census (the triage's p0 condition), tree aeaaa44292

Added by the seat after the contract review: the census also covers two published authoring factories in packages/spec/src/security/rls.zod.ts:613-666. RLS.ownerPolicy (operation: 'all', USING-only) and RLS.allowAllPolicy (all, 1 == 1) both emit write-class USING-only policies. Their defaults see no reachable change: 1 == 1 is always true, and owner_id is stamped and guarded by step 3.5. A caller who passes a custom ownerField to RLS.ownerPolicy is now held to it on writes.

Searched: packages/**, examples/**, apps/**, the published skills/**, and the CLI and create-objectstack scaffolds. The table lists every write-class (insert / update / all) policy that declares using and no check.

Where Policy Operation using Tenant isolation? Reachable write, and what now happens
plugin-security member_default owner_only_writes (platform floor) update, org_member created_by == current_user.id no (ownership) Takes part only where the pre-image kept it (rule 3). An update that re-points created_by away from the caller is now refused.
plugin-security member_default, organization_admin sys_user_preference_self all user_id == current_user.id no (self) Yes, allowCreate / allowEdit are granted. A preference row created for another user, or moved to another user, is now refused (403). It was admitted.
plugin-security member_default, organization_admin sys_api_key_self all user_id == current_user.id no (self) The member revoke patch (#8053). A patch that also carries user_id of another user is now refused (403). It was admitted with user_id stripped later; see the dogfood change below.
plugin-security member_default sys_user_self all id == current_user.id no (self) Self edit of sys_user (whitelisted columns). The new row keeps its id, so there is no change.
plugin-security member_default, organization_admin sys_session_self, sys_two_factor_self, sys_device_code_self, sys_oauth_consent_self, sys_oauth_application_self all user_id == current_user.id no (self) Not reachable today. These are better-auth managed tables, and both sets deny create and edit at the object layer (denyWritesOnManagedObjects).
plugin-security member_default, organization_admin sys_organization_self all id == current_user.organization_id borderline, see below Not reachable today. Create and edit on sys_organization are denied at the object layer for both sets.
packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json (a fixture, not shipped) marketing_campaign_updates, marketing_campaign_member_updates update id != null no Always true, so there is no change.

No write-class USING-only policy was found in examples/app-showcase (task_own_rows and invoice_own_rows are select; invoice_owner_immutable declares check), packages/verify (a select probe), skills/objectstack-data/rules/security.md (own_records declares check; org_isolation is select) or any CLI scaffold.

Tenant-isolation verdict: the p0 condition does not fire.

  • Every shipped organization_id == current_user.organization_id policy is select: sys_member_org, sys_invitation_org and sys_team_org.
  • The tenant wall is Layer 0, and it has its own post-image write check (step 3.7). The [finding] $ne with an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 stage-1 measurement (P3) recorded a forged-organization INSERT refused 403 by it.
  • The one borderline row is sys_organization_self. It compares against current_user.organization_id, and it is in the platform's own tenant-policy provenance set (isPlatformTenantPolicy, which strips it when isolation is off). But:
    • it scopes the organization table to its own row, and the declaration's own comment says these carve-outs "are NOT tenant walls";
    • writes to that table are refused at the object layer for both sets that carry it.
  • Listed so the seat can judge it rather than find it.

Writes refused after this change

A single-row INSERT or a by-id UPDATE whose resulting row falls outside the using of every applicable write-class policy, when none of those policies declares check. The refusal is 403 PERMISSION_DENIED, the existing row-level CHECK envelope, and nothing is stored. There is no transition switch. An author who wants a write to leave a policy's scope declares a check.

In the platform defaults, this reaches the sys_user_preference and sys_api_key rows in the census, and a created_by re-point under the floor.

Tests

Final head 19236d91ad.

New file packages/plugins/plugin-security/src/rls-check-defaults-to-using.test.ts. It uses a real ObjectQL engine and SecurityPlugin and runs on driver-sql (better-sqlite3) and driver-sqlite-wasm: 22 tests, 11 cells on each driver.

  • The repro: USING-only record.status != 'closed' refuses an INSERT with status: 'closed'. The test asserts code: 'PERMISSION_DENIED' and status: 403, and that nothing is stored. It admits 'open'. The same holds for an insert-class policy.
  • The UPDATE new row: moving an in-scope row to 'closed' is refused and the stored row does not move. An in-scope update is admitted.
  • A tenant-style USING-only policy (organization_id == current_user.organization_id) refuses a cross-organization INSERT and admits one into the caller's own organization. Layer 0 is not armed in this harness, so the refusal comes from the defaulted check.
  • A declared-check control: the policy is judged on its check, not on its using, as before.
  • Composition: a USING-only sibling adds nothing to a declared check (rule 1).
  • The floor: the creator still updates their own deal out of the widener's scope, and a non-creator admitted only by the widener cannot move the deal out of it (rule 3).

Fixture triage, two existing tests:

  • security-plugin.test.ts, "owner_id is always auto-injected on insert": its tenant_isolation policy is all and USING-only. The insert payload now carries the caller's organization, which the org-scoping stamp would write before the check, and the test asserts it arrives unchanged.
  • packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts: the mixed smuggle patch is split in two. Re-owning (user_id of another user) is refused 403 PERMISSION_DENIED and changes nothing. key smuggled beside revoked is still admitted with key stripped.

Reverse verification. Each run used scripts/ablation-replace.mjs from committed state, and each restore was proven: the blob equals HEAD and git diff HEAD is empty.

Mutation Result
The using fallback in writeCheckPolicies replaced with an empty list (the pre-fix behaviour) 10 failed, 12 passed of 22. Every refusal witness failed on both drivers: the repro, the insert-class policy, the UPDATE new row, the cross-organization INSERT and the widener non-creator. All admit controls stayed green.
The floor excluded unconditionally 3 failed: the creator cell on both drivers, plus authored-row-write-verdict.test.ts "TRANSFERRED_UPDATE_IS_ADMITTED_BY_THE_COMPOSED_PATH".
The floor kept unconditionally (the pre-image drop ignored) 8 failed across the whole plugin-security suite: 6 in controlled-by-parent-detail-write-authority.test.ts (#8757 / #8865) and 2 in row-write-widener-composition.test.ts (#5492).

Suites and gates:

  • pnpm --filter @objectstack/plugin-security test: 121 files, 2314 tests passed.
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0, and check:test-typecheck OK.
  • @objectstack/organizations test: 108 passed. @objectstack/plugin-sharing test: 913 passed.
  • @objectstack/dogfood, full run before the dogfood fix: 1 failed (the case above), 1102 passed. After the fix, that file passes 9 of 9.
  • eslint on the 5 touched TypeScript files: 0 errors and 0 warnings, read from --format json. The config is not type-aware (no parserOptions.project), so this narrowing leaves no untouched file's verdict unmeasured. The full pnpm lint is left to CI.
  • node scripts/pm/dispatch-gates.mjs --commands: 66 derived commands. 64 exited 0. 2 exited 3, which is PREREQUISITE NOT MET and so NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt need a whole-workspace build.

Acceptance notes


Generated by Claude Code

…t-image

When no applicable policy declares `check`, the write check compiles each
applicable policy's `using` as its check, matching the published
RowLevelSecurityPolicySchema.check default. Declared-check composition is
unchanged; the platform ownership floor stays a pre-image construct.

Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
…e pre-image kept

Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 6 documentable anchor(s).

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

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/permissions-matrix.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • content/docs/permissions/sharing-rules.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

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

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • 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 — 15 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 c1641868a3da71537c9c6c572d2bd2044103236b → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json c1641868a3da71537c9c6c572d2bd2044103236b

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

…or, BREAKING, ADR-0087 disposition)

Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 62af5581f92a7ef0e365357276ddfe358689f7f8

The review ran in two rounds, by an isolated reviewer at the served tier. The reviewer saw only card #19942 (triage 5806292861, claim 5806865969, os-dev report 5807642793), this PR, and the repository rules. The domain:services seat adopts the verdict.

  • Round 1, at 19236d91ad: FAIL. One blocking defect: the change was declared Clause-②: no with a patch changeset, but it narrows what the write gate accepts.
  • Round 2, at this head: the delta reviewed is 19236d91ad..62af5581f9, which is changeset-only, plus the PR body the seat edited.

① Derived judgments

  • Composition (writeCheckPolicies):
    • If any applicable policy declares check, only the declared checks decide, byte-identical to today.
    • Otherwise, each applicable write-class policy's using becomes its check, and they are OR-combined.
    • The ownership floor is tied to the decision step 2.7 records.
    • The runtime implements ADR-0058 D4's 「defaulting to using when omitted」.
  • Newly admitted writes: none. The only change turns a null check into a filter, which can refuse and never admits.
  • Newly refused writes, all stated in the changeset:
  • Census: no shipped tenant-isolation policy is USING-only on a write class. Every _org policy is select, and sys_organization is managed by better-auth. The published factories RLS.ownerPolicy / RLS.allowAllPolicy (rls.zod.ts:613-666) are named in the PR census; their defaults have no reachable change. Triage's p0 condition does not fire.
  • Mixed-case divergence: a USING-only sibling beside a declared check contributes nothing. That diverges from the per-policy wording in the describe, the TSDoc, rls.mdx, permission.mdx and ADR-0058 D4. It is pre-existing, fail-closed and accepted for landing. It is carried by spec(security): RLS check defaults to using per policy in the contract, but the write check ignores a USING-only policy when a sibling declares check — state the composition, or rule OR semantics #19953, together with the stale validate-rls-predicate-enforceability.ts:32-33 header.

② Semver level

minor plus fix(plugin-security)!: and a BREAKING banner. The changeset and the PR body both carry Clause-②: no (narrowing), and there is exactly one ADR-0087 marker, not-required (no-migration-prescription). This matches the precedent a016f08 (#16805), clause2-line.mjs, AGENTS.md:1084-1096, and check-changeset-no-major.mjs:47-75.

Gates at this head:

  • check-adr-0087-registration ✓ [BREAKING+bang+clause-②-narrowing]
  • check-changeset-no-major ✓ no major. The level axis reads the narrowing arm from the PR body.

③ Boundary flags

The PR touches six files: the changeset, 4 files under packages/plugins/plugin-security/src/, and one dogfood test (a fixture that had to change). Zero packages/spec, zero content/docs/**, zero governed surface. The scope matches triage.

Implemented-by: claude/issue-19942-rls-check-defaults-to-using
Reviewed-by: session_01AhQASwqJr2Z7XfGWUdvnbF

VERDICT: PASS


Generated by Claude Code

…aster-gate ruling; pin the post-image own-row read

Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
…ing, not an unresolvable number

Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 62d12779800c9baa147fc5e3367fffca38b0aac3

What this record covers: this head, 62d1277980. The earlier record for 62af5581f9 covers the full PR. This round reviews only the delta 62af5581f9..62d1277980, which fixes the two CI reds at that head. The same isolated served-tier reviewer did the review, and the domain:services seat adopts the verdict.

① Derived judgments

  • security-plugin.ts is comment and placement only, and this was proven. With block and line comments stripped, the two blobs are byte-identical. The [ADR-0095 D1] docblock is back above computeRlsFilter. The helper now cites commit 6feac910b6 (which exists, dated 2026-08-15) instead of #8757, which is allocated-but-absent (404). The prose says so. The full diff adds no #8757 citation.
  • The plugin-auth pins are tightened, not weakened. Every touched pin still asserts exact values. Pin 1 keeps [0] = { $and: [{ id: ME }, { id: ME }] } unchanged and adds [1] = { id: ME }. The org-peer absence assertions now hold over both reads. The guard pin matches the full array exactly.
  • The second read cannot widen anything. It is step 3.6's memoized getCallerPreImage → readRowById, a bare { id } read under the caller's context (security-plugin.ts:3087, :5991, :6009-6015). It runs only after step 2.7 has admitted the row. Its only consumer is satisfiesCheck(postImage), which can refuse and never admits. It exists because sys_user_self (all, USING-only) now gets the defaulted check.
  • PIN 2 (the peer row) is untouched and is still refused at the row scope with a single read. The insert pin is still [].
  • The changeset needs no change. The own-row update is still admitted, and the refused-writes list is complete. The changeset blob is unchanged, so the ADR-0087 and no-major verdicts from the previous head carry.

② Semver level

Unchanged and correct: minor, fix(plugin-security)!:, a BREAKING banner, and Clause-②: no (narrowing) in both the PR body and the changeset. There is exactly one ADR-0087 marker, not-required (no-migration-prescription).

③ Boundary flags

The full diff is 7 files: the changeset, 4 plugin-security source files, the dogfood test, and the plugin-auth test. It touches zero packages/spec, zero content/docs/** and zero governed surface. The plugin-auth pin update is fixture triage forced by the defaulted check. The mixed-case composition divergence and the stale lint header are carried by #19953.

Implemented-by: claude/issue-19942-rls-check-defaults-to-using
Reviewed-by: session_01AhQASwqJr2Z7XfGWUdvnbF

VERDICT: PASS


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 24, 2026 05:24
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit b7c792b Sep 24, 2026
37 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-19942-rls-check-defaults-to-using branch September 24, 2026 05:45
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… of an array insert and a predicate update (objectstack-ai#19988)

Fixes objectstack-ai#19950
Fixes objectstack-ai#19964
Clause-②: no (narrowing)

## What this fixes

A row-level security `check` (declared on the policy, or defaulted from
its `using`) is the write-side half of the policy: a row the check
refuses is never stored (ADR-0058 D4: "on the write pre-image path that
already exists for by-id writes … and on the AST-injected bulk path").
The write gate enforced it for a single-row insert and a by-id update,
and not for the two multi-row write shapes:

- **objectstack-ai#19964, array insert.** Step 3.6 excluded an array payload, so no
judgement was installed and every row was stored unjudged, including
under configurations that refuse every single-row insert.
- **objectstack-ai#19950, predicate update** (`multi: true`, no row address). Step 3.6
skipped the new-row check and logged "governed by the using-scoped
where". A policy that declares only `check` scopes nothing, and a scoped
`where` says nothing about the new row in any case. The skip was
unconditional, so it also covered a declared `check` that differs from
`using` and a `check` defaulted from `using`.

Both shapes are now judged row by row with the existing refusal
(`PERMISSION_DENIED` / 403, nothing stored). One failing row refuses the
whole write. The judgement is the existing `satisfiesCheck` over
`matchesFilterCondition`: no predicate is compiled differently, and
nothing is lowered into the `where`, so no compile surface moves.

## Landing: two packages, and why the engine is one of them

- `packages/plugins/plugin-security/src/security-plugin.ts`, step 3.6
plus the seam declaration and the post-`next()` fail-closed guard
(generalised from "the insert" to the operation). The by-id update
branch is unchanged. The `explainAccessForCaller` wiring is not touched.
- `packages/objectql/src/engine.ts`, the predicate-update branch of
`update()` (`domain:engine`), plus the seam's doc comments. **Why the
producer is the engine (measured, not assumed):** the rows a predicate
update changes are the rows the middleware-COMPOSED AST selects. That
AST is complete only after every middleware has run: step 3 of this
middleware composes its own scope after step 3.6, and plugin-sharing
composes its editable-rows filter onto the same AST
(`sharing-plugin.ts:1405`), in plugin order. The suggested route,
reading "under the caller's context" in the middleware, was measured two
ways and does not hold:
- A caller-context read applies READ scope and field masking. Where the
read scope is narrower than the write scope, written rows go unjudged
(fail-open). Where it is wider, rows that will not be written get judged
(false refusals). Ablation 3 below shows the second: a judgement that is
not over the actual matched rows falsely refuses the USING-only in-scope
control.
- The engine already holds the exact set: the D7 matched-row read
(`readPriorRows`, bound to the composed AST), which the ruling says is
read once and reused, and which already serves validation, the
`readonlyWhen` strip and both per-row hook phases.
  
So the security layer installs its judgement on the existing
`OperationContext.postHookWriteImageCheck` seam (the one objectstack-ai#19952 built
for inserts), and the engine calls it on the predicate branch. It hands
over every matched row merged with the payload (the same shape as the
per-row `afterUpdate` `result`), placed after `assertNoStrictDrops()`,
where the payload is final. That placement follows the insert seam's
contract review: "the row the seam judges must be the row that is
stored". The readonly strips run earlier on this branch, so a pre-strip
placement would judge values that never land.

## Mechanism hypotheses (dispatch Section 2), as measured on
`2c1011b01b`

1. **Held.** Line 3000 carried `!Array.isArray(opCtx.data)`. Lines
3078-3083 set `postImage = null` for `extractSingleId(opCtx) == null`
and logged "governed by the using-scoped where".
2. **Held.** `engine.ts` (the `postHookWriteImageCheck` call in
`insert()`) hands `evaluate` every live row of an array insert. The
array fix is plugin-side only.
3. **Refined.** The memoized `getCallerPreImage` is by-id and
caller-context, so it is not reusable per row for the reasons above. The
engine's memo serves instead, at no extra read wherever per-row hooks
already read it.
4. **Held.** The skip was unconditional. A cell pins a `using` plus a
differing `check`.

One further hole, found and closed: the middleware treats a falsy scalar
id (`''`, `0`) as a row address, and the engine does not
(`resolveEngineUpdateDispatch`). A falsy payload id therefore carried a
bulk update past the per-row judgement, admitted on a change-set-only
image. The seam is now installed whenever the engine will not treat the
write as addressing one row. The falsy case keeps its by-id judgement
too, so it only refuses more.

## Surface beyond the claim, with reasons

- `packages/objectql/src/engine.ts`: see above (cross-lane,
`domain:engine`).
- Existing plugin-security tests: `check-only-write-scope.test.ts` and
`security-plugin.test.ts` carry engine doubles that must now honour the
seam on a predicate update, the way the real engine does. Otherwise the
fail-closed guard refuses them, which is the intended behaviour. One
test title and comment said step 3.6 "declines to check" the bulk path;
it now says 3.6 can refuse a bulk write but never scope one. That pin
still discriminates a site-1 revert, now by refusal. Two comment-only
edits (`rls-check-defaults-to-using.test.ts`,
`rls-phantom-column-negation.test.ts`) stated the array exclusion as a
fact.
- **A pending release note, corrected in place:
`.changeset/rls-check-defaults-to-using.md`** (from objectstack-ai#19952, not yet
released). Its "What does not change" list said bulk updates "are not
checked row by row", which this PR makes false. The bullet now says
their new rows are checked row by row too, by this PR's entry.
`check-empty-changeset` is RED on this by design: it is the DELIBERATE
CORRECTION class (ruling D on objectstack-ai#17712). Its prescribed remedy is to keep
the correction and get it confirmed on the PR; restoring the file would
publish a false sentence. Under the landing rule objectstack-ai#19970 set, 「DELIBERATE
CORRECTION 红:同 head 达档复核 PASS 记录即确认,⛔ 不等维护者」, the confirmation is a
same-head at-tier review record with a PASS verdict, not the maintainer.
The red is expected until that record is on the PR for the landing head.

## Behaviour that changes (all in the refusing direction)

- A predicate update under a check-only policy is refused when any
matched row's new image fails the check.
- A predicate update that moves a matched row out of a policy's `using`,
when no applicable policy declares `check`, is refused: the defaulted
check, which is the answer the by-id update has given since objectstack-ai#19952.
Triage's "已声明 `using` 的策略,行为保持不变" is held as: in-scope bulk updates
under a `using` policy are admitted and scoped exactly as before
(pinned). Only a bulk write that moves rows out of the `using` is newly
refused, on the same terms as by-id. The seat confirmed this reading:
by-id and bulk are two implementations of one operation and must not
disagree (`RowLevelSecurityPolicySchema.check` 「defaults to USING clause
if not specified」; ADR-0058 D4).
- An array insert is refused when any row fails, including every
configuration that already refused each single insert.
- A host that installs the judgement on a predicate update and never
runs it is refused (403, `error` log), as an insert already is.

## Tests

**Patch round 2 (head `3247efeecd`, after merging `origin/main`
`9bfbacbf8b` as merge commit `938b2acfde`):**
`rls-check-multi-row-writes.test.ts` 32 passed (32); plugin-security
suite 123 files, 2348 tests passed; objectql re-run because objectstack-ai#19979
touched that package: local project 155 files / 2434 tests + 154 files /
2758 tests, repo project 1 file / 5 tests; `typecheck` green for
objectql and plugin-security (VERDICT command-exit 0 each).

**Patch round 1 (head `9fff66cfdc`, after merging `origin/main`
`3fd3a4f91b` as merge commit `a5ca1db166`):**
`rls-check-multi-row-writes.test.ts` 32 passed (32); plugin-security
suite 123 files, 2348 tests passed (VERDICT command-exit 0 each). The
merge brought no change under `packages/objectql` or
`packages/plugins/plugin-security`, so the objectql suite was not
re-run; its last run is the one below, on a byte-identical `engine.ts`.

**Round 1 (head `6d28dfe98d`):**


New:
`packages/plugins/plugin-security/src/rls-check-multi-row-writes.test.ts`,
32 cells on driver-sql (better-sqlite3) and driver-sqlite-wasm, real
`SecurityPlugin` + `ObjectQL`.

- Failing first, on the unmodified tree: 18 red and 12 green (the
controls). Every negative cell failed as "admitted" (`expected true to
be false`). In the unresolvable-policy cells the single-insert leg was
refused and only the array leg was admitted.
- objectstack-ai#19950 cells: the check-only repro; per row not per change set (a
matched row failing on an unchanged field refuses the whole write);
`using` plus a differing `check`; fail-closed (an inner middleware
strips the seam, and the write is refused with "the update on
'qa_ticket' was executed without the row-level CHECK being evaluated");
falsy payload id. Controls: over-fix admit, USING-only in-scope admit
and scoped, `using`+`check` in-scope admit, by-id unchanged, and
USING-only move-out refused on both bulk and by-id.
- objectstack-ai#19964 cells: the `[admitted, refused]` repro; over-fix admit; single
insert unchanged; three refuse-every-insert configurations (an
unresolvable sole `using` on `insert` and on `all`, an unresolvable
declared `check`).
- Every refusal asserts `code` `PERMISSION_DENIED`, `status` 403 and the
developer half naming the gate and verb, then reads the stored rows back
under a system context.

Suites: plugin-security 123 files, 2348 tests pass. objectql 309 files,
5182 tests pass (local project in two halves, plus the repo project).
`typecheck` is green for both packages (plugin-security test-layer debt:
0 files, 0 errors).

Ablations: each committed first, mutated through
`scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed),
restored with blob equal to HEAD and an empty `git diff HEAD`.
Resolution path: the plugin is imported relatively, and
`@objectstack/objectql` is aliased to `src/index.ts` in this package's
`vitest.config.ts`, so no `dist/` sits between the mutation and the
test.

| # | mutation | result |
|---|---|---|
| 1 | restore the non-array guard for inserts | 8 red: the 4
array-insert negative cells x 2 drivers |
| 2 | never install the seam on a predicate update | 12 red: the 6 bulk
negative cells x 2 |
| 3 | engine judges the payload alone, not the matched rows | 6 red: the
per-row cell, the falsy-id cell, and the USING-only in-scope control
(falsely refused) |
| 4 | install only when the id is null (falsy counts as by-id) | 2 red:
the falsy-id cell, admitted |
| 5 | engine never calls the seam | 16 red: refusals now carry the
not-evaluated message, controls refused |
| 6 | disable the post-`next()` fail-closed guard | 2 red: the
fail-closed cell, admitted |

## Gates

**Patch round 2 (head `3247efeecd`):** the four comment lines this
change rewrote now cite the surviving record, commit `a016f08b8a` (the
insert-side check), instead of a card that answers 404, and say in words
that the original card no longer resolves. `GITHUB_TOKEN="$GH_TOKEN"
node scripts/check-issue-citations.mjs` probes the board and exits 0:
"every citation this change adds resolves (or is a declared cross-repo
reference)", with 9 judged, 9 resolving and 0 unresolved added.
`dispatch-gates --commands` derived the same 68 families from the same 9
paths against merge base `9bfbacbf8`. All 68 were run; `--ran` answers
"68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". 67 exit 0.
`check-empty-changeset` exits 1 on
`.changeset/rls-check-defaults-to-using.md` only.

**Patch round 1 (head `9fff66cfdc`):** `dispatch-gates --commands`
derived the same 68 families from the same 9 paths, now against merge
base `3fd3a4f91`. All 68 were run; `--ran` answers "68 derived, 68 run,
0 NOT-MEASURED, 0 UNRUN". 67 exit 0. `check-empty-changeset` exits 1 on
`.changeset/rls-check-defaults-to-using.md` only (the deliberate
correction under "Surface beyond the claim"). The changeset gates the
seat named: `check-adr-0087-registration --base origin/main` exits 0 ("1
declared-breaking changeset(s), each carrying an ADR-0087 disposition"),
`check-changeset-no-major --base origin/main` exits 0, and `pnpm
check:changeset-gate-self-tests` exits 0.

**Round 1 (head `6d28dfe98d`):**


- `node scripts/pm/dispatch-gates.mjs --commands` derived 68 families
from the 9 changed paths. All 68 were run with exit codes recorded.
`--ran` answers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN".
- 67 exit 0. One exits 1: `check-empty-changeset`, the deliberate
correction above.
- Three first answered `PREREQUISITE NOT MET` (exit 3):
`check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`.
They pass after the workspace closure build. `check-engine-split-ratio`
passes after deepening the shallow clone to its window.
- Lint, as a proven narrowing: `eslint --no-inline-config --format json`
over the 7 changed `.ts` files reports 7 files linted, 0 errors and 0
warnings. All 7 fall in the config's `**/*.{ts,…}` block. The config
never enables type-aware linting (`eslint.config.mjs:326-328`), so this
diff cannot move a verdict on an untouched file. The full `pnpm lint` is
CI's.

## Acceptance notes

- **Refusal cost on the predicate path.** The judgement runs where the
payload is final, after the per-row `beforeUpdate` hooks and after the
credential channel (`encryptSecretFields`), which runs above the strips
on this branch. A refused bulk update whose payload carries a `secret`
field has therefore already minted its `sys_secret` row. A validation
refusal two lines below pays the same cost today. Moving the credential
channel below the strips is a separate engine change. A `check` naming a
secret field judges the stored reference.
- **Image timing differs between the two update paths.** A predicate
update is judged on the post-hook image; a by-id update is still judged
in the middleware on the pre-hook change set merged with the
caller-visible pre-image. Where a `beforeUpdate` hook rewrites a checked
field, the bulk path is the stricter of the two. The by-id path is
untouched here.
- **Read cost.** On an object with no per-row hooks, a checked predicate
update now reads its matched rows. The read is unbounded by the per-row
hook ceiling, which applies only when hooks dispatch. On a kernel with
the usual global hooks the read already happens and is shared.
- **Partial-row array insert** (`__partialRowErrors`): a failing row
refuses the whole call rather than being reported per row.
- **Version skew.** A plugin-security built from this change, run over
an engine without the predicate-path call, refuses checked bulk updates
(fail-closed). Both packages carry the changeset.
- `.changeset/19950-rls-check-multi-row-writes.md` is declared a
narrowing, per the seat's ruling and following objectstack-ai#19952: `minor` for
`@objectstack/plugin-security` and `@objectstack/objectql`, a `!`
headline, `Clause-②: no (narrowing)`, an ADR-0087 `not-required
(no-migration-prescription)` disposition, and a **BREAKING** paragraph
listing the newly refused writes and the remedy (declare `check` on the
policy, or fix the data).
- `origin/main` was merged twice with merge commits, both clean with no
regeneration owed: at `3fd3a4f91b` (`a5ca1db166`) and at `9bfbacbf8b`
(`938b2acfde`). PR objectstack-ai#19984 (objectstack-ai#19963, the explain wiring in the same file)
had not landed by the second merge.
- Patch round 2: citation fix only (four comment lines in
`security-plugin.ts`); no behaviour change.

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… applicable policies, as the runtime applies it (objectstack-ai#19962)

Fixes objectstack-ai#19953
Clause-②: no

Contract text only; no schema shape, accepted value or runtime behaviour
changes (triage direction A, `5808181305`). The published
`RowLevelSecurityPolicySchema.check` said it "defaults to USING clause
if not specified", which reads per policy. The write gate
(`writeCheckPolicies`, since objectstack-ai#19952) decides the default once per write
operation across the applicable policies. The texts now say so. They
also say 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 (objectstack-ai#19964, objectstack-ai#19950). Round 2 made that change after the
review FAIL `5813145732`, and limited the old 「OR-combine (most
permissive wins)」 wording to reads.

- `packages/spec/src/security/rls.zod.ts`: the `check` describe and
TSDoc. The two reference pages under `content/docs/references/security/`
are regenerated from the describe.
- `content/docs/permissions/rls.mdx`: two sentences.
- `packages/lint/src/validate-rls-predicate-enforceability.ts`
(`domain:devx`, declared on the claim): the header, and the consequence
texts for `using` and `check`.
- Changeset: `@objectstack/spec` and `@objectstack/lint` at `patch`.

The dev measured the runtime composition before writing; the table is in
report `5812757565`. The ADR-0058 D4 clarifying note was reverted out of
this PR (seat ruling `5812826208`), because `docs/adr/**` is governed;
it stays owed separately. Filed from the measurement: objectstack-ai#19964 (an array
insert runs no row-level check) and objectstack-ai#19965 (a `check` on a `select` /
`delete` policy is never evaluated).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1

---------

Co-authored-by: Claude <noreply@anthropic.com>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants