Skip to content

security: a 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 no using exists to scope it #19950

Closed

Description

@objectstack-fleet

Filed by the domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1, seat post #19357) from the at-tier review of PR #19946 (record 5807410719, ③, "Not carried at all"). ⛔ Filed unassigned and unlabelled: routing and grading are triage's. ⛔ Not a claim.

The runtime

packages/plugins/plugin-security/src/security-plugin.ts:3007-3014 (the seat read it on origin/main aeaaa44292). For an update, the post-image check runs only when extractSingleId(opCtx) finds one id. A bulk update takes the branch that logs [Security] RLS check on bulk update '<object>' is not post-image validated (governed by the using-scoped where); single-id writes are checked. and sets the post-image to null. The code comment gives the reason: "A bulk update (no single id) cannot form a post-image here — it is governed by the using-based AST scoping (step 3); we log and skip rather than guess."

The gap

That reasoning assumes the policy has a using. RowLevelSecurityPolicySchema accepts a policy that declares only check. For such a policy nothing scopes a bulk update's where, and nothing checks its post-image.

Measured by the #19946 round-2 reviewer on driver-sql through the real SecurityPlugin + ObjectQL, at ba90ddb578: under a check-only policy, update(…, { where, multi: true }) as the caller was admitted and stored (b1: open → closed), a transition the check was written to refuse.

Why it matters

A check is the write-side guarantee of a row-level policy. Today the guarantee holds for inserts and single-id updates, but a bulk update by where escapes it whenever the policy has no using. Related, not duplicates:

Seam: spec:RowLevelSecurityPolicySchema.check → runtime: plugin-security update post-image check (extractSingleId == null branch).

