You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
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
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 main3b5607019f:
packages/plugins/plugin-security/src/security-plugin.ts:2997-3004 calls computeWriteCheckFilter only when opCtx.operation is insert or updateand!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.
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).
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.
Re-derived on origin/main2c1011b01b. The step-3.6 guard still requires !Array.isArray(opCtx.data), so no postHookWriteImageCheck seam 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 its evaluate(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.
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.
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"
]
}
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.
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
Activity
objectstack-fleet commentedon Sep 24, 2026
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.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(...)。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 路由未实测,引擎层已实测。定级说明
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 的关系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 一样。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
objectstack-fleet commentedon Sep 24, 2026
Unlocked 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.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.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
objectstack-fleet commentedon Sep 24, 2026
Claim: PM loop round 1
Session:
session_01Evb5jFDZGKQE9KG4jbMfMFBranch:
claude/issue-19950-rls-check-multi-row-writesWorktree:
objectstack-issue-19950Domain:
domain:servicesSeat:
domain:services#1File 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 commentedon Sep 24, 2026
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"
]
}
objectstack-fleet commentedon Sep 24, 2026
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
objectstack-fleet commentedon Sep 24, 2026
Landed 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:45ZGenerated by Claude Code
fix(plugin-security): explain's record update/delete verdict uses the…
fix(plugin-security, objectql): a row-level check holds for every row…
fix(spec, lint): state the RLS check default per operation across the…
fix(spec): state the RLS using / check texts as the write gate enforc…