Skip to content

fix(tenancy): 八处把被降级的 OS_MULTI_ORG_ENABLED 当「是不是多组织」在读 (#5262) - #5302

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5262-posture-misreads
Aug 4, 2026
Merged

fix(tenancy): 八处把被降级的 OS_MULTI_ORG_ENABLED 当「是不是多组织」在读 (#5262)#5302
os-zhuang merged 2 commits into
mainfrom
claude/issue-5262-posture-misreads

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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.ts SchemaRegistry
这个类构造在 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.ts isMultiTenantMode()
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_organization insert 上的 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 误读的爆炸半径就从「慢」变成「墙没有东西可过滤」,那条断言会红。


对抗性验证(全部真实执行)

每个新测试文件都对未修复源码跑过,确认相应用例变红:

对未修复源码 修复后
objectql 3 failed / 4 passed (7) 7 passed
runtime 9 failed / 11 passed (20) 20 passed
plugin-dev 4 failed / 2 passed (6) 6 passed
driver-sql 6 failed / 5 passed (11) 11 passed
cloud-connection 7 failed / 6 passed (13) 13 passed
cli 4 failed / 3 passed (7) 7 passed

每次通过的那几条都是 back-compat / 单组织 / 意图不变的用例——它们本来就该两边都绿,这正是「只订正 knob、不改意图」的证据。

额外的双向证明app-plugin.tenancy-posture.test.ts 的注释声称「一个 requested-posture 的修法会通过其余全部断言、只在 degraded 这几条上失败」。这条声称也被真实验证了——把 helper 临时改成只读 requested,得到:

FAIL  DEGRADED boot seeds INLINE — the effective posture is what makes this right
FAIL  DEGRADED boot DOES register it — same reasoning as the inline seed
FAIL  'posture=isolated, DEGRADED'
Tests  3 failed | 17 passed (20)

正好三条,正好是 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 字段来打开多租户:

(driver as any)._multiTenantMode = true;

本 PR 把那个 memo 删掉换成了 live 读(见下面「缓存那一条」),于是这一戳不再有任何效果 → 走 live 读、env 里没有 posture → 判为单租户 → 告警不发 → expected [] to have a length of 1 but got +0。改之前已在本地对原测试复现出同一条断言、同一行号,确认成因,没有靠猜。

修法:与 driver-sql 同款——设真实的 OS_TENANCY_POSTURE=isolatedfinally 还原。这不只是「修好」,是更强:原来的开法伸手进实现细节,所以实现一变它就开始说谎,而且是静悄悄地说谎(用例仍然「运行」,只是不再测它自称在测的东西)。驱动文档化的 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):

pnpm --filter @objectstack/driver-sqlite-wasm test -> Test Files 16 passed (16) | Tests 232 passed
pnpm --filter @objectstack/driver-sql         test -> Test Files 59 passed | 4 skipped (63) | Tests 740 passed | 44 skipped
typecheck (driver-sql, driver-sqlite-wasm) -> 全部 Done
eslint(改动文件) -> 干净

第一版 CI 的其余 job(Test Core (1/3)(3/3)Build CoreESLintCheck Changeset、三个 Dogfood 分片、Temporal Conformance全绿,红的只有这一条。


测试与门禁(第一版,仍然有效)

pnpm --filter @objectstack/objectql         test  ->  Test Files 117 passed (117) | Tests 1875 passed
pnpm --filter @objectstack/runtime          test  ->  Test Files  90 passed  (90) | Tests 1333 passed
pnpm --filter @objectstack/plugin-dev       test  ->  Test Files  13 passed  (13) | Tests   77 passed
pnpm --filter @objectstack/driver-sql       test  ->  Test Files  59 passed | 4 skipped (63) | Tests 740 passed | 44 skipped
pnpm --filter @objectstack/cloud-connection test  ->  Test Files   2 passed   (2) | Tests   30 passed
pnpm --filter @objectstack/cli              test  ->  Test Files  70 passed  (70) | Tests  633 passed

typecheck (objectql, runtime, plugin-dev, driver-sql, driver-sqlite-wasm, cli) -> 全部 Done

确定性门禁:

node scripts/check-engine-double-contract.mjs    -> OK — 22 pinned, 31 DEBT, 1 exempt
node scripts/check-startup-registry-verdict.mjs  -> OK — 40 seam(s) / 1429 files,无记录判决
pnpm check:adr-anchors     -> OK (26 anchored file(s))
pnpm check:doc-authoring   -> OK (362 files clean)
pnpm check:org-identifier  -> OK (1635 files)
pnpm check:role-word       -> OK (43 baselined)
eslint(全部改动文件)      -> 干净

未改 packages/spec,所以不涉及 check:generated。changeset 已写;未碰 content/docs/releases/plugin-auth/**

⚠️ 一个值得记下的坑:CLI 套件在依赖未构建时会红成 21 个文件(报 Failed to resolve entry for package @objectstack/plugin-email 之类),看起来像本次改动打断的。跑 turbo run build --filter=@objectstack/cli... 之后 70/70 全绿——就是 AGENTS.md §9 描述的那种陈旧构建产物假象。


顺带修的既有测试

driver-sqldriver-sqlite-wasm 两个 *-tenant-scope.test.ts 里各有一处 (driver as any)._multiTenantMode = true; —— 那个字段随缓存一起删掉了。两处都改成设真实的 OS_TENANCY_POSTUREfinally 还原),比戳私有字段更贴近真实解析路径。

缓存那一条

_multiTenantMode 已删,改为 live 读。这是 AGENTS.md「启动期登记表读数不要记成判决」的形状:它把一个进程级事实冻结成了「哪次写先落地」的实例级判决。为了让 live 读廉价,auditMissingTenant 里把 tenantId 早退挪到了 posture 读之前——两个都是无副作用的纯谓词,只是换序,带 tenantId 的正常写路径因此根本走不到 posture 读。有测试钉住换序没有改变任何人的答案。


范围外发现

证据边界

  • cli 那条钉的是导出的 resolveVerifyMultiTenant,不是端到端跑 os verify。这是本包既有的形状(serve.tsdescribeRegisteredDriver 就是这么测的),因为另一条路每个场景要起两个完整 kernel。命令体已收敛成对该函数的一次调用,所以未覆盖的接线只有一行。解析器本身没有任何 stub:测试设真实环境变量,走真实 resolveTenancyPosture()
  • plugin-dev 断言的是「多组织分支被进入了」(通过企业包真实缺失所产生的真实告警),不是「插件成功挂载」。后者在开源 CI 里不可观测,硬要观测就得造一个假的企业包——那等于 stub 掉被测对象本身。
  • 未做浏览器 / 真实 objectstack serve 端到端验收:企业 @objectstack/organizations 是 cloud 私有包,本仓装不上,有墙 posture 的真实 boot 在这里跑不起来。

🤖 Generated with Claude Code

https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t

…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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 4:32pm

Request Review

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/cli, @objectstack/cloud-connection, @objectstack/objectql, @objectstack/driver-sql, @objectstack/plugin-dev, @objectstack/runtime.

44 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/cli)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli, packages/runtime)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via packages/cli, @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-sql, @objectstack/runtime)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/validating-metadata.mdx (via packages/cli)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql, @objectstack/runtime)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sql)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli, @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/driver-sql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/plugin-dev)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli, @objectstack/objectql, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/index.mdx (via @objectstack/cli, @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/objectql, @objectstack/driver-sql, @objectstack/plugin-dev, @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/driver-sql, @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql, @objectstack/runtime)
  • content/docs/protocol/kernel/metadata-service.mdx (via @objectstack/cloud-connection)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/driver-sql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/cloud-connection, @objectstack/objectql, @objectstack/driver-sql, @objectstack/runtime)
  • content/docs/releases/v16.mdx (via @objectstack/cli)
  • content/docs/releases/v17.mdx (via @objectstack/cli, @objectstack/plugin-dev, @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants