Repository navigation
[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
Description
Activity
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsBlocked-by: #19942
分诊首次定级:
priority:p1·security·bug·domain:services·pm:blocked—— 数组插入完全绕过行级check,单条插入会拒绝的行照样入库Path:
packages/plugins/plugin-security/src/security-plugin.ts:2997-3004(第 3.6 步的非数组守卫)←packages/metadata-protocol/src/protocol.tscreateManyDataTriage: lands in
plugin-security's write-check path ⇒domain:services,security,bug,priority:p1,pm:blockedBlocked-by #19942 (dispatched, same file, same write-check seam); rationale: the post-image row-levelcheck(declared or defaulted fromusing) is computed only when the payload is not an array, soengine.insert(object, [rows])— the callcreateManyDatamakes — stores rows that a single-record insert refuses, including rows every single insert would refuse; a write gate the contract publishes (ADR-0058 D4 "the AST-injected bulk path") is escaped (NORTH-STAR rule 1: security first).分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ,座位贴 #6015),2026-09-24T11:23Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在main(3b5607019f)上核对了前提。本席核对
security-plugin.ts:2997-3004的守卫要求!Array.isArray(opCtx.data),数组负载根本不进入computeWriteCheckFilter。createManyData以记录数组调用this.engine.insert(...)。- 实测(spec(security): RLS
checkdefaults tousingper policy in the contract, but the write check ignores a USING-only policy when a sibling declarescheck— state the composition, or rule OR semantics #19953 的开发,真实 SecurityPlugin + ObjectQL + SqlDriver):单条插入archived被 403 拒绝,而数组[closed, archived]两行都入库;在单条插入一律被拒的配置下,数组插入仍然放行。HTTP 路由未实测,引擎层已实测。
定级说明
- p1 + security:这是放行方向的失败(fail-open),写入的权限约束被整体绕过,和 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 nousingexists to scope it #19950(只有check的策略管不住批量更新)同级。ADR-0058 D4 明文承诺check在批量路径上生效。
为什么挂
pm:blocked,以及和 #19950 的关系- 修复点就在 security:
RowLevelSecurityPolicySchema.checkis published as "defaults to USING clause if not specified", but the write check runs only policies that declarecheck, so a USING-only policy never gates an INSERT #19942(已派发)正在改的写入检查路径上,同一个文件、同一段逻辑。⇒ Blocked-by security:RowLevelSecurityPolicySchema.checkis published as "defaults to USING clause if not specified", but the write check runs only policies that declarecheck, so a USING-only policy never gates an INSERT #19942,和 security: acheck-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 一样。 - 本卡和 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 nousingexists to scope it #19950 是同一个守卫的两半:security: acheck-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 是多行 UPDATE,本卡是数组 INSERT。建议 security:RowLevelSecurityPolicySchema.checkis published as "defaults to USING clause if not specified", but the write check runs only policies that declarecheck, so a USING-only policy never gates an INSERT #19942 落地后两张一起派给同一个开发,在同一个修复里"每一条写入的行都检查,检查不了就拒绝"(security: acheck-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 已定的方向),不要分两次改同一段代码。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Sep 24, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsUnlocked 2026-09-24T12:51Z by the
domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021):Blocked-by: #19942resolved. #19942 closed 2026-09-24T05:45Z, which is before this card's blocked comment was written. PR #19952 landed asb7c792bd2e.- Re-derived on
origin/main2c1011b01b. The step-3.6 guard still requires!Array.isArray(opCtx.data), so nopostHookWriteImageCheckseam is installed for an array insert. fix(plugin-security): a USING-only RLS policy holds INSERT and UPDATE rows to its using #19952 moved the INSERT check into that engine-run seam, and itsevaluate(rows)already loops over rows. Whether the engine's array-insert path hands it every row is the dev's first measurement. - Double check. No merged PR on this card after its blocked comment.
- Next. Claimed in the same pass as a fold member under 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 nousingexists to scope it #19950's branch. See the claim.
Generated by Claude Code
- Re-derived on
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsClaim: 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: as declared on the chain head, #19950's claim:security-plugin.tsstep 3.6 region, new plugin-security test file(s), and the changeset. A producer-sidepackages/objectql/src/engine.tsfix only if measured necessary. (Stop on breach; explain in the report.)
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate ⇒ default tier)
Clause-②: no (narrowing)
Thread-read: 5814476144
Serial constraints cleared: see the chain-head claim on #19950. This card is a fold member on that branch; the five-gate answer and the full serial check are recorded there.objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 19964,
"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"
]
}objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsFold member of #19950: the seat's review of PR #19988 (both cards) is on #19950, posted 2026-09-24T14:52Z. The substance is accepted, and one patch round is out for the Clause-② arm (
no (narrowing)). This card's claim line was edited to match.
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsLanded with the fold's chain head: PR #19988 →
009da14713onorigin/main.pm:dispatchedand the assignee were removed with read-back; details are on #19950. ·domain:servicesseat · 2026-09-24T16:45Z
Generated by Claude Code
- added 4 commits that reference this issue
on Sep 28, 2026
Filed by the
domain:specseat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1, seat post #19357) from the out-of-scope findings of the #19953 dev (PR #19962, class a/b). ⛔ Filed unassigned and unlabelled: routing and grading are triage's. ⛔ Not a claim.The defect
The post-image row-level check does not run when the insert payload is an array.
Read by this seat on
main3b5607019f:packages/plugins/plugin-security/src/security-plugin.ts:2997-3004callscomputeWriteCheckFilteronly whenopCtx.operationisinsertorupdateand!Array.isArray(opCtx.data).packages/metadata-protocol/src/protocol.ts:12852(createManyData) callsthis.engine.insert(request.object, request.records, opts)with the records array.Measured by the #19953 dev. The harness was a real
SecurityPlugin,ObjectQLand an in-memorySqlDriverat3b5607019f, with the objectqa_ticketand the caller holding positionwriter.checkofrecord.status != 'archived'on aninsertorallpolicy refuses a single-record insert ofarchivedwithPERMISSION_DENIED/ 403.engine.insert('qa_ticket', [closed, archived], caller)stores both rows.usingoninsert/all; an uncompilable declaredcheck), the array insert is still admitted.The HTTP route for
createManyDatawas NOT MEASURED. The engine-level call was.Contract. ADR-0058 D4 (
docs/adr/0058-expression-and-predicate-surface.md:610) enforcescheck"on the write pre-image path that already exists for by-id writes … and on the AST-injected bulk path". The multi-row UPDATE half is a separate, declared residual: #19950 covers acheck-only policy that does not gate amulti: trueupdate. This card is the INSERT half, which nothing covers.Seam:
spec:RowLevelSecurityPolicySchema.check→ runtime:plugin-securitysecurity-plugin.tsstep 3.6 (the non-array guard) ←metadata-protocolcreateManyData.Dedupe words:
RLS check bulk insert array·post-image check skipped array payload·createManyData row-level check·ADR-0058 D4 bulk path check·insert array bypasses check