Dedupe words: bulk update check not validated · multi true update rls check skipped · check-only policy bulk write admitted · governed by the using-scoped where

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-by: #19942

    分诊首次定级:priority:p1 · security · bug · domain:services · pm:blocked(同文件在飞:#19942)

    Path: packages/plugins/plugin-security/src/security-plugin.ts → UPDATE 分支的 post-image 检查(extractSingleId(opCtx) == null 那一支)

    Triage: lands in plugin-security's write-check path ⇒ domain:services, security, bug, priority:p1; pm:blocked behind #19942 (dispatched, same file, same write-check seam); rationale: a check-only row-level policy — which the schema accepts — does not gate a bulk update by where, because the bulk branch skips the post-image check on the assumption that a using scopes the where, and no using exists; the write-side guarantee a check publishes is escaped (NORTH-STAR priority rule 1).

    分诊席(session_01Tw7jnJinGHvoGSi8aFkhPJ,座位贴 #6015),2026-09-24T04:31Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论)。

    前提复核(origin/main 2dc5889013)

    security-plugin.ts:3000-3013 原文仍是:

    // A bulk update (no single id) cannot form a post-image here — it is governed by the using-based AST scoping
    // (step 3); we log and skip rather than guess.
    ...
    if (targetId == null) { this.logger.warn?.(`[Security] RLS check on bulk update '…' is not post-image validated (governed by the using-scoped where)…`); postImage = null; }
    

    ⇒ 跳过的理由是"由 using 约束的 where 管着"。而一条只写 check、没有 using 的策略,where 没有被任何东西约束,post-image 也不查。#19946 第二轮复审在 driver-sql 上实测:b1: open → closed 被放行并写入,这正是 check 要拒绝的转换。

    为什么是 p1 + security

    check 是行级策略的写侧保证。今天它对插入和按 id 更新有效,但按条件批量更新只要策略没写 using 就能绕过。与 #19942 是同一条缝的另一面:#19942 是"只写 using 的策略不拦插入",本卡是"只写 check 的策略不拦批量更新"。

    方向 —— ⛔ 否决窗口,不是许可闸门:批量更新同样逐行检查,不再跳过

    与主流一致:PostgreSQL 的 WITH CHECK 对 UPDATE 写出的每一行新版本都生效,批量也不例外。

    • 做法:按调用者上下文读出这次会被改的行,逐行合并变更集,再做 check,任一行不通过就整体拒绝。
    • 如果某种情况下形成不了逐行 post-image,就拒绝这次批量更新(fail-closed),⛔ 不是记一条日志然后放行。
    • 已声明 using 的策略,行为保持不变(对照测试)。

    与 #19942 的关系

    两张卡改的是同一个文件、同一条"写侧检查"缝,#19942 已派发。⇒ 按同文件串行排在它后面。⚠️ 如果 #19942 的承接人愿意,在同一轮里一起修更省,两张卡的对照测试可以共用一套夹具。本卡不强求。

    验收

    • 先把复审的复现写成一条会失败的测试:只写 check 的策略下,update(…, { where, multi: true }) 把 open 改成 closed 必须被拒绝。
    • 已声明 using 的策略、按 id 更新两条路径的行为不变。
    • 那条 "governed by the using-scoped where" 的注释和告警要改成实际的逻辑。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlocked 2026-09-24T12:51Z by the domain:services seat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021): Blocked-by: #19942 resolved. #19942 closed 2026-09-24T05:45Z; PR #19952 landed as b7c792bd2e.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01Evb5jFDZGKQE9KG4jbMfMF
    Branch: claude/issue-19950-rls-check-multi-row-writes
    Worktree: objectstack-issue-19950
    Domain: domain:services
    Seat: domain:services#1
    File surface: packages/plugins/plugin-security/src/security-plugin.ts, region only: step 3.6, the post-image check block (the !Array.isArray(opCtx.data) guard, the INSERT postHookWriteImageCheck seam, and the UPDATE extractSingleId(opCtx) == null branch), plus any per-row pre-image helper it needs; new test file(s) under packages/plugins/plugin-security/src/; .changeset/19950-*.md. If the engine's array-insert path does not hand every row to the seam, the producer-side fix is in packages/objectql/src/engine.ts (the postHookWriteImageCheck call site), which is domain:engine, and the seat files the cross-lane declaration. ⛔ Not the explain wiring (that is #19963's region). ⛔ No packages/spec. (Stop on breach; explain in the report.)
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: "no path-derived mandate … floor sonnet · default opus · ceiling fable" ⇒ default tier)
    Clause-②: no (narrowing)
    Thread-read: 5814470088
    Serial constraints cleared: PR #19952 (#19942, same file, same step) landed b7c792bd2e, and the dev works on top of it. PR #19947 (#19886) touches only plugin-security TEST files (rls-check-array-comparand-refusal.test.ts, rls-list-literal-comparand-fails-closed.test.ts), not security-plugin.ts. PR #19962 (#19953) is packages/spec + packages/lint + docs, no plugin-security. #19963 (dispatched this round) is the same file but a disjoint region (explain wiring). Its pins are about explain versus the by-id PATCH, and this fold leaves the by-id path unchanged, so no pin crosses.

    Fold (family dispatch), answered against the five gates. Members: #19950 (chain head) and #19964.

    1. Same defect shape and same repair: the step-3.6 post-image check assumes one row, so a multi-row write escapes it. security: a 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 no using exists to scope it #19950 is a bulk UPDATE by where, where the post-image is set to null and skipped. [finding] security: a row-level check (declared, or defaulted from using) is never evaluated on an ARRAY insert — engine.insert(object, [rows]), which createManyData calls, stores rows a single-record insert refuses #19964 is an ARRAY insert, where the guard excludes arrays and no seam is installed. The repair is the same: judge every written row, and refuse the whole write when a per-row image cannot be formed.
    2. Same package and region.
    3. Both graded by triage (p1 · security · bug); neither is in the decision box.
    4. Each is independently checkable. For security: a 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 no using exists to scope it #19950: under a check-only policy, update(…, { where, multi: true }) that turns a row into a state the check refuses is refused. For [finding] security: a row-level check (declared, or defaulted from using) is never evaluated on an ARRAY insert — engine.insert(object, [rows]), which createManyData calls, stores rows a single-record insert refuses #19964: engine.insert(object, [ok, refused]) is refused.
    5. Excluded: 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 is the same file but a different defect (explain wiring) and a different repair.

    One PR, with one Fixes # line per member and one commit per card.
    Clause-② is no (narrowing) (arm added by the seat at review on PR #19988, following the #19952 precedent: enforcement now refuses bulk and array writes it used to admit): the change makes runtime enforcement refuse writes it admits today. That is a runtime security-behaviour change, which is the Clause-② negative boundary. No published accept set is widened and no public surface is added.

  4. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim correction (appended; the claim above stands) · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-24T13:46Z

    The claim's Serial constraints cleared: line missed one same-file in-flight PR, found this patrol by intersecting every open PR's file list with the dev branch's hot files.


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 19950,
    "status": "done",
    "branch": "claude/issue-19950-rls-check-multi-row-writes",
    "pr": "#19988",
    "session": "session_01Evb5jFDZGKQE9KG4jbMfMF — the harness-stamped id this run carries (subagent = the dispatching seat's)",
    "premise_still_valid": true,
    "fold": {
    "chain_head": 19950,
    "members": [
    19964
    ],
    "commits": {
    "19950": "6d28dfe98d",
    "19964": "1fb616ee30"
    }
    },
    "summary": "Both multi-row write shapes are now judged row by row against the row-level check (declared or defaulted from using), with the existing PERMISSION_DENIED/403 refusal and nothing stored; one failing row refuses the whole write. #19964: step 3.6 now installs the existing post-image seam for an array insert (the engine already hands it every live row). #19950: the producer is the ENGINE, measured: the rows a predicate update changes are the ones the middleware-COMPOSED AST selects (security step 3 and plugin-sharing compose after step 3.6), so the middleware installs the seam and objectql update() runs it on the predicate branch after assertNoStrictDrops() over every matched row (the D7 readPriorRows memo) merged with the final payload; a falsy payload id (engine: not a row address) is judged both ways. By-id update and single insert unchanged; explain wiring untouched; no packages/spec change. Assignee os-sales was already set (untouched).",
    "files_changed": [
    ".changeset/19950-rls-check-multi-row-writes.md (new)",
    ".changeset/rls-check-defaults-to-using.md (one bullet: deliberate correction of a pending note this PR falsifies)",
    "packages/objectql/src/engine.ts (predicate-branch seam call + seam doc comments; domain:engine, cross-lane)",
    "packages/plugins/plugin-security/src/security-plugin.ts (step 3.6, seam type, post-next fail-closed guard message, preImageFloorOpts note)",
    "packages/plugins/plugin-security/src/rls-check-multi-row-writes.test.ts (new, 32 cells, 2 drivers)",
    "packages/plugins/plugin-security/src/check-only-write-scope.test.ts (engine double honours the predicate seam; title/comment)",
    "packages/plugins/plugin-security/src/security-plugin.test.ts (runEngineWriteBody: array rows; predicate update matches no rows in this table-less double)",
    "packages/plugins/plugin-security/src/rls-check-defaults-to-using.test.ts (comment only)",
    "packages/plugins/plugin-security/src/rls-phantom-column-negation.test.ts (comment only)"
    ],
    "gates": {
    "head": "6d28dfe98d",
    "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths; change set from merge base 2c1011b, 9 paths)",
    "reconciliation": "--ran: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN (exit 0)",
    "red": [
    "1 node scripts/check-empty-changeset.mjs --base origin/main — DELIBERATE CORRECTION class (ruling D on #17712): kept per the gate's own remedy; confirmation requested on the PR"
    ],
    "prerequisite_cleared": [
    "check:dual-build-cjs-loads, check:i18n, check:type-check-debt first exit 3 (no dist) → 0 after turbo build of ./packages/* closure",
    "check-engine-split-ratio first exit 2 (shallow clone) → 0 after git fetch --shallow-since=2026-06-19 origin main"
    ],
    "exits": [
    "0 node scripts/check-adr-0087-registration.mjs --base origin/main",
    "0 node scripts/check-adr-0087-registration.mjs --self-test",
    "0 node scripts/check-changeset-no-major.mjs --base origin/main",
    "0 node scripts/check-changeset-no-major.mjs --self-test",
    "0 node scripts/check-ci-filter-parity.mjs",
    "0 node scripts/check-closing-keyword-parity.mjs",
    "0 node scripts/check-closing-keyword-parity.mjs --self-test",
    "0 node scripts/check-comment-mask-adoption.mjs",
    "0 node scripts/check-comment-mask-adoption.mjs --self-test",
    "0 node scripts/check-comment-mask-corpus.mjs",
    "1 node scripts/check-empty-changeset.mjs --base origin/main",
    "0 node scripts/check-empty-changeset.mjs --self-test",
    "0 node scripts/check-engine-split-ratio.mjs --days 90",
    "0 node scripts/check-engine-split-ratio.mjs --self-test",
    "0 node scripts/check-keyed-text-bounds.mjs",
    "0 node scripts/check-keyed-text-bounds.mjs --self-test",
    "0 node scripts/check-platform-object-tenancy-census.mjs",
    "0 node scripts/check-platform-object-tenancy-census.mjs --self-test",
    "0 node scripts/check-plugin-teardown-shape.mjs",
    "0 node scripts/check-plugin-teardown-shape.mjs --self-test",
    "0 node scripts/check-registry-log-declared.mjs",
    "0 node scripts/check-registry-log-declared.mjs --self-test",
    "0 node scripts/check-rest-log-spy-declared.mjs",
    "0 node scripts/check-rest-log-spy-declared.mjs --self-test",
    "0 node scripts/check-system-context-census.mjs",
    "0 node scripts/check-system-context-census.mjs --self-test",
    "0 node scripts/check-tenant-audit-census.mjs",
    "0 node scripts/check-tenant-audit-census.mjs --self-test",
    "0 node scripts/check-undeclared-dep-imports.mjs",
    "0 node scripts/check-undeclared-dep-imports.mjs --self-test",
    "0 node scripts/docs-audit/check-affected-docs.mjs",
    "0 node scripts/docs-audit/check-drift-comment.mjs",
    "0 node scripts/pm/release-rehearsal-clone.mjs --self-test",
    "0 pnpm --filter @objectstack/spec run check:duration-unit-keys",
    "0 pnpm check:changeset-gate-self-tests",
    "0 pnpm check:cross-package-test-inputs",
    "0 pnpm check:dispatcher-error-vocabulary",
    "0 pnpm check:doc-authoring",
    "0 pnpm check:driver-memory-census",
    "0 pnpm check:dts-closure",
    "0 pnpm check:dual-build-cjs-loads",
    "0 pnpm check:durability-log-level",
    "0 pnpm check:engine-double-contract",
    "0 pnpm check:gitlink-declared",
    "0 pnpm check:i18n",
    "0 pnpm check:i18n-stale-fill",
    "0 pnpm check:issue-citations",
    "0 pnpm check:lean-entry-closure",
    "0 pnpm check:logger-receiver-detach",
    "0 pnpm check:nul-bytes",
    "0 pnpm check:objectql-double-limit",
    "0 pnpm check:objectui-changeset",
    "0 pnpm check:org-identifier",
    "0 pnpm check:page-declaration-shape",
    "0 pnpm check:pm-changeset-deadline-census",
    "0 pnpm check:published-files",
    "0 pnpm check:query-options-erasure",
    "0 pnpm check:refd-timer-probe",
    "0 pnpm check:slot-lookup",
    "0 pnpm check:sourcemap-no-sources-content",
    "0 pnpm check:stack-collection-maps",
    "0 pnpm check:swallow-census-controls",
    "0 pnpm check:test-source-alias",
    "0 pnpm check:tier-file-adoption",
    "0 pnpm check:type-check-coverage",
    "0 pnpm check:type-check-debt",
    "0 pnpm check:watch-hint-literal",
    "0 pnpm check:where-matcher"
    ]
    },
    "tests": "All at 6d28dfe. FAILING FIRST (unmodified tree): rls-check-multi-row-writes.test.ts "Tests 18 failed | 12 passed (30)", every negative cell "expected a refusal, got a completed …: expected true to be false"; unresolvable-policy cells failed only on the array leg (single-insert leg refused). AFTER: that file "Tests 32 passed (32)"; plugin-security full "Test Files 123 passed (123) / Tests 2348 passed (2348)" (VERDICT command-exit 0); objectql local project 2 halves "154 passed / 2419" + "154 passed / 2758", repo project "1 passed / 5" (VERDICT command-exit 0 each); typecheck objectql + plugin-security VERDICT command-exit 0 (plugin-security test-typecheck debt 0 files / 0 errors); eslint --no-inline-config --format json on the 7 changed .ts files: 7 files linted, 0 errors, 0 warnings (population = config block **/*.{ts,…}; type-aware linting never enabled, eslint.config.mjs:326-328, so no untouched file can move). Refusal cells assert code PERMISSION_DENIED + status 403 + developer message, and read stored rows under a system context. ABLATIONS (each on committed HEAD via scripts/ablation-replace.mjs WRAP: anchor 1→0, blob changed; restore blob == HEAD, git diff HEAD empty; subjects resolve to src — plugin imported relatively, @objectstack/objectql aliased to src/index.ts in vitest.config.ts — so no dist in the path): (1) restore non-array guard → 8 failed (4 array cells ×2); (2) never install the predicate seam → 12 failed (6 bulk cells ×2); (3) engine judges payload only → 6 failed (per-row, falsy-id, and the USING-only in-scope control falsely refused); (4) seam only when id == null → 2 failed (falsy id admitted); (5) engine never calls the seam → 16 failed (refusals carry "executed without the row-level CHECK being evaluated", controls refused); (6) disable the fail-closed guard → 2 failed (fail-closed cell admitted).",
    "mcp_calls": "0 — no MCP tool used",
    "api_writes": "3 relay strokes, each POST /repos/objectstack-ai/objectstack/dispatches (session) executed as objectstack-fleet[bot]: POST /repos/objectstack-ai/objectstack/pulls (draft, PR 19988); POST /repos//issues/19950/comments; POST /repos//issues/19964/comments. Labels: 0 writes (dispatch names none; skip-changeset does not apply). git (not REST): 5 pushes to the branch + 1 --force-with-lease=branch:0fafcca087 to regroup into one commit per card (AGENTS.md §3, all five conditions held; no PR existed yet).",
    "deviations": [
    "Landing beyond the claim surface: packages/objectql/src/engine.ts (domain:engine). The claim named engine.ts only for the array-insert case; measurement put the #19950 producer there instead (array-insert needed no engine change). The dispatch text authorises a producer-side fix with landing + reason stated; the seat owes the cross-lane declaration and the claim file-surface amendment.",
    "Existing plugin-security test files edited (2 engine doubles, 2 comment-only) — their premises/doubles change with the fix; declared in the PR body.",
    "A foreign pending changeset (.changeset/rls-check-defaults-to-using.md, from #19952) corrected in place; keeps check-empty-changeset red until a person confirms (the gate's prescribed path for this class).",
    "Gate derivation ran with NO paths (role-file rule: the tool derives from the merge base) rather than the dispatch's "--commands [each changed path]"; flagged, not silently chosen. The derivation listed exactly the 9 changed paths.",
    "Shared-state side effects in this container: the shared clone was deepened (git fetch --shallow-since=2026-06-19 origin main) for check-engine-split-ratio; turbo build --force for objectql + plugin-security wrote their closure into the shared turbo cache."
    ],
    "open_questions": [
    {
    "question": "Pending release note correction: keep the one-bullet edit to .changeset/rls-check-defaults-to-using.md (check-empty-changeset stays red until confirmed), or restore it and let the two entries contradict each other in one release?",
    "options": [
    "A keep the correction; a person confirms on PR 19988 (the gate's ruling-D remedy)",
    "B restore it from base; this PR's entry alone states the supersession"
    ],
    "recommendation": "A, because restoring republishes a sentence this PR makes false, and ruling D on #17712 names A as the remedy for this class"
    },
    {
    "question": "Triage said a using-declared policy's behaviour stays unchanged. Implemented reading: in-scope bulk updates under a using policy are admitted and scoped as before (pinned); a bulk update that moves rows OUT of the using (no check declared) is now refused by the defaulted check, the answer by-id gives since #19952. Confirm, or restrict the bulk judgement to declared checks only?",
    "options": [
    "A keep: bulk judged by the same checkParts as by-id (declared or defaulted)",
    "B judge bulk only when a policy declares check"
    ],
    "recommendation": "A, because B would re-open the declared≠enforced gap RowLevelSecurityPolicySchema.check publishes ("defaults to USING") on one call site, and makes bulk and by-id disagree"
    },
    {
    "question": "Clause-② arm: the claims say "Clause-②: no" (copied verbatim; changeset is patch). #19952 declared "Clause-②: no (narrowing)" (BREAKING, minor, adr-0087 marker) for the same class of change (enforcement now refuses writes it admitted). Reconcile?",
    "options": [
    "A keep no (patch) as claimed",
    "B re-declare no (narrowing): minor + BREAKING + adr-0087 disposition marker"
    ],
    "recommendation": "Seat decision — the dev did not self-select an arm (role file: copy the claim line, never invent one); B matches the #19952 precedent"
    }
    ],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · the by-id UPDATE is still judged in the middleware on the PRE-hook change set, so a beforeUpdate stamp that rewrites a checked field lands unjudged; insert-check-post-image.test.ts:57-61 records it as measured and filed as #16790, but GET /issues/16790 answers 404 (NOT MEASURED where it is tracked now; not re-measured here, so not graded class a) · noted in PR Acceptance notes · dedupe: "by-id update check pre-hook image" "beforeUpdate stamp checked field admitted" "16790"",
    "carrier: 承接者:无 · predicate-update refusal cost: encryptSecretFields mints a sys_secret row before the per-row judgement (and before validation) on the multi branch · noted in PR Acceptance notes",
    "carrier: 承接者:无 · on an object with no per-row hooks the checked predicate update reads all matched rows, not bounded by the per-row hook ceiling; partial-row array insert (__partialRowErrors) is refused whole rather than per row · noted in PR Acceptance notes"
    ]
    }

  6. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Review — substance ACCEPTED, one patch round (Clause-② arm) · PR #19988 · head 6d28dfe98d · domain:services seat · 2026-09-24T14:51Z

    Seat session_01Evb5jFDZGKQE9KG4jbMfMF. The fold covers #19950 (chain head) and #19964 (member). Checked against GitHub and origin/main, ⛔ not against the report's own account.

    Checklist

    Open questions, answered by the seat

    Accepted deviations (the claim's file surface is revised by this line to include them)

    • packages/objectql/src/engine.ts: this is the measured producer, as the dispatch pre-authorised. A cross-seat declaration goes to the domain:engine seat post.
    • The four existing plugin-security test files whose doubles and comments move with the fix.
    • The foreign pending changeset (see Q1).

    Out-of-scope findings, one line each:

    Landing gates: the patch round's head green on every check (bar the by-design Check Changeset row); the maintainer's confirmation of Q1 quoted on the PR; a merge of origin/main if PR #19984 (#19963, the explain region of the same file) lands first; check-governed-merges --pr 19988.


    Generated by Claude Code

  7. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 19950,
    "status": "done",
    "round": "patch round 1 (seat review of PR 19988 at 6d28dfe)",
    "branch": "claude/issue-19950-rls-check-multi-row-writes",
    "pr": "#19988",
    "head": "9fff66cfdc",
    "session": "session_01Evb5jFDZGKQE9KG4jbMfMF — the harness-stamped id this run carries (subagent = the dispatching seat's)",
    "premise_still_valid": true,
    "summary": "Seat answers applied: Q2 A (no code change), Q3 B (changeset re-declared a narrowing), Q1 keep (the red check-empty-changeset on the pending #19952 note is expected). origin/main merged at 3fd3a4f as merge commit a5ca1db (clean, no regen owed; PR #19984 / #19963 had not landed). Changeset commit 9fff66c. No force-push; identity re-checked (both newest Claim: comments name this branch). The worktree had been removed at the end of round 1; it was recreated on the same branch at the pushed head 6d28dfe before this round.",
    "files_changed": [
    ".changeset/19950-rls-check-multi-row-writes.md: both packages patch → minor; headline fix(plugin-security, objectql)!:; Clause-②: no (narrowing) line; adr-0087 not-required (no-migration-prescription) marker in the #19952 shape; BREAKING paragraph listing the newly refused writes + Remedy (declare check / fix the data); What-does-not-change list",
    "merge commit a5ca1db (origin/main 3fd3a4f); no other file of this PR changed this round"
    ],
    "gates": {
    "head": "9fff66cfdc",
    "seat_named": [
    "0 node scripts/check-adr-0087-registration.mjs --base origin/main — "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition" ([BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription))",
    "0 node scripts/check-changeset-no-major.mjs --base origin/main — "This diff introduces no major bump"; level axis NOT APPLICABLE without a PR payload, so also run with --event carrying the NEW body: exit 0, "LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages//src/ it moves is graded patch"",
    "0 pnpm check:changeset-gate-self-tests"
    ],
    "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths): same 68 families, same 9 paths, merge base 3fd3a4f",
    "reconciliation": "--ran: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN (exit 0)",
    "red": [
    "1 node scripts/check-empty-changeset.mjs --base origin/main — on .changeset/rls-check-defaults-to-using.md only (deliberate correction; with the maintainer per the seat)"
    ],
    "exits": [
    "0 node scripts/check-adr-0087-registration.mjs --base origin/main",
    "0 node scripts/check-adr-0087-registration.mjs --self-test",
    "0 node scripts/check-changeset-no-major.mjs --base origin/main",
    "0 node scripts/check-changeset-no-major.mjs --self-test",
    "0 node scripts/check-ci-filter-parity.mjs",
    "0 node scripts/check-closing-keyword-parity.mjs",
    "0 node scripts/check-closing-keyword-parity.mjs --self-test",
    "0 node scripts/check-comment-mask-adoption.mjs",
    "0 node scripts/check-comment-mask-adoption.mjs --self-test",
    "0 node scripts/check-comment-mask-corpus.mjs",
    "1 node scripts/check-empty-changeset.mjs --base origin/main",
    "0 node scripts/check-empty-changeset.mjs --self-test",
    "0 node scripts/check-engine-split-ratio.mjs --days 90",
    "0 node scripts/check-engine-split-ratio.mjs --self-test",
    "0 node scripts/check-keyed-text-bounds.mjs",
    "0 node scripts/check-keyed-text-bounds.mjs --self-test",
    "0 node scripts/check-platform-object-tenancy-census.mjs",
    "0 node scripts/check-platform-object-tenancy-census.mjs --self-test",
    "0 node scripts/check-plugin-teardown-shape.mjs",
    "0 node scripts/check-plugin-teardown-shape.mjs --self-test",
    "0 node scripts/check-registry-log-declared.mjs",
    "0 node scripts/check-registry-log-declared.mjs --self-test",
    "0 node scripts/check-rest-log-spy-declared.mjs",
    "0 node scripts/check-rest-log-spy-declared.mjs --self-test",
    "0 node scripts/check-system-context-census.mjs",
    "0 node scripts/check-system-context-census.mjs --self-test",
    "0 node scripts/check-tenant-audit-census.mjs",
    "0 node scripts/check-tenant-audit-census.mjs --self-test",
    "0 node scripts/check-undeclared-dep-imports.mjs",
    "0 node scripts/check-undeclared-dep-imports.mjs --self-test",
    "0 node scripts/docs-audit/check-affected-docs.mjs",
    "0 node scripts/docs-audit/check-drift-comment.mjs",
    "0 node scripts/pm/release-rehearsal-clone.mjs --self-test",
    "0 pnpm --filter @objectstack/spec run check:duration-unit-keys",
    "0 pnpm check:changeset-gate-self-tests",
    "0 pnpm check:cross-package-test-inputs",
    "0 pnpm check:dispatcher-error-vocabulary",
    "0 pnpm check:doc-authoring",
    "0 pnpm check:driver-memory-census",
    "0 pnpm check:dts-closure",
    "0 pnpm check:dual-build-cjs-loads",
    "0 pnpm check:durability-log-level",
    "0 pnpm check:engine-double-contract",
    "0 pnpm check:gitlink-declared",
    "0 pnpm check:i18n",
    "0 pnpm check:i18n-stale-fill",
    "0 pnpm check:issue-citations",
    "0 pnpm check:lean-entry-closure",
    "0 pnpm check:logger-receiver-detach",
    "0 pnpm check:nul-bytes",
    "0 pnpm check:objectql-double-limit",
    "0 pnpm check:objectui-changeset",
    "0 pnpm check:org-identifier",
    "0 pnpm check:page-declaration-shape",
    "0 pnpm check:pm-changeset-deadline-census",
    "0 pnpm check:published-files",
    "0 pnpm check:query-options-erasure",
    "0 pnpm check:refd-timer-probe",
    "0 pnpm check:slot-lookup",
    "0 pnpm check:sourcemap-no-sources-content",
    "0 pnpm check:stack-collection-maps",
    "0 pnpm check:swallow-census-controls",
    "0 pnpm check:test-source-alias",
    "0 pnpm check:tier-file-adoption",
    "0 pnpm check:type-check-coverage",
    "0 pnpm check:type-check-debt",
    "0 pnpm check:watch-hint-literal",
    "0 pnpm check:where-matcher"
    ]
    },
    "tests": "At 9fff66c, after pnpm install --frozen-lockfile (exit 0) and a turbo build of the ./packages/* closure (72/72, VERDICT command-exit 0): rls-check-multi-row-writes.test.ts "Tests 32 passed (32)" (VERDICT command-exit 0); plugin-security full suite "Test Files 123 passed (123) / Tests 2348 passed (2348)" (VERDICT command-exit 0). objectql suite NOT re-run this round (declared narrowing): the merge brought no change under packages/objectql or packages/plugins/plugin-security, and engine.ts is byte-identical to round 1, where objectql ran 309 files / 5182 tests green. No code changed this round, so no ablation was re-run; round-1 ablations stand.",
    "mcp_calls": "0 — no MCP tool used",
    "api_writes": "1 relay stroke: POST /repos/objectstack-ai/objectstack/dispatches (session) executed as objectstack-fleet[bot] → POST /repos//issues/19950/comments (this report). PR body NOT patched (role file); the new text is in pr_body_new for the seat to apply. git (not REST): 1 normal push 6d28dfe..9fff66c.",
    "deviations": [
    "The worktree was recreated (git worktree add on the existing branch) because round 1 removed it as its cleanup step; same branch, same pushed head."
    ],
    "open_questions": [],
    "out_of_scope_findings": [],
    "pr_body_new": "Fixes #19950\nFixes #19964\nClause-②: no (narrowing)\n\n## What this fixes\n\nA 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:\n\n- #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.\n- #19950, predicate update (multi: true, no row address). Step 3.6 skipped the new-row check and logged "governed by the using-scoped where". A policy that declares only check scopes nothing, and a scoped where says nothing about the new row in any case. The skip was unconditional, so it also covered a declared check that differs from using and a check defaulted from using.\n\nBoth 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.\n\n## Landing: two packages, and why the engine is one of them\n\n- 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.\n- 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:\n - 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.\n - 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.\n \n So the security layer installs its judgement on the existing OperationContext.postHookWriteImageCheck seam (the one #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.\n\n## Mechanism hypotheses (dispatch Section 2), as measured on 2c1011b01b\n\n1. 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".\n2. Held. engine.ts (the postHookWriteImageCheck call in insert()) hands evaluate every live row of an array insert. The array fix is plugin-side only.\n3. 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.\n4. Held. The skip was unconditional. A cell pins a using plus a differing check.\n\nOne 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.\n\n## Surface beyond the claim, with reasons\n\n- packages/objectql/src/engine.ts: see above (cross-lane, domain:engine).\n- 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.\n- A pending release note, corrected in place: .changeset/rls-check-defaults-to-using.md (from #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 #17712). Its prescribed remedy is to keep the correction and get it confirmed on the PR; restoring the file would publish a false sentence. The seat has put it to the maintainer for that confirmation; the red is expected until then.\n\n## Behaviour that changes (all in the refusing direction)\n\n- A predicate update under a check-only policy is refused when any matched row's new image fails the check.\n- 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 #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).\n- An array insert is refused when any row fails, including every configuration that already refused each single insert.\n- A host that installs the judgement on a predicate update and never runs it is refused (403, error log), as an insert already is.\n\n## Tests\n\nPatch round (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.\n\nRound 1 (head 6d28dfe98d):\n\n\nNew: 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.\n\n- Failing first, on the unmodified tree: 18 red and 12 green (the controls). Every negative cell failed as "admitted" (expected true to be false). In the unresolvable-policy cells the single-insert leg was refused and only the array leg was admitted.\n- #19950 cells: the check-only repro; per row not per change set (a matched row failing on an unchanged field refuses the whole write); using plus a differing check; fail-closed (an inner middleware strips the seam, and the write is refused with "the update on 'qa_ticket' was executed without the row-level CHECK being evaluated"); falsy payload id. Controls: over-fix admit, USING-only in-scope admit and scoped, using+check in-scope admit, by-id unchanged, and USING-only move-out refused on both bulk and by-id.\n- #19964 cells: the [admitted, refused] repro; over-fix admit; single insert unchanged; three refuse-every-insert configurations (an unresolvable sole using on insert and on all, an unresolvable declared check).\n- Every refusal asserts code PERMISSION_DENIED, status 403 and the developer half naming the gate and verb, then reads the stored rows back under a system context.\n\nSuites: 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).\n\nAblations: 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.\n\n| # | mutation | result |\n|---|---|---|\n| 1 | restore the non-array guard for inserts | 8 red: the 4 array-insert negative cells x 2 drivers |\n| 2 | never install the seam on a predicate update | 12 red: the 6 bulk negative cells x 2 |\n| 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) |\n| 4 | install only when the id is null (falsy counts as by-id) | 2 red: the falsy-id cell, admitted |\n| 5 | engine never calls the seam | 16 red: refusals now carry the not-evaluated message, controls refused |\n| 6 | disable the post-next() fail-closed guard | 2 red: the fail-closed cell, admitted |\n\n## Gates\n\nPatch round (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.\n\nRound 1 (head 6d28dfe98d):\n\n\n- 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".\n- 67 exit 0. One exits 1: check-empty-changeset, the deliberate correction above.\n- 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.\n- 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.\n\n## Acceptance notes\n\n- 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.\n- 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.\n- 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.\n- Partial-row array insert (__partialRowErrors): a failing row refuses the whole call rather than being reported per row.\n- 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.\n- .changeset/19950-rls-check-multi-row-writes.md is declared a narrowing, per the seat's ruling and following #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).\n- origin/main was merged at 3fd3a4f91b (merge commit a5ca1db166, clean, no regeneration owed). PR #19984 (#19963, the explain wiring in the same file) had not landed by then.\n\n---\n_Generated by Claude Code_\n"
    }

  8. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 19950,
    "status": "done",
    "round": "patch round 2 + the seat correction folded in (PR 19988, from head 9fff66c)",
    "branch": "claude/issue-19950-rls-check-multi-row-writes",
    "pr": "#19988",
    "head": "3247efeecd",
    "session": "session_01Evb5jFDZGKQE9KG4jbMfMF — the harness-stamped id this run carries (subagent = the dispatching seat's)",
    "premise_still_valid": true,
    "summary": "check-issue-citations is fixed: the four comment lines this change rewrote now read "[insert-check commit a016f08] (the original card no longer resolves)", with no #N on them. Untouched [#16608] lines are left alone. The round-2 instruction was followed first (commit 16a28ad kept the number and added prose): measured, it stays exit 2 with the same 4 findings, because extractCitations has no prose exemption. Before the correction arrived, the "name a target that resolves" arm had already been measured green in a throwaway detached worktree. origin/main was merged again at 9bfbacb (merge commit 938b2ac, clean; #19984 not landed). Commits this round: 938b2ac (merge), 16a28ad (round-2 prose), 3247efe (correction). All normal pushes; no rebase, no force-push.",
    "files_changed": [
    "packages/plugins/plugin-security/src/security-plugin.ts: 4 comment lines (docblock of the seam type, the middleware seam-holder comment, the newWriteImageCheck comment, the insert-branch comment): [#16608] → [insert-check commit a016f08] (the original card no longer resolves)",
    "merge commit 938b2ac (origin/main 9bfbacb)"
    ],
    "gates": {
    "head": "3247efeecd",
    "citations_board_probe": [
    "GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs → exit 0: "board: probed (2 citations), frontier #19993, 3 numbers resolve · citations judged: 9 across 2 file(s) · 9 resolves · ✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference)."",
    "pnpm check:issue-citations && node scripts/check-issue-citations.mjs (GITHUB_TOKEN from GH_TOKEN) → exit 0 (self-test 73 cases / 7 batteries ✅; live ✅)",
    "at 16a28ad (round-2 shape, number kept + prose) the same probe → exit 2: "4 citation(s) THIS CHANGE ADDS do not resolve" [allocated-but-absent] #16608 at :207 :1818 :3080 :3098",
    "before any edit, at merge 938b2ac → exit 2, the 4 findings CI reported (reproduced locally with the board probe)"
    ],
    "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths): same 68 families, same 9 paths, merge base 9bfbacb",
    "reconciliation": "--ran: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN (exit 0)",
    "red": [
    "1 node scripts/check-empty-changeset.mjs --base origin/main — on .changeset/rls-check-defaults-to-using.md only (deliberate correction; confirmed by a same-head at-tier review PASS record per #19970)"
    ],
    "exits": [
    "0 node scripts/check-adr-0087-registration.mjs --base origin/main",
    "0 node scripts/check-adr-0087-registration.mjs --self-test",
    "0 node scripts/check-changeset-no-major.mjs --base origin/main",
    "0 node scripts/check-changeset-no-major.mjs --self-test",
    "0 node scripts/check-ci-filter-parity.mjs",
    "0 node scripts/check-closing-keyword-parity.mjs",
    "0 node scripts/check-closing-keyword-parity.mjs --self-test",
    "0 node scripts/check-comment-mask-adoption.mjs",
    "0 node scripts/check-comment-mask-adoption.mjs --self-test",
    "0 node scripts/check-comment-mask-corpus.mjs",
    "1 node scripts/check-empty-changeset.mjs --base origin/main",
    "0 node scripts/check-empty-changeset.mjs --self-test",
    "0 node scripts/check-engine-split-ratio.mjs --days 90",
    "0 node scripts/check-engine-split-ratio.mjs --self-test",
    "0 node scripts/check-keyed-text-bounds.mjs",
    "0 node scripts/check-keyed-text-bounds.mjs --self-test",
    "0 node scripts/check-platform-object-tenancy-census.mjs",
    "0 node scripts/check-platform-object-tenancy-census.mjs --self-test",
    "0 node scripts/check-plugin-teardown-shape.mjs",
    "0 node scripts/check-plugin-teardown-shape.mjs --self-test",
    "0 node scripts/check-registry-log-declared.mjs",
    "0 node scripts/check-registry-log-declared.mjs --self-test",
    "0 node scripts/check-rest-log-spy-declared.mjs",
    "0 node scripts/check-rest-log-spy-declared.mjs --self-test",
    "0 node scripts/check-system-context-census.mjs",
    "0 node scripts/check-system-context-census.mjs --self-test",
    "0 node scripts/check-tenant-audit-census.mjs",
    "0 node scripts/check-tenant-audit-census.mjs --self-test",
    "0 node scripts/check-undeclared-dep-imports.mjs",
    "0 node scripts/check-undeclared-dep-imports.mjs --self-test",
    "0 node scripts/docs-audit/check-affected-docs.mjs",
    "0 node scripts/docs-audit/check-drift-comment.mjs",
    "0 node scripts/pm/release-rehearsal-clone.mjs --self-test",
    "0 pnpm --filter @objectstack/spec run check:duration-unit-keys",
    "0 pnpm check:changeset-gate-self-tests",
    "0 pnpm check:cross-package-test-inputs",
    "0 pnpm check:dispatcher-error-vocabulary",
    "0 pnpm check:doc-authoring",
    "0 pnpm check:driver-memory-census",
    "0 pnpm check:dts-closure",
    "0 pnpm check:dual-build-cjs-loads",
    "0 pnpm check:durability-log-level",
    "0 pnpm check:engine-double-contract",
    "0 pnpm check:gitlink-declared",
    "0 pnpm check:i18n",
    "0 pnpm check:i18n-stale-fill",
    "0 pnpm check:issue-citations",
    "0 pnpm check:lean-entry-closure",
    "0 pnpm check:logger-receiver-detach",
    "0 pnpm check:nul-bytes",
    "0 pnpm check:objectql-double-limit",
    "0 pnpm check:objectui-changeset",
    "0 pnpm check:org-identifier",
    "0 pnpm check:page-declaration-shape",
    "0 pnpm check:pm-changeset-deadline-census",
    "0 pnpm check:published-files",
    "0 pnpm check:query-options-erasure",
    "0 pnpm check:refd-timer-probe",
    "0 pnpm check:slot-lookup",
    "0 pnpm check:sourcemap-no-sources-content",
    "0 pnpm check:stack-collection-maps",
    "0 pnpm check:swallow-census-controls",
    "0 pnpm check:test-source-alias",
    "0 pnpm check:tier-file-adoption",
    "0 pnpm check:type-check-coverage",
    "0 pnpm check:type-check-debt",
    "0 pnpm check:watch-hint-literal",
    "0 pnpm check:where-matcher"
    ]
    },
    "tests": "At 3247efe: plugin-security full suite "Test Files 123 passed (123) / Tests 2348 passed (2348)" (VERDICT command-exit 0; includes rls-check-multi-row-writes.test.ts). At 16a28ad, after the closure build: rls-check-multi-row-writes.test.ts "Tests 32 passed (32)"; plugin-security "123 passed / 2348 passed"; objectql re-run because the merge brought #19979 (objectql rule-validator): local project "155 passed / 2434" + "154 passed / 2758", repo project "1 passed / 5"; typecheck objectql + plugin-security green (VERDICT command-exit 0 each). The only code change after 16a28ad is the 4 comment lines. NOT a measurement: the first suite run in the fresh worktree failed 117 files at load ("Failed to resolve entry for package @objectstack/core") because the recreated worktree had no dist/. That is a missing prerequisite, not a verdict; it went green after the closure build (72/72).",
    "mcp_calls": "0 — no MCP tool used",
    "api_writes": "1 relay stroke: POST /repos/objectstack-ai/objectstack/dispatches (session) executed as objectstack-fleet[bot] → POST /repos//issues/19950/comments (this report). PR body NOT patched; the new text is pr_body_new (zero footers). git (not REST): 2 normal pushes 9fff66c..16a28ad and 16a28ad..3247efe.",
    "deviations": [
    "The round-2 prescription was applied and pushed as instructed (16a28ad), then measured red. The alternative was measured green in a throwaway detached worktree (objectstack-issue-19950-arm1), which was removed with --force only after confirming its sole change was the disposable 4-line edit. The branch did not take the alternative until the seat's correction arrived.",
    "The worktree was recreated again (removed at the end of patch round 1)."
    ],
    "open_questions": [],
    "out_of_scope_findings": [
    "class: a · check-issue-citations prints a remedy arm it cannot honour: "keep the number and say IN PROSE that it no longer resolves and what the live record is" (scripts/check-issue-citations.mjs:723-725), but extractCitations (:361-384) judges every #N on an added line and has no prose exemption. Repro: this PR at 16a28ad, which followed that arm on 4 lines, still exits 2 with the same 4 [allocated-but-absent] findings. Either the extractor honours the declared arm or the remedy text drops it · dedupe: "issue-citations remedy prose unreachable" "no longer resolves prose exemption" "keep the number and say in prose" "allocated-but-absent remedy""
    ],
    "pr_body_new": "Fixes #19950\nFixes #19964\nClause-②: no (narrowing)\n\n## What this fixes\n\nA 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:\n\n- #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.\n- #19950, predicate update (multi: true, no row address). Step 3.6 skipped the new-row check and logged "governed by the using-scoped where". A policy that declares only check scopes nothing, and a scoped where says nothing about the new row in any case. The skip was unconditional, so it also covered a declared check that differs from using and a check defaulted from using.\n\nBoth 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.\n\n## Landing: two packages, and why the engine is one of them\n\n- 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.\n- 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:\n - 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.\n - 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.\n \n So the security layer installs its judgement on the existing OperationContext.postHookWriteImageCheck seam (the one #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.\n\n## Mechanism hypotheses (dispatch Section 2), as measured on 2c1011b01b\n\n1. 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".\n2. Held. engine.ts (the postHookWriteImageCheck call in insert()) hands evaluate every live row of an array insert. The array fix is plugin-side only.\n3. 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.\n4. Held. The skip was unconditional. A cell pins a using plus a differing check.\n\nOne 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.\n\n## Surface beyond the claim, with reasons\n\n- packages/objectql/src/engine.ts: see above (cross-lane, domain:engine).\n- 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.\n- A pending release note, corrected in place: .changeset/rls-check-defaults-to-using.md (from #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 #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 #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.\n\n## Behaviour that changes (all in the refusing direction)\n\n- A predicate update under a check-only policy is refused when any matched row's new image fails the check.\n- 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 #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).\n- An array insert is refused when any row fails, including every configuration that already refused each single insert.\n- A host that installs the judgement on a predicate update and never runs it is refused (403, error log), as an insert already is.\n\n## Tests\n\nPatch 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).\n\nPatch 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.\n\nRound 1 (head 6d28dfe98d):\n\n\nNew: 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.\n\n- Failing first, on the unmodified tree: 18 red and 12 green (the controls). Every negative cell failed as "admitted" (expected true to be false). In the unresolvable-policy cells the single-insert leg was refused and only the array leg was admitted.\n- #19950 cells: the check-only repro; per row not per change set (a matched row failing on an unchanged field refuses the whole write); using plus a differing check; fail-closed (an inner middleware strips the seam, and the write is refused with "the update on 'qa_ticket' was executed without the row-level CHECK being evaluated"); falsy payload id. Controls: over-fix admit, USING-only in-scope admit and scoped, using+check in-scope admit, by-id unchanged, and USING-only move-out refused on both bulk and by-id.\n- #19964 cells: the [admitted, refused] repro; over-fix admit; single insert unchanged; three refuse-every-insert configurations (an unresolvable sole using on insert and on all, an unresolvable declared check).\n- Every refusal asserts code PERMISSION_DENIED, status 403 and the developer half naming the gate and verb, then reads the stored rows back under a system context.\n\nSuites: 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).\n\nAblations: 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.\n\n| # | mutation | result |\n|---|---|---|\n| 1 | restore the non-array guard for inserts | 8 red: the 4 array-insert negative cells x 2 drivers |\n| 2 | never install the seam on a predicate update | 12 red: the 6 bulk negative cells x 2 |\n| 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) |\n| 4 | install only when the id is null (falsy counts as by-id) | 2 red: the falsy-id cell, admitted |\n| 5 | engine never calls the seam | 16 red: refusals now carry the not-evaluated message, controls refused |\n| 6 | disable the post-next() fail-closed guard | 2 red: the fail-closed cell, admitted |\n\n## Gates\n\nPatch 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.\n\nPatch 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.\n\nRound 1 (head 6d28dfe98d):\n\n\n- 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".\n- 67 exit 0. One exits 1: check-empty-changeset, the deliberate correction above.\n- 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.\n- 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.\n\n## Acceptance notes\n\n- 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.\n- 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.\n- 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.\n- Partial-row array insert (__partialRowErrors): a failing row refuses the whole call rather than being reported per row.\n- 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.\n- .changeset/19950-rls-check-multi-row-writes.md is declared a narrowing, per the seat's ruling and following #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).\n- origin/main was merged twice with merge commits, both clean with no regeneration owed: at 3fd3a4f91b (a5ca1db166) and at 9bfbacbf8b (938b2acfde). PR #19984 (#19963, the explain wiring in the same file) had not landed by the second merge.\n- Patch round 2: citation fix only (four comment lines in security-plugin.ts); no behaviour change.\n\n"
    }

  9. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-24T16:45Z


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions