Skip to content

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

@objectstack-fleet

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).

The contract

packages/plugins/plugin-security/src/security-plugin.ts:3182-3190 on origin/main 14add487b4 (step 3.7) says:

a SUPPLIED (non-empty) organization_id in the write payload must satisfy the SAME Layer 0 filter the read side uses … For UPDATE this makes organization_id effectively immutable in non-platform user contexts: the only value that passes is the caller's active org

The defect

Step 3.7 judges opCtx.data in the middleware, before next() runs the beforeUpdate chain. An organization_id that a hook writes, rather than the caller, is never judged by the tenant wall.

PR #20012 moves the business-RLS check of 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 e8f163fc3a and unchanged on the PR #20012 head; ⛔ not re-run by this seat)

by-id update, caller active org A outcome
supplies organization_id = org B (control) refused, PERMISSION_DENIED / 403 (step 3.7)
re-points a lookup to a parent in org B, and a hook stamps the parent's org refused earlier, VALIDATION_FAILED (the reference check cannot see the parent). ⛔ Not step 3.7
a beforeUpdate hook writes organization_id itself from another payload field admitted; the row is stored in org B

Reachability: only through an app-authored hook that derives organization_id from 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-11824 says 「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 in insert-check-post-image.test.ts now 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 check now 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:

  • 「tenant wall Layer 0 judges pre-hook image; beforeInsert or beforeUpdate hook writes organization_id into another tenant; step 3.7 supplied organization_id only」: 5 hits.
  • 「insert check post-image tenant organization_id stamped by beforeInsert hook … tenant wall judges payload before hooks」: 5 hits.

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 beforeUpdate


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-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:blocked Blocked-by #19989; rationale: step 3.7's own contract says a supplied organization_id must pass the Layer 0 filter, making it "effectively immutable in non-platform user contexts", but the wall judges only the payload as SENT — a beforeUpdate hook that writes organization_id from 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/main 44639665ee 上核对。

    本席核对

    • security-plugin.ts:第 3.7 步的注释原文「… organization_id effectively 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 关闭时本卡解锁。

    解锁后的执行要点

    1. 测更新和插入两个动词:插入这一侧本轮没有测。
    2. 让租户墙判「存下来的那一行」:优先复用插入、谓词更新和(fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 之后的)按 id 更新检查已经在用的同一个接缝。具体落点由实现者测量后选择。
    3. 钉住:钩子写入别的组织的 organization_id,更新和插入都被拒绝(403);调用方自己提交别的组织的对照保持拒绝;同组织的正常写入不受影响。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock scan: the blocker closed; the card goes back to pm:queue. domain:services seat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021) · 2026-09-24T20:35Z


    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-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 judgement newWriteImageCheck it can extend); new test file(s) under packages/plugins/plugin-security/src/; .changeset/20013-*.md. ⛔ Not packages/objectql/src/engine.ts: PR #20021 (engine lane) holds it, and a needed engine change is a stop-and-report. ⛔ Not default-permission-sets.ts / permission-evaluator.ts (#20027, in flight). ⛔ Not explain-engine.ts (PR #20030). ⛔ No packages/spec. Measurement first: the by-id update leg, reproduced on main, 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:

  4. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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.ts lines 3239-3248 on origin/main 246314dffe:\n\n> Both close identically here: a SUPPLIED (non-empty) organization_id in 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 makes organization_id effectively 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-tenant organization_id change-set is caught too (the check inspects the change-set value, not a per-row post-image).\n\nThe wall judged opCtx.data, the payload as the caller sent it, before next() runs the engine's beforeInsert / beforeUpdate chain. A value a hook wrote into organization_id was 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 at packages/objectql/src/engine.ts lines 11846-11847 on 246314dffe: 「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-level check already 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/main 26550c6603. Real SecurityPlugin and ObjectQL, isolated posture, driver-sql (better-sqlite3) and driver-sqlite-wasm. The fixture is a tenant object whose beforeInsert and beforeUpdate hook copies another payload field into organization_id, and a caller whose active organization is org_a.\n\n1. Held. Step 3.7 judged only rows whose organization_id was present in opCtx.data (the suppliedRows filter), before next().\n2. Reproduced on both drivers, all three legs (identical on both):\n\n| leg | outcome on the base | stored organization_id |\n|:--|:--|:--|\n| control: by-id update supplies org_b | refused, PERMISSION_DENIED / 403 ("the update would place 'qa_account' in another tenant") | org_a, unchanged |\n| control: insert supplies org_b | refused, PERMISSION_DENIED / 403 | no row |\n| by-id update, hook writes org_b | admitted | org_b |\n| insert, hook writes org_b | admitted | org_b |\n| array insert, hook writes org_b on the second row | admitted | first row org_a, second org_b |\n| predicate update (multi: true), hook writes org_b | admitted | org_b |\n| control: by-id update, hook writes org_a | admitted | org_a |\n\n3. The seam is installed only where a business check applies, measured: with no row-level policy on the object, an inner middleware saw postHookWriteImageCheck absent 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, computeWriteTenantCheckFilter returns 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 (the check, 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 of sys_permission_set itself, through the metadata protocol, and never calls next(). Measured on the base with a platform administrator (active organization org_a) under isolated: 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 only organization_id such 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 to next() in a plugin-private WeakSet. No operation field is involved that another middleware could set. A seam carrying a row-level check is 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 to computeWriteCheckFilter. Its reason is that such a check "would risk denying legitimate inserts before the auto-stamp runs". This change does not touch computeWriteCheckFilter, 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. A single posture 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-security minor, BREAKING, remedy, not-required (no-migration-prescription).\n\n## Tests\n\nNew file, real SecurityPlugin and ObjectQL on both SQL drivers, isolated posture unless a cell says otherwise. Every refusal asserts code PERMISSION_DENIED, status 403 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 business check installed and passing (one composed seam);\n - the composed seam still runs the check (an in-scope insert the check refuses is refused);\n - group posture: 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 in org_a;\n - a platform administrator on a private object is exempt;\n - a system-context write is ungated;\n - the single posture is unchanged;\n - the data door under isolated: 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 through scripts/ablation-replace.mjs in WRAP mode, with the anchor hit 1 → 0 and a blob change reported by the tool. Each ran under an outer trap restoring from HEAD, and was then proven restored (blob == HEAD 278b25f19e, empty git diff HEAD). They ran on e43cda19b4, whose blobs for security-plugin.ts and the pin file equal the final head's. The subject is imported relatively by the test file and @objectstack/objectql is aliased to src/ in this package's vitest.config.ts, so no dist/ 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 business check is (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 absent organization_id judged too | 2: the absent-value insert control, × 2 |\n| A7 | the composed seam drops the business check | 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 static readonly strip drops a caller-sent organization_id, which the registry injects as readonly: 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 head cce969cf4d, after merging origin/main 7766b62282, 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-security typecheck: 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 --commands on cce969cf4d derived 62 families: the dispatch-time 49 plus 13 more. The 13 are check-adr-0087-registration and check-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-debt and check:where-matcher. All 62 ran, and --ran reconciled 62 derived / 62 run / 0 NOT-MEASURED, every line carrying its exit code, all 0. check:dual-build-cjs-loads, check:i18n and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3) and were re-run green after a full workspace build. The board-probing GITHUB_TOKEN=… node scripts/check-issue-citations.mjs passed: 9 citations resolve.\n\nNarrowed lint: eslint --no-inline-config --format json over the 13 touched .ts files reported 13 files, 0 errors, 0 warnings. It is a full measurement for these files for three reasons. The population is the **/*.{ts,…} and packages/** blocks of eslint.config.mjs. The count, 13, comes from the JSON output. The config never enables type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move the verdict for any untouched file. The repo-wide pnpm lint and 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-visibility and tenant-layer0-verdict-on-operation.\n- explain-dependency-fault, which arrived with the merge of origin/main (PR #20030) and went red on the merged tree for the same reason.\n- check:engine-double-contract is green, with no ledger change.\n- Census outside the package: grep -rln postHookWriteImageCheck packages --include=*.test.ts names only plugin-auth's sys-user-self-service-route.test.ts (PR #20012's). The hand-made SecurityPlugin hosts under a walled posture are runtime's share-links-enforcement-context and standalone-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- Under isolated or group, an insert, by-id update or predicate update whose hook chain leaves organization_id outside 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 an error log. 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 in 19950-rls-check-multi-row-writes.md and 19989-by-id-update-post-hook-check.md are scoped to the row-level check, whose judgement this change does not alter. So no deliberate correction was made, and check-empty-changeset is green.\n\n## Acceptance notes\n\n- packages/objectql/src/engine.ts lines 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 one driver.find over the composed where, with no row ceiling on that path. The row-level check seam (#19950) already imposes the same cost where a check applies. NOT MEASURED: bulk-update latency or memory.\n- An absent or emptied organization_id is 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, before next(), and the stored row, in the engine. The first is what keeps this change refusing only more (A5).\n- The merge commit fe1579223a carries no Claude-Session trailer; the other commits carry the model-free pair.\n"
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Review — ACCEPT (landing gated on CI) · PR #20043 · head cce969cf4d · domain:services seat · 2026-09-24T23:02Z

    Seat session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub and origin/main, ⛔ not against the report's own account.

    Checklist

    • PR shape. Draft, targeting main. The first line is Fixes #20013, and Clause-②: no (narrowing) starts its own line. No other closing keyword.
    • Scope. 14 files, +870 / −71.
      • security-plugin.ts is 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, no packages/spec, no default-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_id supplied as sent, so the change only refuses more.
      • When Layer 0 applies, it installs the stored-row seam, composing after step 3.6's check on 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 existing PERMISSION_DENIED / 403, and nothing is stored.
      • The fail-closed guard reads the composite's honoured flag.
      • 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 business check still fails closed.
      • Neither direction is declared, and both are ablated (A3, A4).
    • 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 check applied, 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):

    1. 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 no using exists to scope it #19950 accepted for the business check, and no measured cost says otherwise. An engine-lane seam input is a follow-up only if a bulk-update cost is measured.
    2. 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-11847 still 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_id on 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 check that the 19950 / 19989 notes describe. check-empty-changeset is 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

  6. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat · 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 → ed3546fd6d on origin/main. Parent count 1 (a queue squash). tenantWallSeamInstalled is present in security-plugin.ts at that commit. The card closed completed through 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_id outside the caller's tenant is refused with nothing stored.
    • Removed in this stroke, with read-back: pm:dispatched and the assignee. The claim is discharged.
    • Noted, not filed: engine.ts:11846-11847 still 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

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