Skip to content

fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update - #19988

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19950-rls-check-multi-row-writes
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19950-rls-check-multi-row-writes

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #19950
Fixes #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:

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 fix(plugin-security): a USING-only RLS policy holds INSERT and UPDATE rows to its using #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 fix(plugin-security): a USING-only RLS policy holds INSERT and UPDATE rows to its using #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 finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #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 pm-dispatch: three landings the seat decides on its own record (ruling 1A, 2A, 3A) #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 fix(plugin-security): a USING-only RLS policy holds INSERT and UPDATE rows to its using #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 #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.

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 fix(plugin-security): a USING-only RLS policy holds INSERT and UPDATE rows to its using #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 fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984 (plugin-security: the record-grained explain verdict for update is not computed with the write path's inputs — record.visible is false on rows the by-id PATCH admits, so every consumer hides Edit from permitted users #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.

…row-level check

A row-level `check` (declared, or defaulted from `using`) guarantees
that no stored row fails it. The write gate installed that judgement
for a single-row insert only: an array payload was excluded, so every
row of an array insert was stored unjudged, including under policies
that refuse every single-row insert.

The gate now installs the same judgement for an array insert. The
engine already hands it every live row after the `beforeInsert` chain,
so each row is judged on the image that will be stored, and one
failing row refuses the whole insert with the existing
PERMISSION_DENIED / 403 refusal. A single-row insert is unchanged.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…ores against the row-level check

A row-level `check` (declared, or defaulted from `using`) guarantees
that no stored row fails it. A predicate update (`multi: true`, no row
address) was never judged: the write gate skipped it on the assumption
that a `using`-scoped `where` governed it. A policy that declares only
`check` scopes nothing, and a scoped `where` says nothing about the new
row, so the guarantee did not hold on that path for any policy.

The rows such an update changes are the ones the middleware-composed
query selects, which is complete only once every middleware has run. So
the security layer installs its judgement on the existing
`OperationContext.postHookWriteImageCheck` seam, and the engine runs it
on the predicate path once the payload is final. It hands the seam
every matched row merged with that payload, read by the one matched-row
read the path already makes. One failing row refuses the whole update
with the existing PERMISSION_DENIED / 403 refusal. A seam the engine
never runs fails closed, as it does for an insert. A falsy payload id,
which the engine does not treat as a row address, is judged both ways.

The by-id update and the single-row insert are unchanged. The pending
release note that said bulk updates are not checked row by row is
corrected in place.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 24, 2026
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-flow.mdx (via OperationContext (symbol, a top-level interface))
  • content/docs/permissions/system-context.mdx (via OperationContext (symbol, a top-level interface))
What this run could not see

Coarse fallback — 27 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 cdc1ae03dd277edac2742a6dfe4eb2bf61afefc5 → packageMentionDocs.

Which tree this was computed on

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

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

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

The change refuses bulk updates and array inserts the write gate used
to admit, so its release note is re-declared the way the using-defaulted
check's was: `minor` for both packages, a `!` headline, the
`Clause-②: no (narrowing)` line, an ADR-0087 not-required disposition,
and a BREAKING paragraph listing the newly refused writes and the
remedy.

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

Copy link
Copy Markdown
Contributor Author

CI red on head 9fff66cfdc: two rows, two causes · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-24T15:21Z

  1. Lint & Repo Gates, this PR's to fix. Step 189, 「Issue citations this change adds resolve on the board」, flags #16608 as allocated-but-absent on four lines this diff rewrote in security-plugin.ts. The seat measured that #16608 and its PR #16805 both answer 404; the surviving record is merge commit a016f08b8a. Patch round 2 has gone to the dev: keep the number, add prose that it no longer resolves, and name a016f08b8a (the gate's own remedy, with no guessed replacement). Two steps behind it never ran, and are NOT MEASURED until the re-run.
  2. Check Changeset, red by design. It is the DELIBERATE CORRECTION of the pending .changeset/rls-check-defaults-to-using.md. Under pm-dispatch: three landings the seat decides on its own record (ruling 1A, 2A, 3A) #19970 it is confirmed by a same-head at-tier review record; that record will be taken on the final head, after patch round 2.

Generated by Claude Code

…onger resolve

The four comment lines this change rewrote cite an issue that answers
404 on the board. Each keeps the number and now says so, naming the
live record: commit a016f08 on main.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
… of a card that no longer resolves

The four comment lines this change rewrote named a card that answers
404 on the board. They now name the surviving record, commit a016f08
(the insert-side check), and say in words that the original card no
longer resolves. Unchanged lines elsewhere in the file are left as
they are.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3247efeecd

