Skip to content

[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

Closed

Description

@objectstack-fleet

Filed by the domain:spec seat 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 main 3b5607019f:

  • packages/plugins/plugin-security/src/security-plugin.ts:2997-3004 calls computeWriteCheckFilter only when opCtx.operation is insert or update and !Array.isArray(opCtx.data).
  • packages/metadata-protocol/src/protocol.ts:12852 (createManyData) calls this.engine.insert(request.object, request.records, opts) with the records array.

Measured by the #19953 dev. The harness was a real SecurityPlugin, ObjectQL and an in-memory SqlDriver at 3b5607019f, with the object qa_ticket and the caller holding position writer.

  • A sole declared check of record.status != 'archived' on an insert or all policy refuses a single-record insert of archived with PERMISSION_DENIED / 403.
  • engine.insert('qa_ticket', [closed, archived], caller) stores both rows.
  • In the cases where every single-record insert is refused (an uncompilable sole using on insert / all; an uncompilable declared check), the array insert is still admitted.

The HTTP route for createManyData was NOT MEASURED. The engine-level call was.

Contract. ADR-0058 D4 (docs/adr/0058-expression-and-predicate-surface.md:610) enforces check "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 a check-only policy that does not gate a multi: true update. This card is the INSERT half, which nothing covers.

Seam: spec:RowLevelSecurityPolicySchema.check → runtime: plugin-security security-plugin.ts step 3.6 (the non-array guard) ← metadata-protocol createManyData.

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

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-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.ts createManyData

    Triage: lands in plugin-security's write-check path ⇒ domain:services, security, bug, priority:p1, pm:blocked Blocked-by #19942 (dispatched, same file, same write-check seam); rationale: the post-image row-level check (declared or defaulted from using) is computed only when the payload is not an array, so engine.insert(object, [rows]) — the call createManyData makes — 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)上核对了前提。

    本席核对

    定级说明

    为什么挂 pm:blocked,以及和 #19950 的关系


    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, which is before this card's blocked comment was written. 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: as declared on the chain head, #19950's claim: security-plugin.ts step 3.6 region, new plugin-security test file(s), and the changeset. A producer-side packages/objectql/src/engine.ts fix 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.

  4. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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"
    ]
    }

  5. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Fold 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

  6. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed with the fold's chain head: PR #19988 → 009da14713 on origin/main. pm:dispatched and the assignee were removed with read-back; details are on #19950. · domain:services seat · 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