fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update - #19988
Conversation
…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>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
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>
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
|
CI red on head
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>
Contract reviewServed-tier: Rendered by an isolated at-tier reviewer and adopted by the ① Derived judgments(a) Accept-set changes. Each is a narrowing only, and nothing previously refused is admitted.
(b) Edited foreign note:
(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), 「 ② Semver levelRight, and complete. Shipped bytes move in exactly two packages, and both are declared:
An accept-set narrowing is breaking, and in the launch window breaking ships as The ADR-0087 gate sees the three breaking signals and exactly one marker. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Generated by Claude Code |
… 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>
…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>
…es them (objectstack-ai#20268) Fixes objectstack-ai#19967 Clause-②: no Text only. No schema shape, accepted value or runtime behaviour moves. Every sentence below was re-measured against the code on `origin/main` at `0d3ec47137`, where PR objectstack-ai#19962 (`b5853da1ca`), PR objectstack-ai#19988 (`009da14713`), PR objectstack-ai#20012 (`44639665ee`, objectstack-ai#19989) and PR objectstack-ai#20167 (`b276d4463f`) are all merged. Each `git merge-base --is-ancestor` probe returned exit 0. ## What `main` enforces (the measurement the texts now state) - **Write `check` selection, per operation.** `writeCheckPolicies` (`packages/plugins/plugin-security/src/security-plugin.ts:890-899`) returns the policies that declare `check` when any applicable policy does. Otherwise it returns every applicable policy with a `using`, and keeps the platform ownership floor only where the write's row gate kept it. `compileFilter` (`rls-compiler.ts:600-602`) reads `check` for a policy that declares one and `using` for the rest, then OR-combines (`:676`). - **A USING-only `insert` policy's `using` is the insert check** when no applicable policy declares `check`. Pinned by `rls-check-defaults-to-using.test.ts`, case "an insert-class USING-only policy gates INSERT the same way", which is green on this head. - **A check-only `update` policy is legal and enforced.** The schema accepts it (`rls.zod.ts`, the at-least-one rule). The row gate derives the write scope from `select` when no write-class `using` applies (`security-plugin.ts:7018`), and the post-image check judges the written rows. Pinned by `check-only-write-scope.test.ts`, which is green. The showcase's `invoice_owner_immutable` is one such policy. - **A non-blank `check` on `select` / `delete` is refused** (`rls.zod.ts`, the `superRefine` at the `check` path, from PR objectstack-ai#20167). - **OR-combination.** On reads, and on the rows an update or delete may target, the applicable policies' `using` clauses OR-combine (`compileFilter`). On the written rows, the check is chosen first and then OR-combined. So "most permissive wins" is true on reads and false as a statement about writes. - **Post-image scope: every insert and update shape.** The seam is installed for every insert and for every update (`security-plugin.ts:3191`, `:3261`, `:3508`). The engine runs it: - on each live row of an insert, after `beforeInsert` (`packages/objectql/src/engine.ts:12470`); - on the by-id row merged with the final payload, after `beforeUpdate` (`:14085`, PR objectstack-ai#20012); - on each matched row of a `multi: true` update (`:14345`, PR objectstack-ai#19988). The by-id update is also judged in the middleware on the change set as sent (`security-plugin.ts:3116`). Pinned by `rls-check-multi-row-writes.test.ts`, `rls-check-by-id-update-post-hook.test.ts` and `insert-check-post-image.test.ts`, all green on this head. ## Sites, old text, new text, and the code that makes the new text true | Site | Old text | New text | True because | |---|---|---|---| | `rls.zod.ts` `check` describe | "matched against the new row of a single-record INSERT or a by-id UPDATE ...; an array insert and a `multi: true` update are not post-image checked." | "judged on every row an insert or an update writes ...: each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent." | `engine.ts:12470`, `:14085`, `:14345`; `security-plugin.ts:3116` | | `rls.zod.ts` `check` TSDoc | "Validation of the new row of a single-record INSERT or a by-id UPDATE. An array insert and a `multi: true` update are not post-image checked." | Every row an insert or update writes, with the per-shape image. It also names the engine-filled values that are not on an insert's judged image (autonumber, a `secret` field's stored reference, an absent tenant column). | the same lines; the engine's closed list after the insert seam (`engine.ts`, the comment above `:12470`) | | `rls.zod.ts` `check` TSDoc, floor | "takes part only where the by-id pre-image gate kept it" | "only where the write's own row gate kept it" | `computeWriteCheckFilter`: a `multi: true` update has no by-id gate and keeps the floor unless the OWD yields (`platformFloorYieldsToObjectWriteModel`) | | `rls.zod.ts` `using` describe | "Filter condition for SELECT/UPDATE/DELETE ... Optional for INSERT-only policies." | "Row predicate ...: the rows a `select` policy lets a caller read, and the existing rows an `update` or `delete` policy lets a caller change or remove. On an insert or an update, when no applicable policy for that operation declares `check`, each applicable policy's `using` also stands in as its check on every row written ...; on an `insert` policy that is its only effect. ... Needed on a `select` or `delete` policy (a `check` there is refused); optional on an `insert`, `update` or `all` policy that declares `check`." | `writeCheckPolicies:896`; `getApplicablePolicies:846`; the `check` refusal | | `rls.zod.ts` `using` TSDoc | "For INSERT-only policies, USING is not required (only CHECK is needed). For SELECT/UPDATE/DELETE operations, USING is required." | A per-operation list. `update` does not require `using`: a check-only `update` policy is accepted and enforced, and its target rows come from the other `update` / `all` `using` or from `select`. An `insert` policy's `using` filters nothing, but it is the insert check when none is declared. | `security-plugin.ts:7018`; `check-only-write-scope.test.ts` | | `rls.zod.ts` `superRefine` message | "... For SELECT/UPDATE/DELETE operations, provide "using". For INSERT operations, provide "check"." | The same head. Then: `select` / `delete` take "using"; `insert` takes "check", or a "using" alone as that check when no applicable insert policy declares one; `update` / `all` take either or both. | the schema accepts each prescription (pinned below) | | `rls.zod.ts` schema TSDoc | "combined with OR logic (union of results)" | OR on reads; on writes, the per-operation choice (see `check`) | `compileFilter`; `writeCheckPolicies` | | `rls.zod.ts` `priority` TSDoc | "Applicable policies OR-combine (... the doc above `RLSCompiler.compileFilter` and this schema's own former describe both say most-permissive-wins)" | No outcome depends on an order: OR on reads; on writes, the check is chosen per operation and then OR-combined | the same | | `rls.zod.ts` overview, "Default Deny" | "If no policy matches, access is denied" | Default deny among the policies that apply. When no policy applies, the policies restrict nothing (the tenant wall still applies). | `compileFilter:656` (`applicable === 0` returns `null`, no filter); `content/docs/permissions/rls.mdx` callout | | `migrations/registry.ts` `--from 16` prose (and regenerated `docs/protocol-upgrade-guide.md`) | "applicable policies OR-combine (most permissive wins)" | "no outcome depends on an order: applicable policies OR-combine on reads, and a write's check is chosen once per operation across the applicable policies, then OR-combined" | the same | | `liveness/permission.json` `check` / `using` evidence | `compileFilter` "`(policy as { check?: string }).check ?? policy.using`" (the expression is gone) | `security-plugin.ts#writeCheckPolicies` plus `rls-compiler.ts#compileFilter`, with the live expression | `check:liveness` resolves both anchors (green) | | `security-plugin.ts` `writeCheckPolicies` docblock | "The published contract is `RowLevelSecurityPolicySchema.check`: "defaults to USING clause if not specified"." | It points at `RowLevelSecurityPolicySchema.check` without quoting it, so it cannot go stale. PostgreSQL's per-policy rule is named as the starting point. | the describe itself | | `rls-check-defaults-to-using.test.ts` header | "A policy that declares no `check` holds the write post-image to its `using`, which is what `RowLevelSecurityPolicySchema.check` publishes: "defaults to USING clause if not specified"." | When no applicable policy declares `check`, each `using` stands in. The choice is per operation, not per policy. | `writeCheckPolicies` | | `content/docs/permissions/authorization.mdx:108-109` | "Multiple row policies for the same object/operation OR-combine" | They OR-combine their `using` on reads and on the rows a write may target. The written-row `check` is chosen per operation first. Links the fail-closed contract. | the same | ### In-place fixes beyond the claim's file surface (same defect class: the same stale sentences) The dispatch asked for a grep for other copies of the old sentences. These hits are not governed and not pending changesets, so they are fixed here. **File-surface supplement for the claim:** `content/docs/permissions/rls.mdx`, `content/docs/protocol/objectql/security.mdx` and `packages/spec/src/conversions/registry.ts` (TSDoc only). The two changesets below are also outside it. - `content/docs/permissions/rls.mdx:63`: "after a single-record insert or a by-id update; an array insert and a `multi: true` update are not checked" now says every row an insert or update writes. - `content/docs/protocol/objectql/security.mdx:144`: the same parenthetical. `using` is no longer "for SELECT/UPDATE/DELETE" only. - `packages/spec/src/conversions/registry.ts`: the `permission-rls-priority-removed` TSDoc said "most permissive wins". Its `summary` (which feeds `spec-changes.json`) is unchanged and not false. - `packages/spec/src/security/rls.test.ts`: the `priority` test comment said "(most permissive wins)". ## Pin sweep for the `superRefine` message ① The repo-wide grep for `At least one of` and `provide "using"` finds pins only in `packages/spec/src/security/rls.test.ts`: `toContain('At least one of')` and its negation. Both still hold, because the head is unchanged. No other package, doc or translation carries the message. ② New load-bearing pins, in the `RowLevelSecurityPolicySchema — the "at least one" refusal` block of `rls.test.ts`. For each of the five operations they assert the refusal's `code` (`custom`), `path` (`[]`) and head, that every operation is named, and that the false sentence is absent. They then prove each prescription against the schema itself: `select` / `delete` + `using`, `insert` + `check`, `insert` + `using` alone, and `update` / `all` with check only, using only, and both. All parse. ## Pending release notes corrected (DELIBERATE CORRECTION: `check-empty-changeset` is red by design) - `.changeset/19953-rls-check-default-composition-text.md` (from PR objectstack-ai#19962). It said: "The check runs on the new row of a single-record insert and of a by-id update. An array insert and a `multi: true` update are not post-image checked; those are tracked in objectstack-ai#19964 and objectstack-ai#19950, and the texts now say so". That would ship false. objectstack-ai#19988 and objectstack-ai#20012 land in the same release, and this PR changes the texts it says "now say so". The rewrite dates the old scope to when that change was written, and states the release's scope. - `.changeset/rls-check-defaults-to-using.md` (from PR objectstack-ai#19952). It said: "`RowLevelSecurityPolicySchema.check` reads "defaults to USING clause if not specified"". The describe no longer reads that in the release this note ships in (PR objectstack-ai#19962). Changed to "read ... (it now states the default per operation across the applicable policies, objectstack-ai#19953)". The triage on objectstack-ai#19967 raised this note for the dev to judge. The gate's remedy is to say so here and get it confirmed. Restoring either note from base would put the false sentence back. ## Reported only, not edited - **Governed (Tier H):** - `docs/adr/0066-unified-authorization-model.md:93`: "Multiple row policies for the same object/operation are OR-combined". This is the ADR sentence that `authorization.mdx` paraphrases, and it has the same false-for-writes reading. - `skills/objectstack-data/rules/security.md:72-75`: calls `using` the "read filter" and `check` the "write filter", and does not state the stand-in check. - `docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md:79-81`: about the read filter, and not false. - **Release-owned (never edited in a code PR):** `packages/spec/CHANGELOG.md:46934`, `:73373` and `packages/plugins/plugin-security/CHANGELOG.md:6610`, `:10225`. These say "(the schema's own describe says most-permissive-wins)". That was true of the describe when they shipped. - `packages/spec/spec-changes.json:79`, `:971`: the generated `summary` "policies OR-combine". It is shorthand and not false. It is regenerated from the conversion registry, which this PR leaves as is. ## Published surface - **`@objectstack/spec`**: `patch`. It ships `src/**/*.zod.ts`, `dist`, `liveness` and the `--from 16` prose. - **`@objectstack/plugin-security`**: no changeset. The two edits are a comment in a non-exported function and a test header. Measured: tsup strips in-body comments. A positive control in `packages/objectql/dist/index.js` shows the code line `postHookWriteImageCheck.honoured = true` present (1 hit) and the comment above it, "INSERT POST-IMAGE seam", absent (0 hits). `writeCheckPolicies` is not exported, so no `.d.ts` carries its docblock. ## Verification Every reading below was taken on head `e2bd8f3680`, the final commit, with a clean tree. - **Gate union.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived the list. `--ran` reconciles it: "116 derived famil(ies) accounted for — 112 run, 4 NOT-MEASURED". The 116 includes 2 families the derivation added after the regeneration commit (`pnpm --filter @objectstack/spec run check:generated` and `pnpm check:quick-reference-counts`). Both were run: exit 0. - **111 green.** - **1 red by design:** `node scripts/check-empty-changeset.mjs --base origin/main` exits 1 with the DELIBERATE CORRECTION class, for the two notes named above. - **NOT MEASURED, each one a PREREQUISITE NOT MET (exit 3):** - `check:skill-examples` needs a built `@objectstack/client-react` (a 36-package closure); - `check:dual-build-cjs-loads` needs a full `pnpm build`; - `check:i18n` needs the built CLI closure; - `check:type-check-debt` needs a whole-workspace build. - **`@objectstack/spec` build:** exit 0, DTS included. `check:generated`: 2 of 15 stale (`docs/protocol-upgrade-guide.md`, `content/docs/references/**`). Regenerated with exactly `gen:upgrade-guide && gen:docs`, then "All 15 generated artifacts are up to date". - **`@objectstack/spec` tests:** `vitest run --project local --maxWorkers=2`: "Test Files 547 passed (547) · Tests 16086 passed | 2 todo". - **`@objectstack/plugin-security` tests:** `vitest run --maxWorkers=2`: "Test Files 139 passed (139) · Tests 2839 passed (2839)". This includes the semantics pins cited above: `rls-check-defaults-to-using` (22 cases), `check-only-write-scope` (21), `rls-check-multi-row-writes` (32), `rls-check-by-id-update-post-hook` (24) and `insert-check-post-image` (25), all green. - **`@objectstack/plugin-security` typecheck:** exit 0, test layer included ("check:test-typecheck: OK"). - **`@objectstack/spec` typecheck: declared narrowing.** The full `pnpm --filter @objectstack/spec typecheck` never got a turn on the shared verify lock in about 40 minutes of queueing: 8 attempts, each exit 99. Its three legs are covered as follows: - the src program: the build's DTS pass compiles the entry closure, which contains `rls.zod.ts`, `migrations/registry.ts` and `conversions/registry.ts`; - the test program: `check:test-typecheck` is green inside `check:generated` on this head, and `tsconfig.test.json` includes `src/**/*.test.ts`; - `check:scripts-typecheck`: no spec script is touched. CI's `TypeScript Type Check` runs the full command. - **eslint, a proven narrowing:** - ① Population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus build output, which contains all 6 changed `.ts` files. - ② `pnpm exec eslint --no-inline-config --format json` over those 6 files reports 6 files, 0 errors, 0 warnings, exit 0. - ③ No config object sets `parserOptions.project`, so no rule is type-aware, and this diff cannot move a verdict on an untouched file. - **Control bytes:** `check:nul-bytes` is green. The self-scan `grep -naP` for C0 control characters and DEL over the changed files exits 1 with no match. ## Acceptance notes - **Whitespace-only `check`: the schema and the runtime disagree** (observation, not filed; no public-door measurement, no named producer). The at-least-one rule tests `!data.check`, so `check: ' '` satisfies it. The runtime and the `select` / `delete` refusal read a blank clause as absent (`policyDeclaresClause`). So a policy with only a whitespace `check` parses and is inert. The `using` describe's "needed on a `select` or `delete` policy" is worded as the runtime reads it. - **`@objectstack/lint` `rls-predicate-*` messages understate the scope** (`packages/lint/src/validate-rls-predicate-enforceability.ts:272`, `:318`, `:943`, `:969`). They say "the single-record INSERT check" and "every single-record insert and by-id update ... fails". Since PR objectstack-ai#19988 and PR objectstack-ai#20012 this is true but narrow: array inserts and `multi: true` updates are refused too. These are not false, and they are outside this card's file surface. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ADR-0066 and the data skill (objectstack-ai#20298) Fixes objectstack-ai#20275 Clause-②: no Two governed texts said the RLS write check wrong. ADR-0066's combination rule (item 3) called row policies "OR-combined (any matching policy admits the row)" for every operation; the published data skill's RLS section called `using` the read filter and `check` the write filter, with no stand-in rule, no per-row statement and no refusal rule. Both now say what `main` enforces, in the wording the schema already carries. objectstack-ai#20268 landed the non-governed half (the `rls.zod.ts` overview and describe texts, the docs pages); this PR is the governed half and waits on the maintainer's hand (Tier H: `docs/adr/**` and `skills/**`). No code change. Provenance of the three facts the skill now states: every-row judging of inserts and updates landed in objectstack-ai#19988 and objectstack-ai#20012; the refusal of a non-blank `check` on a `select` / `delete` policy in objectstack-ai#20167; the per-operation default and the default-posture wording in objectstack-ai#20268. ## What changed ### `docs/adr/0066-unified-authorization-model.md` — item 3 of "Precedence / combination semantics" One sentence. It now reads: on a read, the applicable policies' `using` predicates OR-combine (any matching policy admits the row); on an insert or update, the check is chosen once per operation across the applicable policies — the declared `check` predicates when any declares one, else each applicable policy's `using` standing in — and the chosen predicates OR-combine over every row written. The tenant-isolation clause and the superuser-bypass sentence are unchanged; Status, headings and the other items are untouched. The enforcing code is cited as symbol anchors, so `check:adr-symbol-anchors` holds them: - `packages/plugins/plugin-security/src/rls-compiler.ts#RLSCompiler.compileFilter` — read side: OR-combines the applicable policies' `using` (its docblock: "Multiple policies for the same object/operation are OR-combined"). - `packages/plugins/plugin-security/src/security-plugin.ts#writeCheckPolicies` — write side: takes the applicable policies that declare `check`; when none does, the ones that declare `using` (the platform ownership floor kept exactly when the pre-image gate kept it, `keepOwnershipFloor`). `computeWriteCheckFilter` then hands that set to `compileFilter` with the `check` clause, which OR-combines. ### `skills/objectstack-data/rules/security.md` — the "Row-Level Security (RLS)" paragraph and its example comments The paragraph now states, in this order: 1. `using` admits rows — what a `select` policy lets the caller read, and the existing rows an `update`/`delete` policy lets it change or remove; on a read the applicable `using` OR-combine, then AND into the query. 2. `check` is judged on every row an `insert`/`update` writes (array inserts and `multi: true` included; one failing row refuses the write), chosen per operation: when any applicable policy declares `check`, only those decide (OR-combined); else each applicable `using` stands in. 3. A non-blank `check` on a `select`/`delete` policy is refused. 4. The default posture, in the schema overview's words ("Default deny, among the policies that apply … when none applies the policies restrict nothing (the tenant wall still applies)"), plus the one clause the review of the non-governed half named: an `update`/`delete` target with no write-class `using` is bounded by the caller's `select` policies (the by-id pre-image gate derives its scope from the caller's SELECT narrowing when no write-class `using` applies; not for `insert`, not under the read-side superuser bypass). The example's two comments (`// read scope` / `// write scope`) now read `// rows readable / targetable` and `// every row written`. Wording follows the `RowLevelSecurityPolicySchema.using` / `.check` describe texts and the `rls.zod.ts` overview as landed in `3f86dc52`; no second phrasing of the same fact was introduced. No issue or PR number appears in the skill text (`check:doc-authoring` refuses one under `skills/`). ### Paying the token ceiling `check:skills-token-ratchet` holds `security.md` at 2543 tokens with headroom 0. The RLS paragraph grew by 687 bytes and the two comments by 22; the difference is paid in the same file by removing sentences the file already states elsewhere — nothing moved to another file, the ceiling is untouched: - the `permissions`-vs-`permissionSets` bullet no longer repeats the code comment two lines above it (the refusal text, the `ObjectStackDefinitionSchema` source and "never a silent drop" stay); - the `permission_set_id` warning no longer says "record id" twice; - the RLS source line cites `rls.zod.ts` once (policy shape, grammar, `check` composition) instead of `permission.zod.ts` again (already cited under RBAC); - the owner-scoping bullet, the `requiredPermissions` paragraph (its enforcer was already named under `maskingRule`), the platform-global paragraph and its blockquote lose filler words, no facts. ## Readings Line/token budget (tokens = `ceil(utf8 bytes / 4)`, the ratchet's own unit; `3f86dc52` → `4b330f38`): | surface | lines before → after | tokens before → after | |---|---|---| | `skills/objectstack-data/rules/security.md` | 214 → 216 | 2543 → 2541 (ceiling 2543, headroom 2) | | `skills/objectstack-data/**` (17 files) | 3807 → 3809 | 40934 → 40932 | | `skills/**/SKILL.md` (10 files) | 4402 → 4402 | 51903 → 51903 | | whole `skills/` tree (65 files) | 13427 → 13429 | 155990 → 155988 | | ratchet "bundle total (whole shipped tree)" | — | 153970 → 153968 | | `docs/adr/0066-unified-authorization-model.md` | 114 → 114 | 5096 → 5224 (no token ratchet on `docs/adr/**`) | The +2 lines in the skill file are the natural wrapping of a longer paragraph; the gate that prices `skills/**` is the token ratchet, and it went down. Gates — derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` off the merge base at `4b330f38`: 29 commands; every exit code captured before any pipe; `--ran` reconciliation: "29 derived, 29 run, 0 NOT-MEASURED, 0 UNRUN … all 29 recorded an exit code and none of them is 3". - `node scripts/check-skills-token-ratchet.mjs` → 0 — "`skills/objectstack-data/rules/security.md` is 2541 tokens (ceiling 2543; headroom 2)"; `--self-test` → 0 (65 cases) - `node scripts/check-adr-symbol-anchors.mjs` → 0 — "2125 anchors across 140 records resolve"; `--self-test` → 0 - `node scripts/check-adr-links.mjs` → 0; `--self-test` → 0 - `node scripts/check-ci-filter-parity.mjs` → 0 - `node scripts/check-closing-keyword-parity.mjs` → 0; `--self-test` → 0 - `node scripts/check-comment-mask-corpus.mjs` → 0 - `node scripts/check-doc-route-spelling.mjs --advisory` → 0; `--self-test` → 0 - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` → first run exit 3 (PREREQUISITE NOT MET: `@objectstack/formula` and `@objectstack/lint` not built — a refusal, not a measurement); after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` under `os-verify-lock.sh` (VERDICT command-exit 0, held 240s) → 0 - `pnpm check:adr-anchors` → 0 · `check:agent-test-spelling` → 0 · `check:corpus-claim-drift` → 0 · `check:cross-package-test-inputs` → 0 · `check:doc-authoring` → 0 · `check:driver-memory-census` → 0 · `check:gitlink-declared` → 0 · `check:nul-bytes` → 0 · `check:pm-governed-merges` → 0 · `check:pm-prior-rulings` → 0 · `check:refd-timer-probe` → 0 · `check:role-word` → 0 · `check:skill-compatibility` → 0 · `check:skill-frame-sync` → 0 · `check:skill-identifier-liveness` → 0 · `check:watch-hint-literal` → 0 Reverse verification of the ADR anchors (the gate lists only findings, so resolution was proven by failure): with `writeCheckPolicies` mutated to `writeCheckPoliciesNOPE` in the committed ADR, `check-adr-symbol-anchors` exits 1 with `[unresolved-symbol] docs/adr/0066-unified-authorization-model.md:93`; restored with `git checkout HEAD -- PATH`, `git hash-object` of the path equals the HEAD blob (`01878adb…`) and `git diff HEAD` is empty. Package tests / typecheck: none owed — the diff touches no `packages/**` file, so there is no ① dependency closure and no ② package suite; the whole-repo `pnpm lint` sweep is CI's. Changeset: `skip-changeset` applies. Both paths are outside every published package: 0 of the 69 non-private `package.json` manifests list `skills/`, `docs/adr` or a parent path in `files[]`; positive control — `RowLevelSecurityPolicySchema` is found in `packages/spec/dist/security/index.d.ts` (grep exit 0) and the schema's describe phrase "decided per operation across the applicable policies" in 9 spec dist files (exit 0); negative — the new skill sentence "chosen per operation across the applicable" is in no built output under `packages/` (exit 1). `docs/adr/**` is on the fast track (never published). ## Acceptance notes - The dispatch order's suggested ADR phrasing named "the `using` of the applicable insert-class policies" as the stand-in. The code is wider: `writeCheckPolicies` runs for `insert` and `update` alike (an `all` policy included), and when no applicable policy declares `check`, every applicable policy's `using` stands in for that operation. The landed text says "each applicable policy's `using`", matching `RowLevelSecurityPolicySchema.using` ("on an insert or an update, when no applicable policy for that operation declares `check`, each applicable policy's `using` also stands in"). The triage note's "an insert policy's `using` stands in when no `check` is declared" is one instance of that rule, not the whole rule. - The example policy `org_isolation` (a hand-written `organization_id == current_user.organization_id` select policy) sits beside the file's own Multi-tenancy section ("⛔ never `single` + your own RLS") and duplicates the Layer 0 wall. Left as is: not this card, and the token ceiling was paid without touching it. Observation only, nothing to file. - `check-doc-formula-expressions` refuses (exit 3) on a fresh worktree until `@objectstack/formula` and `@objectstack/lint` are built; its refusal text names the fix. Not a finding. ## 维护者速读(草稿) **改了什么**:两处受管文本各改一段。ADR-0066「优先级/组合语义」第 3 项那一句,从「同一对象/操作的多条行策略 OR 合并(任一匹配即放行)」改为:读侧按 `using` OR 合并;写侧(insert/update)先按操作在适用策略中选出检查——有声明 `check` 的只用它们,否则每条适用策略的 `using` 顶上——再 OR 合并,逐行判定写出的每一行;并以符号锚引用 `compileFilter` 与 `writeCheckPolicies`。发布技能包 `objectstack-data` 的 RLS 段改写为四句:`using` 放行哪些行;`check` 逐行判定 insert/update 写出的每一行(数组插入与 `multi: true` 包含)、按操作选定、无 `check` 时 `using` 顶上;`select`/`delete` 策略上的非空 `check` 被拒;默认姿态(只在有策略适用时默认拒绝;无策略适用则不限制,租户墙照旧;update/delete 目标行在没有写侧 `using` 时受调用者的 `select` 策略约束)。示例块两行注释同步。 **为什么改**:这两段是 AI 写 RLS 策略时读的唯一说明,原文把 `check` 当成读过滤的对偶、且不写顶替规则,按它写出的策略与运行时真实执行不一致(NORTH-STAR 优先级规则 4:写给 AI 的文档与 skills 说错一句等于产品缺陷)。执行代码本身是对的,非受管文本已由 objectstack-ai#20268 修正;本 PR 只改受管的两处,不动代码。 **风险与代价(含回滚)**:纯文本;不改 schema、不改运行时。`security.md` 的 token 上限为 2543、余量 0,新增内容以删除同文件重复句付账(2543 → 2541),上限未动、未挪内容到别的文件。29 个派生门禁全绿,ADR 符号锚经反向验证(改坏符号名即变红)。回滚即 revert 本 PR 的单个 commit,无迁移、无数据影响。 **席位意见**:(留空) **你要做的**:审阅两处措辞后在本 PR 上 Approve(Tier H);落地由席位执行。 --- _Generated by [Claude Code](https://claude.ai/code/session_01MjvgiFAmjHqsxy1XLiVYfH)_ Co-authored-by: objectstack-fleet[bot] <noreply@anthropic.com>
Fixes #19950
Fixes #19964
Clause-②: no (narrowing)
What this fixes
A row-level security
check(declared on the policy, or defaulted from itsusing) 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:check(declared, or defaulted fromusing) is never evaluated on an ARRAY insert —engine.insert(object, [rows]), whichcreateManyDatacalls, stores rows a single-record insert refuses #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.check-only row-level policy does not gate a bulk update (update(…, { where, multi: true })): the post-image check is skipped as "governed by the using-scoped where", and nousingexists to scope it #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 onlycheckscopes nothing, and a scopedwheresays nothing about the new row in any case. The skip was unconditional, so it also covered a declaredcheckthat differs fromusingand acheckdefaulted fromusing.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 existingsatisfiesCheckovermatchesFilterCondition: no predicate is compiled differently, and nothing is lowered into thewhere, 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. TheexplainAccessForCallerwiring is not touched.packages/objectql/src/engine.ts, the predicate-update branch ofupdate()(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:readPriorRows, bound to the composed AST), which the ruling says is read once and reused, and which already serves validation, thereadonlyWhenstrip and both per-row hook phases.So the security layer installs its judgement on the existing
OperationContext.postHookWriteImageCheckseam (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-rowafterUpdateresult), placed afterassertNoStrictDrops(), 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!Array.isArray(opCtx.data). Lines 3078-3083 setpostImage = nullforextractSingleId(opCtx) == nulland logged "governed by the using-scoped where".engine.ts(thepostHookWriteImageCheckcall ininsert()) handsevaluateevery live row of an array insert. The array fix is plugin-side only.getCallerPreImageis 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.usingplus a differingcheck.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).check-only-write-scope.test.tsandsecurity-plugin.test.tscarry 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..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-changesetis 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'sminorchangeset 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)
using, when no applicable policy declarescheck, 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 ausingpolicy are admitted and scoped exactly as before (pinned). Only a bulk write that moves rows out of theusingis 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).errorlog), as an insert already is.Tests
Patch round 2 (head
3247efeecd, after mergingorigin/main9bfbacbf8bas merge commit938b2acfde):rls-check-multi-row-writes.test.ts32 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;typecheckgreen for objectql and plugin-security (VERDICT command-exit 0 each).Patch round 1 (head
9fff66cfdc, after mergingorigin/main3fd3a4f91bas merge commita5ca1db166):rls-check-multi-row-writes.test.ts32 passed (32); plugin-security suite 123 files, 2348 tests passed (VERDICT command-exit 0 each). The merge brought no change underpackages/objectqlorpackages/plugins/plugin-security, so the objectql suite was not re-run; its last run is the one below, on a byte-identicalengine.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, realSecurityPlugin+ObjectQL.expected true to be false). In the unresolvable-policy cells the single-insert leg was refused and only the array leg was admitted.check-only row-level policy does not gate a bulk update (update(…, { where, multi: true })): the post-image check is skipped as "governed by the using-scoped where", and nousingexists to scope it #19950 cells: the check-only repro; per row not per change set (a matched row failing on an unchanged field refuses the whole write);usingplus a differingcheck; 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+checkin-scope admit, by-id unchanged, and USING-only move-out refused on both bulk and by-id.check(declared, or defaulted fromusing) is never evaluated on an ARRAY insert —engine.insert(object, [rows]), whichcreateManyDatacalls, stores rows a single-record insert refuses #19964 cells: the[admitted, refused]repro; over-fix admit; single insert unchanged; three refuse-every-insert configurations (an unresolvable soleusingoninsertand onall, an unresolvable declaredcheck).codePERMISSION_DENIED,status403 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).
typecheckis 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 emptygit diff HEAD. Resolution path: the plugin is imported relatively, and@objectstack/objectqlis aliased tosrc/index.tsin this package'svitest.config.ts, so nodist/sits between the mutation and the test.next()fail-closed guardGates
Patch round 2 (head
3247efeecd): the four comment lines this change rewrote now cite the surviving record, commita016f08b8a(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.mjsprobes 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 --commandsderived the same 68 families from the same 9 paths against merge base9bfbacbf8. All 68 were run;--rananswers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". 67 exit 0.check-empty-changesetexits 1 on.changeset/rls-check-defaults-to-using.mdonly.Patch round 1 (head
9fff66cfdc):dispatch-gates --commandsderived the same 68 families from the same 9 paths, now against merge base3fd3a4f91. All 68 were run;--rananswers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". 67 exit 0.check-empty-changesetexits 1 on.changeset/rls-check-defaults-to-using.mdonly (the deliberate correction under "Surface beyond the claim"). The changeset gates the seat named:check-adr-0087-registration --base origin/mainexits 0 ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"),check-changeset-no-major --base origin/mainexits 0, andpnpm check:changeset-gate-self-testsexits 0.Round 1 (head
6d28dfe98d):node scripts/pm/dispatch-gates.mjs --commandsderived 68 families from the 9 changed paths. All 68 were run with exit codes recorded.--rananswers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN".check-empty-changeset, the deliberate correction above.PREREQUISITE NOT MET(exit 3):check:dual-build-cjs-loads,check:i18nandcheck:type-check-debt. They pass after the workspace closure build.check-engine-split-ratiopasses after deepening the shallow clone to its window.eslint --no-inline-config --format jsonover the 7 changed.tsfiles 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 fullpnpm lintis CI's.Acceptance notes
beforeUpdatehooks and after the credential channel (encryptSecretFields), which runs above the strips on this branch. A refused bulk update whose payload carries asecretfield has therefore already minted itssys_secretrow. A validation refusal two lines below pays the same cost today. Moving the credential channel below the strips is a separate engine change. Achecknaming a secret field judges the stored reference.beforeUpdatehook rewrites a checked field, the bulk path is the stricter of the two. The by-id path is untouched here.__partialRowErrors): a failing row refuses the whole call rather than being reported per row..changeset/19950-rls-check-multi-row-writes.mdis 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:minorfor@objectstack/plugin-securityand@objectstack/objectql, a!headline,Clause-②: no (narrowing), an ADR-0087not-required (no-migration-prescription)disposition, and a BREAKING paragraph listing the newly refused writes and the remedy (declarecheckon the policy, or fix the data).origin/mainwas merged twice with merge commits, both clean with no regeneration owed: at3fd3a4f91b(a5ca1db166) and at9bfbacbf8b(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 forupdateis not computed with the write path's inputs —record.visibleis 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.security-plugin.ts); no behaviour change.