Rendered by an isolated at-tier reviewer and adopted by the domain:services seat (session_01Evb5jFDZGKQE9KG4jbMfMF). Under the DELIBERATE CORRECTION landing rule (#19970), this record is also the confirmation of the foreign-note edit: it names the note and judges each rewritten sentence.

① Derived judgments

(a) Accept-set changes. Each is a narrowing only, and nothing previously refused is admitted.

  • Array insert. The step-3.6 guard now admits an array for insert, so the seam is installed. ObjectQL.insert already hands the seam every live row after beforeInsert, so the seam is always honoured. The single-object insert is byte-equivalent: the former inline judgement became newWriteImageCheck(). An array payload on update stays excluded, as on main.
  • Predicate update. Main's targetId == null branch (a warning, no judgement) now installs the seam whenever !targetId. The by-id branch keeps its three statements unchanged. A falsy scalar id is judged by-id as before and also gets the seam. The one shape the engine still routes by-id with a falsy payload id is already refused on main at step 2.7.
  • The floor on the predicate path. It takes part on the same terms as step 3's bulk where-scope, so matched rows already satisfy it.
  • OperationContext.postHookWriteImageCheck, a published @objectstack/objectql type. Its shape and export are unchanged; only the TSDoc moves. At runtime, update()'s predicate branch sets honoured = true after assertNoStrictDrops(), then evaluates each row from the D7 readPriorRows memo merged with the exact payload driver.updateMany receives. Zero matched rows evaluates [].
  • The post-next() fail-closed guard is verb-neutral: unchanged for inserts, and new for predicate updates only against a host that bypasses the engine.

(b) Edited foreign note: .changeset/rls-check-defaults-to-using.md (#19952, b7c792bd2e, pending). One bullet was rewritten.

(c) No other sentence is false on this head, in either the foreign note or this PR's own changeset. For the foreign note that covers the ownership-floor bullet (scoped to the by-id gate, and true there), 「select policies never gate」, the modifyAllRecords bypass, and the uncompilable-using bullet (now also array inserts, pinned). This PR's changeset was checked sentence by sentence, including the four newly-refused bullets, 「by-id update and single-row insert judged exactly as before」, 「a system-context write is not gated」, and the create-many route.

② Semver level

Right, and complete. Shipped bytes move in exactly two packages, and both are declared:

  • @objectstack/objectql (engine.ts) and @objectstack/plugin-security (security-plugin.ts), both minor, both published and in the fixed group. The five test files ship nothing.

An accept-set narrowing is breaking, and in the launch window breaking ships as minor (major would be refused by check-changeset-no-major). Its breaking-ness is carried by the ! headline, the BREAKING banner and the ADR-0087 disposition, the same shape #19952 used. Clause-②: no (narrowing) is truthful: no new export, no new accepted key, and the seam type's shape is unchanged.

The ADR-0087 gate sees the three breaking signals and exactly one marker. no-migration-prescription is a valid category, and the body carries no prescription shape. On CI, Check Changeset aborts at the correction refusal before its ADR-0087 and no-major steps, so those two verdicts rest on this reading of the gate code against the changeset text.

③ Boundary flags

  • Check-runs on 3247efeecd: 34 success, 5 skipped (path and opt-in skips), and 2 failure, both Check Changeset. Their log names exactly .changeset/rls-check-defaults-to-using.md as the correction class. That is by design, not a required context, and confirmed by this record. Build Core, Test Core 1–6, all four Type Check jobs, Lint & Repo Gates, Dogfood ×4, Temporal Conformance and the queue and claim guards are green.
  • The four [insert-check commit a016f08b8a] lines are TRUE. #16608 and #16805 answer 404, and a016f08b8a on origin/main is the commit that introduced postHookWriteImageCheck and the plugin's seam. The citation gate is green, a sha is outside its grammar, and there is sha-citation precedent in the same file.
  • Disclosure hygiene: clean. The public text names the defect class, the two API shapes and the remedy, with no reproduction recipe.
  • No governed surface; not a fork. Author-acknowledged residuals, none blocking:

Implemented-by: claude/issue-19950-rls-check-multi-row-writes
Reviewed-by: session_01Evb5jFDZGKQE9KG4jbMfMF

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Check Changeset is red by design on this PR, and it will be enqueued with that one row red · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-24T16:19Z

  • Which gate, and why. Check Changeset (pr-automation.yml → changeset-check, scripts/check-empty-changeset.mjs) fails on head 3247efeecd. This PR CHANGES a pending release note it did not add, .changeset/rls-check-defaults-to-using.md (fix(plugin-security): a USING-only RLS policy holds INSERT and UPDATE rows to its using #19952): its 「not checked row by row」 clause became false when this PR made bulk updates checked row by row. The job's error names exactly that file, in the DELIBERATE CORRECTION class.
  • The confirmation. The at-tier record above (same head, PASS) names the note and judges both rewritten sentences TRUE. Under pm-dispatch: three landings the seat decides on its own record (ruling 1A, 2A, 3A) #19970 that record is the confirmation.
  • The three conditions for enqueueing with a red row, all met:
    1. The gate's source states that this class stays red by design: 「this gate stays red either way, and staying red is what puts the decision in front of a person」.
    2. The job runs on pull_request only, never on merge_group.
    3. This comment names the gate and the cause.
  • Every other check on the head is green.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 16:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 009da14 Sep 24, 2026
41 of 43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19950-rls-check-multi-row-writes branch September 24, 2026 16:43
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… the caller's read depth (objectstack-ai#20000)

Fixes objectstack-ai#19986

Clause-②: no

## What changes

`POST /api/v1/security/explain` with `{ object, operation: 'read',
recordId }` now asks plugin-sharing's read filter with the same read
depth the find path hands it. `decision.record.visible` for `read`
equals whether the same caller's `find` returns the row. Enforcement
admits and refuses exactly what it did before. Only the explain report
changes.

Landing: `packages/plugins/plugin-security/src/security-plugin.ts` (the
`explainAccessForCaller` dependency wiring only), a new test file, the
changeset, and one generated row in
`scripts/engine-double-contract.pinned.json` (see "Surface note"). The
step-3.6 post-image block and the seam declaration are untouched. The
producer is the explain wiring, as the card says; no other package is
involved.

## The two sides, quoted on `origin/main` `ccccdcc35e` before the change

- **Explain** (`security-plugin.ts:4426`): `sharingReadFilter: (o:
string, c: any) => sharing.buildReadFilter(o, c)`. The bare context, no
`__readScope`.
- **The find path, step 2.6** (`security-plugin.ts:2375`, `:2403`): for
`find` / `findOne` / `count` / `aggregate` on a non-delegated context,
`sc.__readScope` is set to
`this.permissionEvaluator.getEffectiveScope('read', opCtx.object,
permissionSets, { isPrivate: secMeta.isPrivate })`. `permissionSets`
comes from `resolvePermissionSetsForContext` and `secMeta` from
`getObjectSecurityMeta`. plugin-sharing's `buildReadFilter` then reads
it: `org` gives no owner filter (null), and a unit depth gives the
unit's owners.
- **The resolver already exists.** `resolveSharingReadFilter`
(`security-plugin.ts:4490` after this change; it is the analytics path's
seam) computes the depth from the same two inputs and the same evaluator
call, stamps it, and calls `buildReadFilter`. Its docblock names the
situation explain is in: no middleware runs on this path, so the depth
is computed there. The change reuses it. Nothing new is computed.

## Reproduced before the change

The new test file was committed first on the unfixed tree (`07f447e0c0`,
base `ccccdcc35e`). It wires the real `SecurityPlugin`, the real
`SharingService` and sharing middleware, and the real platform
`member_default` seed. The object has an unset OWD (private). The one
variable is the principal's read depth, on the same three rows:

| principal (read depth) | row | explain `record` | find |
|:--|:--|:--|:--|
| `hr_reviewer` (`org`) | shared to someone else | `visible: false`,
`decidedBy: sharing` | returns it |
| `hr_reviewer` (`org`) | owned by someone else | `visible: false`,
`decidedBy: sharing` | returns it |
| `hr_reviewer` (`org`) | unshared | `visible: false`, `decidedBy:
sharing` | returns it |
| `team_lead` (`unit`) | owned inside the unit | `visible: false`,
`decidedBy: sharing` | returns it |
| `dept_reporter` (`own`), the control | read-shared / owned / unshared
| `true` / `true` / `false` | returns / returns / refuses: agrees |

Result: `Tests 7 failed | 17 passed (24)`. The seventh red is a second
direction on the old binding. A `__readScope: 'org'` already present on
the explained context decided the report (`dept_reporter` × unshared:
explain `true`, find refused), because the bare context was passed
straight through.

## The change

- **The non-delegated `sharingReadFilter` binding** calls
`resolveSharingReadFilter(o, { ...c, __readScope: undefined })`. The
caller's own key is cleared first, so the depth is always the computed
one, the way step 2.6 overwrites it. Where resolution yields nothing (no
sets, or a failed resolution), the helper stamps nothing and
plugin-sharing reads `own`, the safe direction.
- **On-behalf-of contexts keep `sharing.buildReadFilter(o, c)`
unchanged**, as objectstack-ai#19984 did on the write side. The middleware's delegated
read (the agent-leg depth, plus a second filter AND-ed in under the
delegator's identity) is not modelled on explain's record path. Stamping
the agent's own depth without that second filter could only widen the
report.
- `delegatedWrite` is renamed `actsOnBehalfOf`: it now guards read and
write bindings, and `delegated` is already a name inside this function
(the delegated-admin gate).

## Tests: `explain-read-verdict-inputs.test.ts` (24 cases)

Every cell asserts that `record.visible` for `read` equals whether the
find returns the row, and it asserts the find's own answer too. A cell
cannot go green because both sides drifted. The find runs through both
real middlewares, and the engine double evaluates the composed AST
`where`. The double's matcher throws on an operator it does not know, so
a refused cell cannot go green because the double silently answered
`false`. Both sides read one hierarchy resolver: `unit` = the lead and
the reporter.

- Read matrix, private OWD: builtin admin × unshared; `hr_reviewer`
(`org`) × shared / owned / unshared; `team_lead` (`unit`) × owned inside
the unit / owned outside it / shared to someone else; `dept_reporter`
(`own`) × read-shared / owned / unshared. Negative control:
`dept_reporter` × unshared, refused by both, and explain names `sharing`
(layer `excluded`).
- Depth: the `org` reader's sharing layer is `admitted` with `rowFilter:
null`. A `__readScope` the caller brings does not decide the report, in
either direction: `dept_reporter` + `'org'` × unshared is refused by
both, and `hr_reviewer` + `'own'` × unshared is admitted by both.
- On-behalf-of: the context explain hands plugin-sharing's read filter
carries no `__readScope`.
- OWD control `public_read`: all nine principal × row cells admit on
both sides.

Unfixed tree: `Tests 7 failed | 17 passed (24)`. Fixed (`461187a0d2`)
and at the final head: `Tests 24 passed (24)`.

## Ablations

The fix was committed first (`461187a0d2`). Each mutation used
`scripts/ablation-replace.mjs`: the anchor went from 1 hit to 0, the
replacement from 0 to 1, and the blob changed on disk. After each
restore the blob matched HEAD (`229605a70e3a`) and `git diff HEAD` was
empty. The test imports `./security-plugin.js` from source, so there is
no `dist/` hop and `ablation-dist-preflight` does not apply. Directions
were predicted before each run.

| ablation | mutation | result |
|:--|:--|:--|
| A | the old binding: `sharing.buildReadFilter(o, c)` for every context
| `7 failed / 17 passed`: `hr_reviewer` × 3, `team_lead` × owned inside
the unit, the `org` layer pin, and both caller-depth pins. The widening
one reads `u_reporter × unshared × read: … expected true to be false`.
The plain `visible: false` cells stay green, as predicted: the old
binding is narrower and cannot widen. |
| B | over-widening: every non-delegated context asked at `__readScope:
'org'` | `5 failed / 19 passed`: every `visible: false` pin (`team_lead`
× unshared, `team_lead` × shared, `dept_reporter` × unshared, the
negative control, the widening caller-depth pin), each `expected true to
be false`. |
| C | the on-behalf-of guard dropped | `1 failed / 23 passed`: the
on-behalf-of pin (`no read depth stamped on a delegated context:
expected true to be false`). |
| D | the caller-key clear dropped: `resolveSharingReadFilter(o, c)` |
`24 passed`, as predicted. The clear matters only where depth resolution
yields nothing, and there explain's own CRUD layer already refuses, so
`record.visible` cannot show it. It is declared, unpinned hygiene. |

The first C attempt was a no-op. Its replacement text was a substring of
its anchor, so `ablation-replace` saw the replacement count stay at 1,
refused with exit 1, and ran no tests. The re-run with a distinct marker
is the row above.

## Local verification: final head `8644de0cbe`

- `pnpm --filter '@objectstack/plugin-security...' build`: exit 0.
- Full plugin-security `vitest run`: `Test Files 124 passed`, `Tests
2365 passed`, at `6471f5564d`. Later commits add only the ledger row and
a `main` merge whose source changes sit in `packages/mcp` and
`packages/runtime`, outside plugin-security's build closure. The three
explain suites re-ran at `8644de0cbe`: `Tests 138 passed (138)`.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. The
new test is in `tsconfig.test.json`'s program (`--listFiles`: 1 hit) and
`check:test-typecheck` is OK.
- `dispatch-gates --commands` at `8644de0cbe`, reconciled with `--ran`:
70 derived, 68 run with exit 0, 2 NOT MEASURED.
`check:dual-build-cjs-loads` and `check:type-check-debt` exit 3
(PREREQUISITE NOT MET): they need every workspace package's `dist/`,
which CI's Build Core owns. `check:i18n` was measured after building its
declared closure: exit 0, 9 packages in sync.
- `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs`: exit 0, 1
citation, resolves.
- Narrowed eslint (`--no-inline-config --format json`) over the two
changed TS files: 2 files, 0 errors, 0 warnings. Both files resolve a
non-empty config under `--print-config`. `eslint.config.mjs` enables no
type-aware linting (no `parserOptions.project`, no `projectService`), so
this diff cannot move the verdict of any untouched file.
- Two `main` merges before opening (`ccccdcc35e` then `bfa23a8f49` then
`615c0856ef`). Neither touched plugin-security, plugin-sharing, the
ledger or the lockfile. objectstack-ai#19988 had not landed.

## Surface note

`scripts/engine-double-contract.pinned.json` gains one row (+5 lines:
the new test file's `findOne` double). It was written by `node
scripts/check-engine-double-contract.mjs --write`, which that gate
prescribes for a new pinned double ("0 added or grown, 0 lost"). It lies
outside the claim's declared file surface, the same as the three rows
objectstack-ai#19984 added. objectstack-ai#19988 does not touch this file.

## Acceptance notes

- **Explain's record path does not model the on-behalf-of read, before
or after this PR.** That is the agent-leg depth intersection plus the
delegator's filter AND-ed in. It was not measured here. objectstack-ai#19984 recorded
the same boundary for writes.
- **Under a sharing-service fault, explain over-reports `read`
visibility.** This predates the PR, which does not change it.
`explain-engine.ts:958` catches a rejected `sharingReadFilter` into
`null`, and the record matcher reads `null` as "no filter". A one-off
probe on this branch made `sys_record_share` reads throw.
`dept_reporter` × unshared then got explain `visible: true` (sharing
`admitted`, `rowFilter: null`), while the same find threw. The old bare
binding rejected into the same catch. This is reported for the seat to
judge. It is not fixed here: the site is `explain-engine.ts`, outside
this card's surface.
- **The test double's nested reads** are not scoped by plugin-sharing's
read filter. That boundary is recorded in
`row-write-widener-composition.test.ts`, and no cell here depends on a
nested read.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…000 no longer truncates it, a leading U+FEFF is no longer dropped on read (objectstack-ai#19998)

Fixes objectstack-ai#19978

Clause-②: no

`SqliteWasmDriver` now stores and reads a text value byte-for-byte, as
`SqlDriver` on better-sqlite3 does. Before this change, an embedded
U+0000 cut the stored value short, and a leading U+FEFF was dropped when
the value was read. Neither raised. The fix lives entirely in this
package's sql.js transport (`knex-wasm-dialect.ts` plus a new
`sqljs-exact-text.ts`). sql.js is not patched. No code line in
`SqlDriver` moves, and no public export moves. One doc-comment
parenthetical in `sql-driver.ts` and one sentence of a pending changeset
are corrected, because this fix makes them false (see "Deliberate
correction of a pending changeset" below).

## Where each byte was lost (measured first; the card's readings were a
scratch probe nobody had re-run)

sql.js 1.14.1 is what the lockfile installs (`pnpm-lock.yaml`:
`sql.js@1.14.1`) and what `node_modules/sql.js/package.json` reports.
The control is better-sqlite3 via driver-sql's devDependency. The probes
are scratch files, not committed.

**Raw sql.js, write side.** Bind a JS string, then read `hex(v)`. The
hex is ASCII, so the read decode cannot hide a write-side loss:

| value | its UTF-8 | sql.js stored | better-sqlite3 stored |
|:--|:--|:--|:--|
| `'a'` + U+0000 + `'b'` | `610062` | `61` | `610062` |
| `'ab'` + U+0000 | `616200` | `6162` | `616200` |
| U+FEFF + `'hello'` | `EFBBBF68656C6C6F` | `EFBBBF68656C6C6F` |
`EFBBBF68656C6C6F` |
| `'he'` + U+FEFF + `'llo'` | `6865EFBBBF6C6C6F` | same | same |

**Raw sql.js, read side.** Cells are planted with `cast(x'…' as text)`,
so no bind is involved:

| stored | sql.js `getString` | sql.js `getBlob` on the same cell |
better-sqlite3 |
|:--|:--|:--|:--|
| `610062` | `"a"` | `610062` | `"a\u0000b"` |
| `EFBBBF78` | `"x"` | `EFBBBF78` | `"x"` |
| `EFBBBF` + 33 ASCII bytes | the ASCII only | all bytes | BOM kept |

**H1 holds, and the loss has two separate causes.** The write loses a
U+0000. The read loses both a U+0000 and a leading U+FEFF. A U+FEFF was
always stored intact.

**H2: the exact functions.**
- Write: sql.js `Statement.prototype.bindString` calls
`sqlite3_bind_text(this.stmt, pos, strptr, -1, 0)`
(`dist/sql-wasm-debug.js` lines 684–695). The length `-1` tells SQLite
to read up to the first NUL.
- Read: `Statement.prototype.getString` returns `sqlite3_column_text`,
cwrapped with return type `"string"`, so `UTF8ToString` decodes it. That
function stops at the first NUL (`findStringEnd`) and decodes through a
module-level `new TextDecoder()` whose default `ignoreBOM: false` drops
a leading BOM. In the minified `dist/sql-wasm.js` this is `Za=new
TextDecoder` with
`z=(a,b,c)=>a?Za.decode(C.subarray(a,$a(C,a,b,c))):""`, so the BOM is
dropped at every length. The debug build decodes strings of 16 bytes or
fewer by hand, and those keep it. That is why a long-BOM case is pinned
too.
- Ours: `Client_WasmSqlite._query` in `knex-wasm-dialect.ts` owns both.
It binds through `stmt.bind` / `db.run` and decodes through
`stmt.getAsObject()`. `wasm-connection.ts` owns neither.

**Driver level, base `a7581b326`: `create` → `findOne`, and `find` with
`{ v: value }`.**

```text
== driver-sql on better-sqlite3 (control)
  nul_mid   stored-hex=610062           read="a\u0000b"     faithful=true
  nul_trail stored-hex=616200           read="ab\u0000"     faithful=true
  bom_lead  stored-hex=EFBBBF68656C6C6F read="hello"  faithful=true
== driver-sqlite-wasm (sql.js), before
  nul_mid   stored-hex=61               read="a"            faithful=false
  nul_trail stored-hex=6162             read="ab"           faithful=false
  bom_lead  stored-hex=EFBBBF68656C6C6F read="hello"        faithful=false
== driver-sqlite-wasm (sql.js), this head
  (identical to the better-sqlite3 block, every line)
```

## The fix (H3: it fits in our adapter)

- **Read.** A cell that `stmt.get()` returns as a string is a TEXT cell.
For each one, `readExactRow` re-reads the stored bytes with
`Statement.getBlob` (`sqlite3_column_bytes` + `sqlite3_column_blob`;
SQLite hands a UTF-8 TEXT value to `column_blob` unconverted) and
decodes them with `TextDecoder('utf-8', { ignoreBOM: true })`. `getBlob`
is not in `@types/sql.js`, but it is the method sql.js's own `get()`
calls for a BLOB column. It keeps its name in all three 1.14.1 builds
this package can load (`sql-wasm.js`, `sql-wasm-browser.js`,
`sql-wasm-debug.js`); `getString` is renamed in the minified two. If
`getBlob` is absent, the read throws and does not fall back to the lossy
decode.
- **Write.** Only a string binding that holds U+0000 is changed; nothing
else can be truncated. It is bound as its UTF-8 bytes (a `Uint8Array`,
which sql.js binds as a BLOB with an explicit length), and every
parameter token that receives it is wrapped as `+CAST(PARAM AS TEXT)`.
The unary plus is load-bearing. `CAST(… AS TEXT)` alone carries TEXT
affinity and changes a comparison. Measured on sql.js: comparing the
integer `5` as less than the text `' x'` answers 1 against a bound text,
0 through `CAST(? AS TEXT)`, and 1 through `+CAST(? AS TEXT)`
(better-sqlite3 bound: 1). A numeric-affinity column stores `'12'` as
integer through either path. A statement with no such binding comes back
as the same string and the same array.
- **Which token receives which binding** follows SQLite's own numbering
rule, which `sqljs-exact-text.ts` applies:
  - bare `?` takes the largest index so far + 1;
  - `?NNN` takes `NNN`;
- `:name` / `@name` / `#name` / `$name` take the index of their first
occurrence;
  - nothing inside quotes, `[…]` or comments is a parameter.

Wrapping adds or removes no parameter token, so no index moves. No
statement form is refused, which is why this is `Clause-②: no` and not a
narrowing.
- **Ruled out:** an explicit-length `sqlite3_bind_text` through the
statement pointer. That pointer is `this.stmt` only in the debug build;
the minified builds rename it (`this.Qa`), so it is not a usable seam.

## H4: why the shared case table is not edited here

`VALUE_ROUNDTRIP_CASES` lives in
`packages/spec/src/data/value-roundtrip-conformance.ts`. The claim's
file surface allowed a shared case file under
`packages/drivers/driver-sql/src/` "that the SQLite family's conformance
suites read". No such file exists, and the real one is in
`packages/spec`, so this is the stop-on-breach case and the table is not
edited. There is a second reason, a substantive one.
`sql-driver-value-roundtrip-conformance.test.ts` runs that table through
the dialect matrix, including the live Postgres CI job. Postgres
documents that its `text` type cannot store the character with code
zero, so a U+0000 row would make that cell fail on Postgres. That is a
platform decision (refuse U+0000 everywhere? answer per dialect?), not a
driver fix. It is NOT MEASURED here, because there is no live Postgres
in this container. A leading-U+FEFF row is a better candidate for the
shared table, but how the MySQL and Postgres drivers decode it is
unmeasured here. Both questions go to the seat in the report. Meanwhile
the answer is pinned in this package, against the written value and its
own UTF-8.

## Tests

New file `sqlite-wasm-text-bytes-roundtrip.test.ts`, 35 cases. Each seam
is pinned on its own, because one can hide the other:
- **Write:** `hex(v)` equals the written string's UTF-8, for U+0000 in
the middle and trailing, U+FEFF leading (short and long) and in the
middle, and a plain control.
- **Read:** cells planted by SQL literal read back exactly (`610062`,
`EFBBBF78`, a long BOM value, a plain control).
- **Round trip:** `create` → `findOne`, and `update` (whose returned row
is also checked).
- **Filters:** equality on each exact value selects that row and no
other. Equality on `'a'` no longer matches the stored `'a'` + U+0000 +
`'b'`; before, the comparand was cut at the same NUL, so it did. `$in`
places each NUL-bearing comparand. `$contains` U+FEFF and `$startsWith`
U+FEFF select the right rows and read them back whole.
- **Placement:**
  - the no-affinity pin;
  - the statement comes back unchanged when no binding holds U+0000;
- placement past `'it''s ?'`, `` `a?` ``, `[b?]`, `"?"`, both comment
forms and the identifier `c$d`;
  - `?NNN` and `:name` through the driver;
  - SQLite's numbering on a mixed statement;
- a binding no parameter receives is left for sql.js to answer as
before.
- **Refusal of the fallback:** a statement object without `getBlob`
throws.

**Ablations.** Each ran from the committed state through
`scripts/ablation-replace.mjs`. Every mutation was proved on disk
(anchor 1 → 0, blob changed), and every restore was proved (blob equal
to HEAD, `git diff HEAD` empty). The subject resolves from `src`
(relative imports), so no build was involved:
- **A: read seam back to `stmt.getAsObject()`.** 11 of 35 red: every
read pin, the update pin, `$contains` U+FEFF (its values), and the two
numbered/named pins. Every write-hex pin and every equality filter
stayed green.
- **B: write rewrite disabled** (`truncatable.size === 0` →
`truncatable.size >= 0`). 10 of 35 red: the U+0000 hex pins and reads,
update, the prefix-equality pin, and the placement pins. Every read-seam
pin stayed green. A first attempt used a replacement that was a
substring of its anchor. The tool refused it (replacement count 1 → 1)
and restored, and no test ran; it was repeated with a distinct
replacement.
- **C: the unary plus removed.** 3 red: the affinity pin (`r: 0`, not 1)
and the two literal-placement pins.
- **D: `?NNN` numbered as a bare `?`.** 2 red: the `?2`/`?1` driver pin
and the mixed-statement pin.

Package runs at `58ee6c042`:
- `pnpm --filter @objectstack/driver-sqlite-wasm test`: 30 files, **556
passed**.
- `typecheck` (`tsc --noEmit`): exit 0. Its program includes both new
files, checked with `--listFiles`.

## Deliberate correction of a pending changeset

The dispatch asked whether the "not read back verbatim" sentence in
`.changeset/19912-json-backfill-depth-limit.md` still holds. That
changeset is still pending and belongs to a landed PR. It says the
backfill leaves as stored, "on `SqliteWasmDriver`, a legacy text with a
leading U+FEFF or an embedded NUL (sql.js drops both when it reads the
text)". **After the fix (head `58ee6c042`) that is false.** Measured
with a scratch probe: legacy json TEXT cells were planted, then the
backfill ran via a second `initObjects`.

```text
                      legacy      better-sqlite3   wasm, both seams ablated (dist rebuilt)   wasm, this head
  bom (U+FEFF + x)    EFBBBF78    22EFBBBF7822     EFBBBF78 (left)                           22EFBBBF7822
  nul (a + U+0000 + b) 610062     22615C75303030306222  610062 (left)                        22615C75303030306222
  plain control       68656C6C6F  2268656C6C6F22   2268656C6C6F22                            2268656C6C6F22
```

`SqliteWasmDriver` now converges these cells exactly as better-sqlite3
does. `ablation-dist-preflight` confirmed the ablation was present in
`dist/` for the control. After the rebuild it was absent again and the
tree was clean. The same parenthetical sits in
`SqlDriver.backfillCanonicalJsonEncoding`'s doc block in
`packages/drivers/driver-sql/src/sql-driver.ts` ("sql.js drops an
embedded NUL and a leading U+FEFF"). The seat ruled that both are
corrected in this PR (claim amendment 5818397702 on objectstack-ai#19978). Patch round
1 (`6a195b37c`) made the edits below, and patch round 2 (`98cb90873`)
corrected them after contract review 5819169241 (FAIL). Round 1 had
labelled `TursoDriver`'s local mode as libsql, but every non-remote arm
of `TursoDriver.toKnexConfig` hands Knex `client: 'better-sqlite3'`. The
measurement behind the edits was taken fresh at `58ee6c042` on three
local faces, which run on two engines: `SqlDriver` on better-sqlite3,
`TursoDriver` in local mode (better-sqlite3 through Knex, url
`:memory:`), and `SqliteWasmDriver` (sql.js). All three convert
`EFBBBF78` → `22EFBBBF7822`, `610062` → `22615C75303030306222` and the
control `68656C6C6F` → `2268656C6C6F22`. All three leave invalid UTF-8
(`FF78`, `61C3`) as stored, because each reads it back with U+FFFD in
place of the invalid bytes. `SqlDriver`'s `sqlite3` / `sqlite` clients
are NOT MEASURED, because that client is not installed in the container.
libsql is not a local engine here and is not cited. The reviewer read it
directly (`@libsql/client` 0.17.4, `:memory:`): a stored `FF78` panics
the native binding, and a stored `610062` reads back as `"a"`.

**`.changeset/19912-json-backfill-depth-limit.md`** (pending, landed
with PR objectstack-ai#19972; one sentence of the merge base rewritten into three):

- Old: "A cell the engine does not read back verbatim is left as stored
and keeps reading as it did, where the old statement rewrote it: on
`SqliteWasmDriver`, a legacy text with a leading U+FEFF or an embedded
NUL (sql.js drops both when it reads the text); on any engine, text
holding invalid UTF-8."
- New: "A cell the engine does not read back verbatim is left as stored
and keeps reading as it did, where the old statement rewrote it: text
holding invalid UTF-8, measured on better-sqlite3 and sql.js.
`SqlDriver` on better-sqlite3, `TursoDriver` in local mode (which runs
on better-sqlite3 too) and `SqliteWasmDriver` (sql.js) were each
measured to read such a cell back with U+FFFD in place of the invalid
bytes, so the text the rewrite would be decided from is not the stored
text, and the cell is left alone. A legacy text with a leading U+FEFF or
an embedded NUL is read back verbatim on all three faces, and is
rewritten like any other plain string."

**`packages/drivers/driver-sql/src/sql-driver.ts`**, in the
`backfillCanonicalJsonEncoding` doc block. Two comment lines change and
no code line moves. A build of driver-sql from the merge base and one
from the head differ in exactly those two comment lines of
`dist/index.js` / `dist/index.mjs`, re-proved in round 2. The driver-sql
suite reads 2680 passed / 170 skipped on both copies (round 1).

- Old: "(sql.js drops an embedded NUL and a leading U+FEFF; any engine
replaces invalid UTF-8)"
- New: "(better-sqlite3 and sql.js, the engines measured, read invalid
UTF-8 back as U+FFFD)"

**This PR's own changeset** gains one bullet: the local `Field.json`
backfill now converts those legacy cells on this driver too, with the
measured bytes.

`Check Changeset` is red by design on this head.
`check-empty-changeset.mjs` reads the 19912 edit as the DELIBERATE
CORRECTION class ("do NOT restore it -- say so on the PR and get it
confirmed"). The edit is not restored and `skip-changeset` is not
applied. Under ruling 1A (objectstack-ai#19940, 5814546887), the confirmation is a
same-head at-tier contract-review PASS that names this note and judges
each rewritten sentence.

## Gates

- **Derived gate list.** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` on this head gives 59 commands. All
59 exit 0 at `58ee6c042`. `--ran` reconciliation: 59 derived, 59 run, 0
NOT-MEASURED (derived from recorded exit codes).
- `check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:type-check-debt` first answered PREREQUISITE NOT MET (exit 3).
They were re-run after `turbo run build --filter='./packages/*'
--filter='./packages/*/*'` and answered 0.
- `check:query-options-erasure` first went red: the test surface grew
from 236 to 242, from this test's `as any` query casts. The queries are
now typed as `DriverQuery`, and the gate is green.
- **Driver conformance census, before and after.** `pnpm
check:driver-conformance` reads `OK — 50 covered cell(s), 0 in the DEBT
ledger, 0 exempt` both on base `a7581b326` and at `58ee6c042`. No cell
was added or removed.
- **`node scripts/check-issue-citations.mjs --base origin/main`:** exit
0.
- **Patch round 1 at `6a195b37c`.** `dispatch-gates` re-derives 62
commands: the 59 above, plus `check:dispatcher-error-vocabulary`,
`check:object-def-param-keys` and `check:tenant-chokepoint`, which
`sql-driver.ts` brings in. All 62 ran after a `./packages/*` build. 61
exit 0, and `check-empty-changeset` exits 1 by design (the correction
above). `check-issue-citations --base origin/main` exits 2 at this head,
but only because `origin/main` moved past the merge base: objectstack-ai#19988 removed
three `objectstack-ai#16608` citations from a file this diff does not touch. Against
the merge base `67ebc84a7` it exits 0. The driver-sqlite-wasm suite
reads 30 files / 556 passed, and typecheck exits 0 for
driver-sqlite-wasm and driver-sql. `check:driver-conformance` is
unchanged: 50 covered, 0 in debt.
- **Patch round 2 at `98cb90873`** (text only, the same two files).
- `check-changeset-no-major`, `check-adr-0087-registration`,
`check-issue-citations --base 67ebc84`, `check:doc-authoring`,
`check:nul-bytes` and driver-sql typecheck all exit 0.
- `check-empty-changeset` exits 1 by design: one `::error` annotation,
on the 19912 note.
  - `dispatch-gates` re-derives the same 62 families.
- The two changesets this PR touches name libsql 0 times and "any
engine" 0 times.
- **Lint, narrowed and proven.** `pnpm exec eslint --no-inline-config
--format json` over the three changed TypeScript files gives 3 files, 0
errors, 0 warnings.
- None of the three is ignored: an explicitly passed ignored file
reports a warning, and there were none.
- `eslint.config.mjs` sets no `parserOptions.project` (0 hits for
`project:` / `projectService`), so linting is not type-aware and this
diff cannot move any untouched file's verdict.
  - The repo-wide `pnpm lint` is CI's.

## Acceptance notes

- **Found here, out of scope:** a `$contains` or `$startsWith` whose
comparand holds U+0000 is matched on both SQLite faces, better-sqlite3
included, with the GLOB pattern cut at that U+0000 (wildcards after it
included) against each value cut at its own first U+0000. So `$contains
'a'`+U+0000 answers only the row stored as `'a'`+U+0000+`'b'` (measured
by the second contract review). `glob()` cuts both the pattern and the
value at their first U+0000. So a comparand that starts with U+0000
makes `$contains` and `$endsWith` match every row, and makes
`$startsWith` answer only the rows that are empty before their first
U+0000 (re-measured in patch round 2 on better-sqlite3 and sql.js, which
answer identically). The SQLite text predicate is `GLOB`, and `glob()`
reads its pattern as a C string. Measured on both faces with the probe
above. That is a filter that silently gives the wrong answer,
reproducible today. It is not fixed here; the seat filed it as objectstack-ai#19999.
- `WasmSqliteConnection`'s `defaultLocateFile()` calls
`require.resolve('sql.js/package.json')`. That throws
`ERR_PACKAGE_PATH_NOT_EXPORTED` on sql.js 1.14.1, whose `exports` map
has no `./package.json`. So it always returns `undefined`, and sql.js
then locates its own `.wasm`, which works. Behaviour is unaffected; only
the docblock's claim is dead. Noted, not filed. Carrier: none.
- `origin/main` (`67ebc84a7`) was merged in before opening. It shares no
path with this diff, and the lockfile did not move. The dependency
closure was rebuilt and the package suite re-run on the merged head.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_

---------

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>
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