fix(tenancy): 八处把被降级的 OS_MULTI_ORG_ENABLED 当「是不是多组织」在读 (#5262) - #5302
Merged
Conversation
…s this multi-org?" (#5262) ADR-0105 D1 made OS_TENANCY_POSTURE the authoritative knob and demoted OS_MULTI_ORG_ENABLED to a back-compat *input* of resolveTenancyPosture(). A deployment configured the documented way — OS_TENANCY_POSTURE=isolated (or group), legacy boolean unset — therefore reads false from resolveMultiOrgEnabled() while running a fully mounted organization wall. #5233 fixed two sites in plugin-auth; a census found eight more, all written before that function's doc comment was corrected. Third recurrence of the shape (cloud#1020, #5233). Each site was judged separately for WHICH posture answers its question — what the operator REQUESTED, or what the `tenancy` service reports is actually IN FORCE — rather than converted mechanically: REQUESTED posture (postureEnforcesWall(resolveTenancyPosture())): - objectql SchemaRegistry — constructed below the kernel, no service registry to ask. The organization_id column was always provisioned; what diverged is its INDEX, so a posture-only deployment ran the Layer 0 wall's hottest predicate unindexed while SecurityPlugin compiled that same wall. - plugin-dev — this branch is what MOUNTS the wall, so asking whether the wall is up would be circular. Mirrors serve.ts. Its diagnostic now names the requested posture instead of asserting OS_MULTI_ORG_ENABLED=true at an operator who never set it. - driver-sql isMultiTenantMode() — a driver has no kernel to ask, and a suppressed warning is the costlier error for a diagnostic. Also drops the _multiTenantMode memo, which froze a process-level fact into a per-instance verdict on whichever write landed first; the read is now live, affordable because auditMissingTenant consults it only after the tenantId early-out. - cli `os verify` — resolves a flag before any kernel exists. This site produced a GREEN verification run over an unverified property. EFFECTIVE posture (the `tenancy` service, falling back to requested): - runtime AppPlugin, both seeder sites — they ask "will the per-org replay run instead of me?", and on an ADR-0093 D5 degraded boot that replay does not exist, so keying on the request would defer to a replay that can never happen. - cloud-connection marketplace local install, both sites — same reasoning; the install path is a write path that landed sample rows with no organization_id, outside the wall its own reads apply. Single-org behaviour is unchanged at every site; only the knob each one reads is corrected. resolveTenancyPosture()'s own fallback chain is untouched — it remains the sole legitimate consumer of the legacy boolean, and is now the only caller left in the repo. Every new test was run against the unfixed source and confirmed red; the runtime suite additionally pins that a REQUESTED-posture fix fails exactly the three degraded assertions, which is what makes the per-site judgment falsifiable rather than asserted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 6 package(s): 44 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
… knob, not the deleted memo (#5262) Regression from this branch's own driver-sql change, caught by CI (Test Core 2/3): `SqliteWasmDriver extends SqlDriver`, and this suite's tenant-audit test switched multi-tenant mode on by poking the private `_multiTenantMode` memo: (driver as any)._multiTenantMode = true; That memo is gone — `isMultiTenantMode()` now resolves the tenancy posture live — so the poke silently stopped doing anything, the gate read single-org, no warning was emitted, and the assertion failed with "expected [] to have a length of 1 but got +0". Reproduced locally against the pre-fix test before changing it. Fixed the way the sibling driver-sql suite already was: set the real `OS_TENANCY_POSTURE=isolated` and restore it in a `finally`. This is strictly better than what it replaces — the old poke reached into an implementation detail, so it began lying the moment that detail changed, which is exactly how it failed. Driving the documented knob cannot go quietly stale that way. Also strengthened the neighbouring `bypassTenantAudit` case in the same block, which was VACUOUS: it never set a posture, so the audit returned at the multi-tenant gate and `warnSpy` was empty regardless of the flag under test — it would have passed with the flag deleted. (Vacuous before this branch too; it simply never poked the memo the way its sibling did.) It now sets the posture, so the flag is the only thing that can keep the log quiet; verified by deleting the flag and watching it go red. Repo-wide grep confirms no third package pokes `_multiTenantMode`; the only remaining mentions are this branch's own comments and the tombstone assertion in sql-driver-tenant-audit-posture.test.ts. Full suites (not just the touched file): driver-sqlite-wasm 16 files / 232 tests passed; driver-sql 63 files (59 passed, 4 skipped) / 740 passed + 44 skipped. typecheck Done for both; eslint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
os-zhuang
marked this pull request as ready for review
August 4, 2026 17:03
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5262
ADR-0105 D1 把
OS_TENANCY_POSTURE定为权威 knob,OS_MULTI_ORG_ENABLED降级成resolveTenancyPosture()的回落输入。于是一台按文档配置的部署(OS_TENANCY_POSTURE=isolated|group,遗留布尔不设)从resolveMultiOrgEnabled()读到false,而组织墙是完整挂上的。#5233 修了 plugin-auth 的两处;普查出的另外八处全部写在那条注释被订正之前。这是同一缺陷形状的第三次复发(cloud#1020、#5233)。本 PR 覆盖 issue 清单的全部 6 处,外加
cloud-connection的 2 处,共 8 处,无遗留。逐站点的 knob 选择理由
维护者要求「逐站点判断该用哪一种」,不要一刀切。判据是这个站点真正在问什么。
读 REQUESTED posture ——
postureEnforcesWall(resolveTenancyPosture())1.
packages/objectql/src/registry.tsSchemaRegistry这个类构造在 kernel 之下,没有 service registry 可问。它要的事实是「这里会不会有东西按
organization_id过滤」,那由请求决定:一台降级 boot 的部署之后装上企业包时,索引必须已经在。两种错误也不对称——单组织栈上多一个索引是死重量,有墙栈上少一个索引是全表扫描打在系统里最热的谓词上,所以朝「建索引」失败。2.
packages/plugins/plugin-dev/src/dev-plugin.ts这个分支就是把墙立起来的那件事,问「墙立起来没有」是循环的。与
serve.ts:1772完全同构。3.
packages/plugins/driver-sql/src/sql-driver.tsisMultiTenantMode()driver 由连接配置构造,根本够不到 kernel / service registry,effective posture 在这里不可达。而且对一条告警来说不对称方向是对的:降级栈上多一行日志的代价是一行日志,有墙栈上少一行就是本次要修的缺陷。
4.
packages/cli/src/commands/verify.ts在任何 kernel 存在之前解析 CLI flag,问的字面上就是「操作者要这一轮证明什么」。
读 EFFECTIVE posture —— kernel 内的
tenancy服务(回落到 requested)5 + 6.
packages/runtime/src/app-plugin.ts(inline seed 与registerHotReloadSeeder)它们真正要的是「per-org replay 会不会代替我跑」,而那个 replay 是企业
@objectstack/organizations挂在sys_organizationinsert 上的 middleware。ADR-0093 D5 降级 boot 里它不存在,此时按 request 判就是让位给一个永远不会发生的 replay ⇒ 整个栈一行 seed 数据都没有。两处是同一条策略的两半。7 + 8.
packages/cloud-connection/src/marketplace-install-local-plugin.ts(安装期 seed 与 rehydrate heal)同上。且安装期那处是写路径:判错时整个 app 的样例数据以
organization_id = NULL落库,落在自己的墙外面,行存在但谁都看不见。这两处都不是「真的就想要那个遗留输入」——问的都是「per-org replay 会不会接手」,与 app-plugin 完全同类,所以都改了。
回落链未动:
resolveTenancyPosture()自己对遗留布尔的回落原样保留。改完之后resolveMultiOrgEnabled()在全仓只剩它一个调用者(packages/types/src/env.ts:161)——这既是 back-compat 的唯一支点,也是这轮清扫做干净了的证据。packages/types/src/env.ts的注释按要求未改。一处与 issue 描述不符,如实记录
issue 把站点 1 的后果写成「组织墙要用的列按单租户走」。实际不是这样:
applySystemFields早已把organization_id列改成无条件 provision,multiTenant只门控这一列是否建索引。所以真实缺陷是「有墙的 posture-only 部署上,Layer 0 墙最热的谓词跑在无索引列上」——是性能/执行计划问题,不是数据形状分叉。仍然值得修,但严重度低于 issue 的措辞,测试也只钉这一条事实,没有顺着更宽的说法写。
registry-tenancy-posture.test.ts里留了一条断言守住这个前提:如果将来有人把列重新变成条件性的,posture 误读的爆炸半径就从「慢」变成「墙没有东西可过滤」,那条断言会红。对抗性验证(全部真实执行)
每个新测试文件都对未修复源码跑过,确认相应用例变红:
objectqlruntimeplugin-devdriver-sqlcloud-connectioncli每次通过的那几条都是 back-compat / 单组织 / 意图不变的用例——它们本来就该两边都绿,这正是「只订正 knob、不改意图」的证据。
额外的双向证明:
app-plugin.tenancy-posture.test.ts的注释声称「一个 requested-posture 的修法会通过其余全部断言、只在 degraded 这几条上失败」。这条声称也被真实验证了——把 helper 临时改成只读 requested,得到:正好三条,正好是 degraded。这让「逐站点判断」是可证伪的,而不是嘴上说说。
补:修 CI 打红的
driver-sqlite-wasm(第二个 commit)第一版把
Test Core (2/3)打红了,是本分支自己造成的真实回归,已修。成因:
SqliteWasmDriver extends SqlDriver,而packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts的 tenant-audit 用例靠戳私有 memo 字段来打开多租户:本 PR 把那个 memo 删掉换成了 live 读(见下面「缓存那一条」),于是这一戳不再有任何效果 → 走 live 读、env 里没有 posture → 判为单租户 → 告警不发 →
expected [] to have a length of 1 but got +0。改之前已在本地对原测试复现出同一条断言、同一行号,确认成因,没有靠猜。修法:与
driver-sql同款——设真实的OS_TENANCY_POSTURE=isolated,finally还原。这不只是「修好」,是更强:原来的开法伸手进实现细节,所以实现一变它就开始说谎,而且是静悄悄地说谎(用例仍然「运行」,只是不再测它自称在测的东西)。驱动文档化的 knob 不会这样过期。顺带修掉一条真·空测:同一个
describe里的does not warn when bypassTenantAudit is set从来没设过 posture,于是 audit 在多租户门那里就早退了,warnSpy无论bypassTenantAudit是什么都是空——把那个 flag 整个删掉它也照样绿。(这在本分支之前就是空的,只是它的兄弟用例碰巧戳了 memo、它没戳。)现在补上 posture,flag 成了唯一能让日志安静下来的东西;已通过「删掉 flag → 变红」验证它不再是空测。全仓 grep 确认没有第三个包这么干:
_multiTenantMode现存的引用只剩本分支自己的注释,以及sql-driver-tenant-audit-posture.test.ts里那条防止 memo 被重新引入的墓碑断言。这一轮跑的是两个包的完整套件,不是只跑改动的那个文件(上一轮红就是因为只跑了
driver-sql):第一版 CI 的其余 job(
Test Core (1/3)、(3/3)、Build Core、ESLint、Check Changeset、三个Dogfood分片、Temporal Conformance)全绿,红的只有这一条。测试与门禁(第一版,仍然有效)
确定性门禁:
未改
packages/spec,所以不涉及check:generated。changeset 已写;未碰content/docs/releases/、plugin-auth/**。Failed to resolve entry for package @objectstack/plugin-email之类),看起来像本次改动打断的。跑turbo run build --filter=@objectstack/cli...之后 70/70 全绿——就是 AGENTS.md §9 描述的那种陈旧构建产物假象。顺带修的既有测试
driver-sql与driver-sqlite-wasm两个*-tenant-scope.test.ts里各有一处(driver as any)._multiTenantMode = true;—— 那个字段随缓存一起删掉了。两处都改成设真实的OS_TENANCY_POSTURE(finally还原),比戳私有字段更贴近真实解析路径。缓存那一条
_multiTenantMode已删,改为 live 读。这是 AGENTS.md「启动期登记表读数不要记成判决」的形状:它把一个进程级事实冻结成了「哪次写先落地」的实例级判决。为了让 live 读廉价,auditMissingTenant里把tenantId早退挪到了 posture 读之前——两个都是无副作用的纯谓词,只是换序,带 tenantId 的正常写路径因此根本走不到 posture 读。有测试钉住换序没有改变任何人的答案。范围外发现
plugin-dev没有 ADR-0093 D5 的 fail-fast:dev 栈请求了组织墙但企业包缺失时只 warn 就继续跑无墙,而serve.ts在同一事实上是拒绝启动。本 PR 之后这条更容易被触发(在此之前 posture-only 的 dev 栈根本进不了那个分支),但补 fail-fast 是行为变更而非 knob 订正,且throwvsprocess.exit在库形态插件里的正当性与serve.ts不同,留给维护者定。已按 Prime Directive chore: version packages #10 归档,未指派。证据边界
cli那条钉的是导出的resolveVerifyMultiTenant,不是端到端跑os verify。这是本包既有的形状(serve.ts的describeRegisteredDriver就是这么测的),因为另一条路每个场景要起两个完整 kernel。命令体已收敛成对该函数的一次调用,所以未覆盖的接线只有一行。解析器本身没有任何 stub:测试设真实环境变量,走真实resolveTenancyPosture()。plugin-dev断言的是「多组织分支被进入了」(通过企业包真实缺失所产生的真实告警),不是「插件成功挂载」。后者在开源 CI 里不可观测,硬要观测就得造一个假的企业包——那等于 stub 掉被测对象本身。objectstack serve端到端验收:企业@objectstack/organizations是 cloud 私有包,本仓装不上,有墙 posture 的真实 boot 在这里跑不起来。🤖 Generated with Claude Code
https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t