Repository navigation
plugin-security: the Layer 0 tenant write wall (step 3.7) judges only an organization_id present in the payload as sent, so a beforeUpdate hook that writes organization_id itself can store the row in another tenant #20013
Description
Activity
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsBlocked-by: #19989
分诊首次定级:
priority:p1·security·bug·domain:services·pm:blocked—— 租户写入墙只检查调用方提交的organization_id,如果应用的beforeUpdate钩子自己改写了organization_id,这一行就能被存进别的租户;与 #19989(PR #20012)改的是同一处,排在它后面Path:
packages/plugins/plugin-security/src/security-plugin.ts(第 3.7 步 Layer 0 租户写入墙,在next()之前只判opCtx.data)·packages/objectql/src/engine.ts(插入侧的同型:「The Layer 0 tenant wall still judges the PRE-hook image — filed separately」)Triage: lands in
plugin-security(engine seam if needed) ⇒domain:services,security,bug,priority:p1,pm:blockedBlocked-by #19989; rationale: step 3.7's own contract says a suppliedorganization_idmust pass the Layer 0 filter, making it "effectively immutable in non-platform user contexts", but the wall judges only the payload as SENT — abeforeUpdatehook that writesorganization_idfrom a caller-steered field stores the row in another tenant (measured on two drivers); reachability needs such an app-authored hook, so p1 rather than p0; the fix region is the one PR #20012 (for #19989) is moving onto the stored row, so it waits for that landing. This card also carries the INSERT twin, whose previous tracker answers 404.分诊席 #6015,2026-09-24T20:19Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack
origin/main44639665ee上核对。本席核对
security-plugin.ts:第 3.7 步的注释原文「…organization_ideffectively immutable in non-platform user contexts: the …」在 main 上。engine.ts:插入侧注释「(The Layer 0 tenant wall still judges the PRE-hook image — filed separately, and the fix's host is this seam.)」也在 main 上。卡面说原来的跟踪卡现在是 404。⇒ 本卡把更新和插入两个动词一起承接。- 卡面的测量表(在 driver-sql 和 driver-sqlite-wasm 上,钩子写
organization_id时被放行、行存进了组织 B)本席没有重跑。
定级说明
- p1 +
security:方向是跨租户写入,违反的是租户隔离这条最硬的墙。 - 不给 p0:需要应用自己写了一个「从调用方能控制的字段推导
organization_id」的钩子才会触发。常见的「从父记录盖章」形状,会先被引用检查挡住(卡面表里第二行)。
为什么挂在 #19989 后面
PR #20012(#19989)正在把按 id 更新的业务 RLS
check挪到「存下来的那一行」上判,改的正是这一块区域。它不管租户墙,所以本卡在它落地后仍然存在,但现在并行开 PR 会冲突。#19989 关闭时本卡解锁。解锁后的执行要点
- 测更新和插入两个动词:插入这一侧本轮没有测。
- 让租户墙判「存下来的那一行」:优先复用插入、谓词更新和(fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 之后的)按 id 更新检查已经在用的同一个接缝。具体落点由实现者测量后选择。
- 钉住:钩子写入别的组织的
organization_id,更新和插入都被拒绝(403);调用方自己提交别的组织的对照保持拒绝;同组织的正常写入不受影响。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2and removed
on Sep 24, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsUnlock scan: the blocker closed; the card goes back to
pm:queue.domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021) · 2026-09-24T20:35Z- The blocker, security: a by-id UPDATE's row-level
checkis judged on the pre-hook change set, so abeforeUpdatestamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989, closedcompletedat 2026-09-24T19:49Z through PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 (44639665eeonorigin/main). That was before this card's grading comment named it. - Re-derived on
origin/main7b76fff237. Step 3.7 insecurity-plugin.tsstill judges only anorganization_idsupplied inopCtx.databeforenext(). PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 moved the businesscheckof a by-id update onto the stored row through the engine seam, and ⛔ did not carry the tenant wall, as its record states. The premise holds, and the stored-row seam this card's direction can reuse now exists on the by-id path. - File surface. No open PR touches
security-plugin.ts. The seat's in-flight security (P0 suspect): the managed-object blanket gives non-admin shipped sets an object-level read on twoaccess: privatecredential-bearing identity objects that the sets themselves declare deny-by-design #20027 is indefault-permission-sets.ts/permission-evaluator.ts, which is disjoint. - Not dispatched this minute. The seat's three dev slots are full (plugin-security: explain reports a record VISIBLE when the sharing read filter throws —
explain-enginecatches the rejection intonulland the record matcher readsnullas "no filter", while enforcement refuses the same read #20002, service-analytics: object-form analyticswhereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010, and security (P0 suspect): the managed-object blanket gives non-admin shipped sets an object-level read on twoaccess: privatecredential-bearing identity objects that the sets themselves declare deny-by-design #20027 at p0). This card is next when a slot frees.
Generated by Claude Code
- The blocker, security: a by-id UPDATE's row-level
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_01Evb5jFDZGKQE9KG4jbMfMF
Branch:claude/issue-20013-tenant-wall-post-hook
Worktree:objectstack-issue-20013
Domain:domain:services
Seat:domain:services#1
File surface:packages/plugins/plugin-security/src/security-plugin.ts(step 3.7, the Layer 0 tenant write wall, and the stored-row seam judgementnewWriteImageCheckit can extend); new test file(s) underpackages/plugins/plugin-security/src/;.changeset/20013-*.md. ⛔ Notpackages/objectql/src/engine.ts: PR #20021 (engine lane) holds it, and a needed engine change is a stop-and-report. ⛔ Notdefault-permission-sets.ts/permission-evaluator.ts(#20027, in flight). ⛔ Notexplain-engine.ts(PR #20030). ⛔ Nopackages/spec. Measurement first: the by-id update leg, reproduced onmain, plus the unmeasured insert and predicate-update legs. (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: 5821821354
Serial constraints cleared at 2026-09-24T21:00Z:- PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 (security: a by-id UPDATE's row-level
checkis judged on the pre-hook change set, so abeforeUpdatestamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989, the stored-row seam on the by-id path, same file) landed as44639665ee. - Over all 17 open PRs, none touches
security-plugin.ts. PR fix(objectql): a delete refused by a traversing rule on its reference cleanup names the delete, the reference and the repair #20021 (A traversing validation rule on a child refuses the cascade FK clear that deleting its parent issues, and the refusal names the wrong fix: it points at the child object, not the reference the delete is clearing #20006, engine lane) touchesengine.ts, which is outside this surface. - security (P0 suspect): the managed-object blanket gives non-admin shipped sets an object-level read on two
access: privatecredential-bearing identity objects that the sets themselves declare deny-by-design #20027 (in flight) and PR fix(plugin-security): explain fails closed when a dependency it shares with enforcement throws #20030 (plugin-security: explain reports a record VISIBLE when the sharing read filter throws —explain-enginecatches the rejection intonulland the record matcher readsnullas "no filter", while enforcement refuses the same read #20002) are in other files of this package, so they are disjoint.
- PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 (security: a by-id UPDATE's row-level
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 20013,
"status": "done",
"branch": "claude/issue-20013-tenant-wall-post-hook",
"pr": "#20043",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF (subagent: the parent seat's id, from the Claude-Session line)",
"premise_still_valid": true,
"summary": "Step 3.7's Layer 0 tenant wall now also judges the row the engine stores, through OperationContext.postHookWriteImageCheck, installed whenever Layer 0 applies to a walled insert/update (not only when a business check does) and composed after step 3.6's check when both apply; the payload-as-sent judgement stays, so it only refuses more, with step 3.7's existing refusal (PERMISSION_DENIED/403, nothing stored). All three legs were reproduced on base on driver-sql and driver-sqlite-wasm (by-id update, insert incl. array insert, predicate update: admitted, row stored in org_b) and are now refused. The post-next() fail-closed guard covers the new installation with one measured stand-down: the ADR-0094 permission-set data door executes its write without the engine (a platform admin's insert under isolated is admitted on base with Layer 0 walling the object), so a wall-only seam on a write the door executed itself is not refused; this is observed by a wrapper around the door's registration (plugin-private WeakSet), and a seam carrying a business check still fails closed there. No engine change; assignee untouched (os-sales, as found); the newest Claim names this branch.",
"measurement_table": [
"| leg (base 26550c6, isolated, both drivers identical) | outcome | stored organization_id |",
"| control: by-id update supplies org_b | refused PERMISSION_DENIED/403 (step 3.7) | org_a, unchanged |",
"| control: insert supplies org_b | refused PERMISSION_DENIED/403 | no row |",
"| by-id update, beforeUpdate hook writes org_b | ADMITTED | org_b |",
"| insert, beforeInsert hook writes org_b | ADMITTED | org_b |",
"| array insert, hook writes org_b on row 2 | ADMITTED | row 1 org_a, row 2 org_b |",
"| predicate update (multi: true), hook writes org_b | ADMITTED | org_b |",
"| control: by-id update, hook writes org_a | admitted | org_a |",
"| seam presence (inner middleware), every leg above, no row-level policy | postHookWriteImageCheck ABSENT | (hypothesis 3: installed only when a business check applies) |",
"| data door: platform admin inserts sys_permission_set, isolated, active org org_a | admitted; Layer 0 = {organization_id: org_a}; engine write never ran (next() not called) | stored organization_id null (projector-owned) |"
],
"files_changed": [
".changeset/20013-tenant-wall-post-hook.md (new)",
"packages/plugins/plugin-security/src/security-plugin.ts (step 3.7 + post-next guard + data-door registration wrapper)",
"packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts (new, 34 cells)",
"packages/plugins/plugin-security/src/authz-matrix-gate.test.ts (double runs the seam on insert)",
"packages/plugins/plugin-security/src/can-write-object-admission.test.ts (terminal runs the seam on insert)",
"packages/plugins/plugin-security/src/check-only-write-scope.test.ts (double insert runs the seam)",
"packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts (double runs the seam on insert)",
"packages/plugins/plugin-security/src/controlled-by-parent-master-widener.test.ts (double runs the seam on insert)",
"packages/plugins/plugin-security/src/explain-dependency-fault.test.ts (door runs the seam on by-id update; file arrived with the merge of main, PR #20030)",
"packages/plugins/plugin-security/src/explain-write-verdict-inputs.test.ts (double update runs the seam)",
"packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts (terminal runs the seam on insert)",
"packages/plugins/plugin-security/src/row-write-widener-composition.test.ts (double update runs the seam)",
"packages/plugins/plugin-security/src/select-only-write-visibility.test.ts (passes opCtx to the double on the bulk cell)",
"packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts (24 no-op terminals -> engineTerminal)",
"diffstat vs merge base 7766b62: 14 files, +870 / -71"
],
"tests": "Head cce969c (after merging origin/main 7766b62; closure rebuilt via turbo). New pin file failing first on 26550c6 (commit 8afaec5): 'Tests 14 failed | 18 passed (32)', every red 'AssertionError: expected a refusal, got a completed write'. After the fix: tenant-wall-post-hook-image 'Tests 34 passed (34)'. Suites (os-verify-lock VERDICT command-exit 0 each): plugin-security 'Test Files 133 passed (133) Tests 2648 passed (2648)'; plugin-auth 'Test Files 114 passed (114) Tests 2439 passed (2439)'; runtime 'Test Files 278 passed (278) Tests 3962 passed | 1 skipped (3963)'. First full plugin-security run after the fix was 'Tests 48 failed | 2545 passed' across 10 harness files, all the fail-closed guard (user message 'You are not allowed to save this record...', thrown at the guard) on no-op engine terminals under walled postures; each double now runs the seam as the engine does. plugin-security typecheck exit 0; check:test-typecheck 'OK ... 0 file(s) / 0 error(s)'; tsc -p tsconfig.test.json --listFiles counts 131 test files incl. the new one. Ablations on e43cda1 (security-plugin.ts blob 278b25f19e and pin-file blob 36fd6aab3e both equal final head), each via scripts/ablation-replace.mjs WRAP ('anchor 1 -> 0', blob changed) under an outer trap restoring from HEAD, then proven 'RESTORED blob==HEAD (278b25f19eec...) git-diff-HEAD empty': A1 stored-row judgement never refuses -> 12 red (by-id, insert, array, predicate, composed-with-check, group x2); A2 seam only when a business check is installed -> 12 red (by-id, insert, array, predicate, group, fail-closed x2); A3 guard stands down for every wall-only seam -> 2 red (fail-closed x2); A4 no data-door stand-down -> 2 red (data door x2; 'expected the write to be admitted: [Security] Access denied: the insert on sys_permission_set was executed without the Layer 0 tenant wall being evaluated on the stored row'); A5 payload judgement dropped -> 4 red (supplied control + only-refuses-more x2; a supplied value is then admitted but defused by the engine's static readonly strip, organization_id being injected readonly:true, while a hook-written value is exempt from that strip); A6 absent organization_id judged -> 2 red (absent-value insert control x2); A7 composite drops the business check -> 2 red (composed-check cell x2). No dist/ between mutation and run: the subject is imported relatively and @objectstack/objectql is aliased to src in the package vitest.config.ts, so ablation-dist-preflight does not apply. Narrowed lint: eslint --no-inline-config --format json over the 13 touched .ts files -> 13 files, 0 errors, 0 warnings; population = the '/*.{ts,...}' and 'packages/' blocks of eslint.config.mjs; eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules), so untouched files' verdicts cannot move. Repo-wide pnpm lint and the Dogfood Regression Gate: NOT MEASURED locally, declared to CI.",
"gates": {
"derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on cce969c: 62 families (dispatch-time 49 + adr-0087-registration x2, empty-changeset x2, release-rehearsal-clone --self-test, check:engine-double-contract, check:objectql-double-limit, check:objectui-changeset, check:pm-changeset-deadline-census, check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:where-matcher).",
"reconcile": "--ran: '62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN' ... 'a DERIVED zero — all 62 recorded an exit code and none of them is 3'.",
"first_attempt_prerequisite": "check:dual-build-cjs-loads, check:i18n, check:type-check-debt answered PREREQUISITE NOT MET (exit 3) before a full workspace build (turbo 73 tasks, 46 cached), then exit 0.",
"check_empty_changeset": "exit 0 — the expected red did not occur: no pending changeset sentence was made false, so no deliberate correction was written.",
"issue_citations_board": "GITHUB_TOKEN=... node scripts/check-issue-citations.mjs exit 0: '9 resolves'.",
"ran_with_exit_codes": [
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-tenant-audit-census.mjs :: exit 0",
"node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 0",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:i18n :: exit 0",
"pnpm check:i18n-stale-fill :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:type-check-debt :: exit 0",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0"
]
},
"mcp_calls": "0 — no MCP GitHub tool was called.",
"api_writes": "2 relay strokes (fleet-write, as objectstack-fleet[bot]), each carried by one POST /repos/objectstack-ai/objectstack/dispatches: (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft, PR #20043); (2) comment -> POST /repos//issues/20013/comments (this os-dev-report). Label writes: 0 (the dispatch named no label; skip-changeset does not apply, the diff publishes). git push is not a REST write. Reads only otherwise (issue, comments, PR #20012, PR list/body read-back).",
"deviations": [
"Section 3 route 'the fail-closed guard must cover the new installation': covered, plus one stand-down the route did not foresee (the ADR-0094 permission-set data door, which executes writes without the engine), measured on base and pinned both ways (A3, A4).",
"Surface: 11 existing plugin-security test files' engine doubles (the expected class); explain-dependency-fault.test.ts arrived with the merge of origin/main (PR #20030 landed) and went red on the merged tree for the same reason. No production file outside security-plugin.ts; engine.ts, default-permission-sets.ts, permission-evaluator.ts, explain-engine.ts and packages/spec untouched.",
"Merged origin/main once (7766b62). origin/main has since moved to 246314d (#20021 engine delete path, #20032 service-analytics); not merged again, the PR's CI runs on the merge ref.",
"The expected check-empty-changeset red did not occur: no pending sentence was made false (see pr_body_new 'Pending changesets').",
"The merge commit fe15792 has no Claude-Session trailer (default merge message); every other commit carries the model-free pair. History not rewritten.",
"Label writes: 0 (none named, skip-changeset inapplicable)."
],
"open_questions": [
{
"question": "A walled predicate update now always pays the engine's memoized matched-row read (the seam takes matched rows merged with the payload); on an object with no update hook and no row-reading rule this read is new and has no row ceiling on that path. Accept, or follow up?",
"options": [
"A accept, as #19950 accepted the same read for check-bearing objects",
"B engine-lane follow-up: a seam input that lets a payload-only judgement run without the read (engine change, out of this card's surface)"
],
"recommendation": "A now, B only if a measured bulk-update cost asks for it: correctness of the wall is the card, the read is the one D7 memo the ruling allows, and nothing was measured to show the cost matters (NOT MEASURED here)."
},
{
"question": "The data-door stand-down: keep it as an observed wrapper around the door's registration, or choose another shape?",
"options": [
"A as implemented: plugin-private WeakSet filled when the door returns without next(); only a wall-only seam stands down",
"B do not guard-cover wall-only seams at all (the payload half remains); simpler, but a host that runs hooks without the seam would silently keep the defect",
"C have the data door run the seam itself; widens today's refusal of the door under a business check, so not 'only refuses more'"
],
"recommendation": "A: it keeps the fail-closed guarantee for every engine host, changes nothing for the door under a business check, and the fact it keys on is observed rather than declared."
}
],
"out_of_scope_findings": [
"carrier: domain:engine seat (engine.ts owner) · packages/objectql/src/engine.ts lines 11846-11847 on 246314d still read 'The Layer 0 tenant wall still judges the PRE-hook image — filed separately'; false after this PR. Suggested text in the PR's Acceptance notes. A stale comment, not a/b/c · noted, not filed.",
"carrier: this PR's Acceptance notes · a hook that clears organization_id (null/'') on an update is judged by neither half (absent values are out of step 3.7's declared scope, mirroring ADR-0095 D1); not measured, no declared contract covers it · noted, not filed.",
"carrier: this PR's Acceptance notes · walled predicate-update matched-row read cost (see open_questions) · noted, not filed."
],
"pr_body_new": "Fixes #20013\nClause-②: no (narrowing)\n\n## What this fixes\n\nStep 3.7 of the security middleware is the Layer 0 tenant write wall (ADR-0095 D1, ADR-0105 D5). Its contract,packages/plugins/plugin-security/src/security-plugin.tslines 3239-3248 onorigin/main246314dffe:\n\n> Both close identically here: a SUPPLIED (non-empty)organization_idin the write payload must satisfy the SAME Layer 0 filter the read side uses (isolation active, tenant object, platform-admin posture exemption, fail-closed on a missing active org). For UPDATE this makesorganization_ideffectively immutable in non-platform user contexts: the only value that passes is the caller's active org (which — since the pre-image already scoped the target to that org — equals the row's current org), so a re-point to any OTHER tenant is denied. A bulk update carrying a cross-tenantorganization_idchange-set is caught too (the check inspects the change-set value, not a per-row post-image).\n\nThe wall judgedopCtx.data, the payload as the caller sent it, beforenext()runs the engine'sbeforeInsert/beforeUpdatechain. A value a hook wrote intoorganization_idwas never judged by the wall, so the row was stored in whatever organization the hook named. The engine records the insert twin of the same gap atpackages/objectql/src/engine.tslines 11846-11847 on246314dffe: 「The Layer 0 tenant wall still judges the PRE-hook image — filed separately, and the fix's host is this seam.」\n\nThe wall now also judges the row the engine is about to store, through the seam the row-levelcheckalready uses (OperationContext.postHookWriteImageCheck). The refusal is the wall's existing one:PERMISSION_DENIED/ 403, nothing stored. The judgement of the payload as sent stays, so this change only ever refuses more. No engine change.\n\n## Mechanism hypotheses (dispatch Section 2), measured\n\nBase:origin/main26550c6603. RealSecurityPluginandObjectQL,isolatedposture, driver-sql (better-sqlite3) and driver-sqlite-wasm. The fixture is a tenant object whosebeforeInsertandbeforeUpdatehook copies another payload field intoorganization_id, and a caller whose active organization isorg_a.\n\n1. Held. Step 3.7 judged only rows whoseorganization_idwas present inopCtx.data(thesuppliedRowsfilter), beforenext().\n2. Reproduced on both drivers, all three legs (identical on both):\n\n| leg | outcome on the base | storedorganization_id|\n|:--|:--|:--|\n| control: by-id update suppliesorg_b| refused,PERMISSION_DENIED/ 403 ("the update would place 'qa_account' in another tenant") |org_a, unchanged |\n| control: insert suppliesorg_b| refused,PERMISSION_DENIED/ 403 | no row |\n| by-id update, hook writesorg_b| admitted |org_b|\n| insert, hook writesorg_b| admitted |org_b|\n| array insert, hook writesorg_bon the second row | admitted | first roworg_a, secondorg_b|\n| predicate update (multi: true), hook writesorg_b| admitted |org_b|\n| control: by-id update, hook writesorg_a| admitted |org_a|\n\n3. The seam is installed only where a businesscheckapplies, measured: with no row-level policy on the object, an inner middleware sawpostHookWriteImageCheckabsent on every leg above. So the wall gets its own installation condition: a walled posture, a tenant object, a caller Layer 0 does not exempt (that is,computeWriteTenantCheckFilterreturns a filter). When step 3.6 also installed its judgement, the two are composed into the one handle the engine runs, in the order the middleware judges in (thecheck, then the wall).\n\nA second finding shaped the fail-closed guard. The post-next()guard now covers the new installation. The ADR-0094 permission-set data door executes an insert or update ofsys_permission_setitself, through the metadata protocol, and never callsnext(). Measured on the base with a platform administrator (active organizationorg_a) underisolated: Layer 0 walls the object (organization_id = org_a), the insert is admitted, and the engine's write never runs. A guard-covered wall seam alone would turn that into a 403. No engine write runs there, so no hook chain runs, and the payload judgement has already cleared the onlyorganization_idsuch a write can carry. The guard therefore stands down for a seam that carries the wall alone on a write the door executed itself. The fact is observed by a wrapper around the door's registration, which records a write the door never passed tonext()in a plugin-privateWeakSet. No operation field is involved that another middleware could set. A seam carrying a row-levelcheckis not stood down: the data door keeps failing closed under one, exactly as before.\n\nADR-0095 D1 "Not touched" records that D1 added no tenant post-image check tocomputeWriteCheckFilter. Its reason is that such a check "would risk denying legitimate inserts before the auto-stamp runs". This change does not touchcomputeWriteCheckFilter, and it judges an image only when the image names an organization. An absent value (the auto-stamp's to fill) is never judged, and a control pins that. ADR-0105 D5 affirms the direction: an explicit value is validated against the membership set or equality.\n\n## What changed\n\n-packages/plugins/plugin-security/src/security-plugin.ts\n - Step 3.7 computes the wall when a payload names an organization (as before) or the posture walls. Asingleposture pays for nothing new.\n - One refusal (denyTenantPlacement) serves both halves. The step installs the stored-row judgement whenever the wall applies (insert, or a non-array update, as step 3.6 scopes it), composed after step 3.6's judgement when one is installed.\n - The post-next()guard covers the new installation with the data-door stand-down above. Its developer message names what was not evaluated: the business-check sentence is unchanged byte for byte, and a wall-only seam gets its own sentence.\n - The data door is registered through the observing wrapper.\n-packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts(new): 34 cells, 17 per driver.\n- 11 existing plugin-security test files: engine doubles (see "Surface beyond the claim").\n-.changeset/20013-tenant-wall-post-hook.md:@objectstack/plugin-securityminor, BREAKING, remedy,not-required (no-migration-prescription).\n\n## Tests\n\nNew file, realSecurityPluginandObjectQLon both SQL drivers,isolatedposture unless a cell says otherwise. Every refusal assertscodePERMISSION_DENIED,status403 and the wall's message ("the insert/update would place 'OBJECT' in another tenant"), then reads the table straight off the driver.\n\n- Negative cells:\n - a hook-written out-of-scope organization, refused on four paths: a by-id update, an insert, an array insert (the whole write refused) and a predicate update;\n - the same with a businesscheckinstalled and passing (one composed seam);\n - the composed seam still runs thecheck(an in-scope insert the check refuses is refused);\n -groupposture: outside the membership set refused, inside admitted;\n - fail-closed: a host that strips the installed wall-only seam is refused, with the guard's wall sentence.\n- Controls:\n - an in-scope hook write is admitted on all three paths;\n - a supplied out-of-scope value is refused before the hook chain, as before;\n - only refuses more: a supplied out-of-scope value stays refused when a hook would replace it with an in-scope one;\n - an update that does not touch the column is admitted on both update paths;\n - an insert that leaves the column absent is admitted and lands inorg_a;\n - a platform administrator on aprivateobject is exempt;\n - a system-context write is ungated;\n - thesingleposture is unchanged;\n - the data door underisolated: admitted, and the engine's write never ran.\n\nFailing first. The file was committed before the fix (8afaec51a1). On the base it gave 14 red (the 7 negative cells that existed then, × 2) and 18 green. Every red read "expected a refusal, got a completed write".\n\nAblations, each throughscripts/ablation-replace.mjsin WRAP mode, with the anchor hit 1 → 0 and a blob change reported by the tool. Each ran under an outertraprestoring fromHEAD, and was then proven restored (blob == HEAD278b25f19e, emptygit diff HEAD). They ran one43cda19b4, whose blobs forsecurity-plugin.tsand the pin file equal the final head's. The subject is imported relatively by the test file and@objectstack/objectqlis aliased tosrc/in this package'svitest.config.ts, so nodist/sits between a mutation and the run.\n\n| # | mutation | red (of 34) |\n|---|---|---|\n| A1 | the stored-row wall judgement never refuses | 12: by-id, insert, array insert, predicate, composed-with-check,group, × 2 |\n| A2 | the wall's seam installed only when a businesscheckis (the old condition) | 12: by-id, insert, array insert, predicate,group, fail-closed, × 2 |\n| A3 | the guard stands down for every wall-only seam | 2: fail-closed, × 2 |\n| A4 | no data-door stand-down | 2: the data door, × 2 |\n| A5 | the payload judgement dropped | 4: supplied-value control and only-refuses-more, × 2 |\n| A6 | an absentorganization_idjudged too | 2: the absent-value insert control, × 2 |\n| A7 | the composed seam drops the businesscheck| 2: the composed-check cell, × 2 |\n\nA5 has an extra reading. Without the payload judgement, a supplied out-of-scope value is admitted but lands nowhere. The engine's staticreadonlystrip drops a caller-sentorganization_id, which the registry injects asreadonly: true, while a hook-written value is exempt from that strip. That exemption is why the hook path reached the store, and why the payload refusal is the loud half of the supplied case.\n\nSuites (final headcce969cf4d, after mergingorigin/main7766b62282, closure rebuilt):\n- plugin-security: 133 files, 2648 tests passed;\n- plugin-auth: 114 files, 2439 tests passed;\n- runtime: 278 files, 3962 passed, 1 skipped;\n- plugin-securitytypecheck: exit 0; the test layer compiles all 131 test files (--listFiles), and its ledger holds 0 files / 0 errors.\n\n## Gates\n\nnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsoncce969cf4dderived 62 families: the dispatch-time 49 plus 13 more. The 13 arecheck-adr-0087-registrationandcheck-empty-changeset(each with its self-test),release-rehearsal-clone --self-test,check:engine-double-contract,check:objectql-double-limit,check:objectui-changeset,check:pm-changeset-deadline-census,check:query-options-erasure,check:type-check-coverage,check:type-check-debtandcheck:where-matcher. All 62 ran, and--ranreconciled 62 derived / 62 run / 0 NOT-MEASURED, every line carrying its exit code, all 0.check:dual-build-cjs-loads,check:i18nandcheck:type-check-debtfirst answered PREREQUISITE NOT MET (exit 3) and were re-run green after a full workspace build. The board-probingGITHUB_TOKEN=… node scripts/check-issue-citations.mjspassed: 9 citations resolve.\n\nNarrowed lint:eslint --no-inline-config --format jsonover the 13 touched.tsfiles reported 13 files, 0 errors, 0 warnings. It is a full measurement for these files for three reasons. The population is the**/*.{ts,…}andpackages/**blocks ofeslint.config.mjs. The count, 13, comes from the JSON output. The config never enables type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move the verdict for any untouched file. The repo-widepnpm lintand the Dogfood Regression Gate are left to CI.\n\n## Surface beyond the claim, with reasons\n\nThe new installation runs on every walled insert and update. So every plugin-security test harness that stands in for the engine with a terminal that skips the seam, under a walled posture, was refused by the fail-closed guard (48 red on the first full run). Each double now runs the seam the way the engine does: an insert's rows as sent (no hooks in a double), and on an update the by-id row or the matched rows merged with the payload, through the producer's dispatch predicate where the double dispatches updates.\n\n- The 10 files that went red on the first run:authz-matrix-gate,can-write-object-admission,check-only-write-scope,controlled-by-parent-detail-write-authority,controlled-by-parent-master-widener,explain-write-verdict-inputs,no-active-organization-write-refusal,row-write-widener-composition,select-only-write-visibilityandtenant-layer0-verdict-on-operation.\n-explain-dependency-fault, which arrived with the merge oforigin/main(PR #20030) and went red on the merged tree for the same reason.\n-check:engine-double-contractis green, with no ledger change.\n- Census outside the package:grep -rln postHookWriteImageCheck packages --include=*.test.tsnames only plugin-auth'ssys-user-self-service-route.test.ts(PR #20012's). The hand-made SecurityPlugin hosts under a walled posture are runtime'sshare-links-enforcement-contextandstandalone-stack-seeder-declaration-copy. All pass unchanged (plugin-auth and runtime suites green), so none is touched.\n\n## Behaviour that changes (all in the refusing direction)\n\n- Underisolatedorgroup, an insert, by-id update or predicate update whose hook chain leavesorganization_idoutside the caller's organization scope (or the delegator's, ADR-0090 D10) is refused, and nothing is stored.\n- A walled write on a host that installs the wall's judgement and never runs it is refused after the write, with anerrorlog. The ADR-0094 data door is the stated exception, for a wall-only seam.\n\n## Pending changesets\n\nThis change makes no sentence in a pending changeset false. None says the tenant wall is unchanged. The "admitted as before" and "judged exactly as before" sentences in19950-rls-check-multi-row-writes.mdand19989-by-id-update-post-hook-check.mdare scoped to the row-levelcheck, whose judgement this change does not alter. So no deliberate correction was made, andcheck-empty-changesetis green.\n\n## Acceptance notes\n\n-packages/objectql/src/engine.tslines 11846-11847 still read "The Layer 0 tenant wall still judges the PRE-hook image — filed separately". After this change that sentence is false: the wall judges the stored row through that same seam. The file was held by the engine lane at dispatch, and this PR does not touch it. Suggested replacement for the engine lane: "(The Layer 0 tenant wall judges this row too, through the same seam; #20013.)".\n- Cost:\n - Under a walled posture, every non-system insert and update now computes the Layer 0 filter.\n - A walled predicate update now always pays the engine's memoized matched-row read, because the seam receives matched rows merged with the payload. On an object with an update hook or a row-reading rule that read already happened. On one with neither it is new. There it is onedriver.findover the composedwhere, with no row ceiling on that path. The row-levelcheckseam (#19950) already imposes the same cost where acheckapplies. NOT MEASURED: bulk-update latency or memory.\n- An absent or emptiedorganization_idis not judged by either half. That mirrors step 3.7's "supplied (non-empty)" scope and ADR-0095 D1's reason. A hook that clears the column on an update therefore lands a row with no organization. It is not measured here, and no declared contract covers it.\n- An array UPDATE payload gets no stored-row wall judgement, as step 3.6 gets none (the engine takes one payload per update). The payload judgement still covers it.\n- A by-id update is now judged by the wall twice: the payload as sent, beforenext(), and the stored row, in the engine. The first is what keeps this change refusing only more (A5).\n- The merge commitfe1579223acarries noClaude-Sessiontrailer; the other commits carry the model-free pair.\n"
}
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsReview — ACCEPT (landing gated on CI) · PR #20043 · head
cce969cf4d·domain:servicesseat · 2026-09-24T23:02ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main, ⛔ not against the report's own account.Checklist
- PR shape. Draft, targeting
main. The first line isFixes #20013, andClause-②: no (narrowing)starts its own line. No other closing keyword. - Scope. 14 files, +870 / −71.
security-plugin.tsis the only production source: step 3.7, the post-next()guard, and a wrapper around the data door's registration.- The rest is a new 34-cell test file, engine doubles in 11 existing plugin-security harnesses, and the changeset.
- No
engine.ts, nopackages/spec, nodefault-permission-sets.ts/permission-evaluator.ts/explain-engine.ts. - NOT governed, 941 lines, merges clean over
origin/main.
- Diff, read line by line.
- Step 3.7 keeps its judgement of the
organization_idsupplied as sent, so the change only refuses more. - When Layer 0 applies, it installs the stored-row seam, composing after step 3.6's
checkon the same handle. The engine therefore judges the row it stores for a by-id update, an insert (array included) and a predicate update. A value the hook writes outside the caller's tenant is refused with step 3.7's existingPERMISSION_DENIED/ 403, and nothing is stored. - The fail-closed guard reads the composite's
honouredflag. - The data-door stand-down. The ADR-0094 permission-set door writes without the engine (measured on base). A WeakSet private to the plugin records, by observation, a write the door ran without calling
next(). Only a wall-only seam stands down for it; a seam that carries a businesscheckstill fails closed. - Neither direction is declared, and both are ablated (A3, A4).
- Step 3.7 keeps its judgement of the
- Measurement. Three legs on both SQL drivers, all ADMITTED on base with the row stored in the other tenant, plus two supplied-value controls refused. The seam was absent when no business
checkapplied, which confirms the hypothesis. - Tests. Red first on base: 14 red / 18 green. After the fix, 34/34. Ablations A1–A7 each turn their own cells red, and every restore is blob-identical. plugin-security (2648), plugin-auth (2439) and runtime (3962) are green. The 11 harness doubles now run the seam the way the engine does, and the fail-closed guard was the cause of their first red, as the report shows.
Open questions, answered by the seat (implementation, not escalated):
- A (accept) for the matched-row read on a walled predicate update. It is the same memoized read 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 accepted for the businesscheck, and no measured cost says otherwise. An engine-lane seam input is a follow-up only if a bulk-update cost is measured. - A (as implemented) for the data-door stand-down. It keeps the fail-closed guarantee for every engine host and changes nothing for the door under a business
check, and the fact it keys on is observed.
Out-of-scope findings, one line each:
engine.ts:11846-11847still says the Layer 0 wall judges the pre-hook image, "filed separately". This PR makes that comment false. It is an engine-lane comment, and the suggested text is in the PR's Acceptance notes. Noted.- A hook that clears
organization_idon an update is outside step 3.7's declared scope (ADR-0095 D1): Acceptance notes.
Pending changesets: no pending sentence is made false. The tenant wall is a separate gate from the business
checkthat the 19950 / 19989 notes describe.check-empty-changesetis green.Landing. No Clause-② limb is hit: no
packages/spec/src, and the declaration is a narrowing with a checked changeset. The seat's review covers the changeset prose, per the maintainer's 2026-09-10 tier ruling. The PR goes ready and auto-merge when every check is green.
Generated by Claude Code
- PR shape. Draft, targeting
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat ·session_01Evb5jFDZGKQE9KG4jbMfMF· 2026-09-24T23:33Z- PR fix(plugin-security)!: the Layer 0 tenant write wall holds for the row a write stores (#20013) #20043 →
ed3546fd6donorigin/main. Parent count 1 (a queue squash).tenantWallSeamInstalledis present insecurity-plugin.tsat that commit. The card closedcompletedthrough the PR's one closing keyword. - The Layer 0 tenant write wall now also judges the stored row, for a by-id update, an insert (array included) and a predicate update. A hook-written
organization_idoutside the caller's tenant is refused with nothing stored. - Removed in this stroke, with read-back:
pm:dispatchedand the assignee. The claim is discharged. - Noted, not filed:
engine.ts:11846-11847still says the wall judges the pre-hook image, "filed separately". That is now stale; it is an engine-lane comment, and suggested text is in the PR's Acceptance notes.
Generated by Claude Code
- PR fix(plugin-security)!: the Layer 0 tenant write wall holds for the row a write stores (#20013) #20043 →
- added a commit that references this issue
on Sep 28, 2026 - added a commit that references this issue
on Oct 9, 2026
Filing gate ① — a product defect with a named site and a measurement (class b: the declared contract of the tenant write wall is not held).
domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post [PM seat] domain:services — 🟢 zhuangjianguo · session_013j5gkUCpqQiti4GgPqqmnt #6021).checkis judged on the pre-hook change set, so abeforeUpdatestamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989 dev on PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012, as an out-of-scope finding.domain:servicesseat (plugin-security, step 3.7), after PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 lands (same region). The engine side is thepostHookWriteImageCheckseam host (domain:engine), if the fix needs it.The contract
packages/plugins/plugin-security/src/security-plugin.ts:3182-3190onorigin/main14add487b4(step 3.7) says:The defect
Step 3.7 judges
opCtx.datain the middleware, beforenext()runs thebeforeUpdatechain. Anorganization_idthat a hook writes, rather than the caller, is never judged by the tenant wall.PR #20012 moves the business-RLS
checkof a by-id update onto the stored row. It does not carry the tenant wall, which is a separate gate, so this remains open after that PR.Measured (the #19989 dev, isolated posture, driver-sql and driver-sqlite-wasm, on
e8f163fc3aand unchanged on the PR #20012 head; ⛔ not re-run by this seat)organization_id= org B (control)PERMISSION_DENIED/ 403 (step 3.7)VALIDATION_FAILED(the reference check cannot see the parent). ⛔ Not step 3.7beforeUpdatehook writesorganization_iditself from another payload fieldReachability: only through an app-authored hook that derives
organization_idfrom a value the caller steers. The common parent-stamp shape is caught by the reference check.The insert twin:
packages/objectql/src/engine.ts:11823-11824says 「The Layer 0 tenant wall still judges the PRE-hook image — filed separately, and the fix's host is this seam」. No live card carries it: the tracker cited next to it ininsert-check-post-image.test.tsnow answers 404. This card covers both verbs. ⛔ The insert leg was not measured in this round.Direction (the implementer measures)
The tenant wall holds on the row that is stored, as the business
checknow does. That may mean judging the stored image through the seam the insert, predicate-update and (after PR #20012) by-id update checks already use. The site and shape are the implementer's measured choice.Dedupe
Two semantic issue searches, open and closed:
Adjacent: #19989 (the business-RLS by-id twin, not the tenant wall), #15195 (the Default Organization), and the closed #10103, #8459 and #8208. None covers the tenant wall on a hook-written
organization_id.Dedupe words:
Layer 0 tenant wall pre-hook image·organization_id hook stamp by-id update·step 3.7 supplied organization_id only·organization_id effectively immutable·tenant post-image beforeUpdateGenerated by Claude Code