fix(spec)!: HierarchyScopeContext 声明 organizationId 为权威租户字段并转必填 (#5858) - #5973
Conversation
… authority and requires it (#5858) `HierarchyScopeContext` declared `organizationId?` and `tenantId?` side by side with no doc saying which one carries the caller's active organization. The one in-repo producer filled `organizationId` from a `SharingExecutionContext` whose only tenancy member is `tenantId` (structurally always null), while the real consumer reads `organizationId` and skipped tenant isolation on null — two individually contract-compliant ends adding up to a reachable cross-org read (#5852). - `organizationId` documented as AUTHORITATIVE (null = platform/unscoped, matching `EvalUser.organizationId`), per the #3280/#3290 naming convention that `scripts/check-org-identifier.mjs` gates. - `organizationId?: string | null` -> `organizationId: string | null`: a producer that omits the caller's org now fails to compile instead of handing every resolver an `undefined`. - `tenantId` retained as a `@deprecated` alias (NOT removed) with the explicit "a resolver must not depend on it alone" obligation. - `IHierarchyScopeResolver.resolveOwnerIds` documents the fail-closed rule: a null organization is "no org", never "every org". Pins: two compile-time probes (`@ts-expect-error` on the omitted field and on tenantId-only; a RequiredKeys pin in both directions) plus an AST prose pin over the three doc obligations, in a file with a zero budget in test-typecheck-debt.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 111 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31108010067 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
队列管家:⛔ 已修签名再现 = 新问题,本座位不重投(第 8 轮巡检)本 PR 13:52:34Z 在队列里被踢出(run 31108010067)。按 #5810 签名台账认签名,判为 objectstack 表第 2 行「已修签名」的再现,因此 ⛔ 不重投,只留判读与路由。红与本 PR 的 diff 无关(连坐),处置权归本 PR 所属车道( 完整签名(取完整日志归档,非 tail;note 7 的两个陷阱本轮都现身且都被绕过——同 job 的 11 个测试文件与 229 条用例全绿,
台账那一行的三个要素—— 为什么它是新问题而不是那条老 flaky(可证伪的读数,均取自 #4856 的 起因(commit 级,非「相邻」推断):该测试文件由 #5954(
建议动作(本座位无权执行,仅指引):
⛔ 本座位未做任何入队/撤队/重跑/合并/ready-draft 切换/认领。让行核验:本 PR 最近 30 分钟仅 台账升级已在锚点 #5810 提请(⛔ 本座位不自行加表)。 Generated by Claude Code |
按 #5859 裁决(C)追加:`SharingService` 新增 late-bound `tenancy` 姿态探针(读法与 SecurityPlugin 为 Layer-0 墙读 `tenancy` 服务一致,由 SharingServicePlugin 接线), 按 ADR-0105 D1 的既有分叉决定「没有活动组织」意味着什么: - `single`(纯单租户,无组织):行为不变,DEPTH 照常 —— 那是唯一隐含租户,不是「所有组织」; - `group` / `isolated`:权威组织缺失/空白 → 不咨询 resolver,回落 owner-only, warn 点名 ADR-0095 D1 / ADR-0105 D1 与 #5973 的 fail-closed 契约义务; - 姿态解析不出(未接线/抛错/词表外)→ 按有墙处理,未知姿态不是 single 的证据。 测试两个方向都钉:single+无组织仍 widened(先绿保持绿)、walled+无组织拒绝(先红后绿)、 姿态不可解析拒绝、legacy `isolationActive:false` 视为无墙、空白组织在两侧各自的表现。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
…stack-ai#6139) (objectstack-ai#6366) origin/main 上两个已接受立场互相矛盾,resolver 无法同时满足: 1. `IHierarchyScopeResolver.resolveOwnerIds` 要求实现在 `organizationId` 为 `null` 时**无条件** fail closed(「no org 不是 every org」,objectstack-ai#5852/objectstack-ai#5973)。 2. 裁 C(objectstack-ai#5859,PR objectstack-ai#6067 落地)要求 single 姿态部署 —— 根本没有组织 —— 喂显式 null 且 DEPTH 照常生效。 `HierarchyScopeContext` 无从分辨这两个 `null`,于是严格 spec-conformant 的 resolver(cloud PR objectstack-ai#1196 就是照字面无条件 fail-close)必然杀死每一个 single 姿态部署上的企业 DEPTH。契约要求的,正是平台裁决禁止的。 FROM `{ userId, organizationId: string | null, tenantId? }`,null 组织 ⇒ 永远 fail closed。 TO 同上 + **必填** `posture: TenancyPosture`(ADR-0105 D1),义务改为由两个 字段共同判读: - `single` + `organizationId: null` —— **合法**。压根没有组织维度,`null` 即 那唯一的隐式租户;resolver 必须照常解析 DEPTH。在此拒绝不是保守,而是为每一个 无组织部署退役了层级作用域。 - `group` / `isolated` + `null` —— **严格 fail closed**。墙在生效,`null` 是 缺失的约束。这一半未变、未放松 —— 它正是关掉 objectstack-ai#5852 跨组织提权的那一半。 选结构化信号而非散文豁免:散文无法被真正需要据此行动的代码读到 —— resolver 跑在 企业包里,它需要的是调用时的部署事实,不是一段话。 `posture` 必填,理由与 objectstack-ai#5858 令 `organizationId` 必填完全同构:漏传的 producer 必须**编译失败**,而不是把 `undefined` 丢给每个 resolver 去猜 —— 一种猜法跨组织 泄露,另一种静默杀死 DEPTH。对**构造** HierarchyScopeContext 的一方这是破坏性 变更;IHierarchyScopeResolver 的实现方源码兼容,但只看 organizationId 的 resolver 不再 conformant,应改为两字段合读。 producer 侧不增成本:开源 sharing 层本就要解析姿态来决定是否咨询 resolver。该 推导现在收敛到一处(effectiveTenancyPosture()),本地拒绝改由它表达,使「拒绝」 与「上报的姿态」不可能各走各的。仍然 fail closed —— 姿态不可解析时上报最严格的 带墙姿态,绝不是 `single`。 showcase-scope-depth dogfood 用例改用**spec-conformant** 参考 resolver,并按真实 接口定型。原 fixture 取 `c: any` 且完全无视租户字段 —— 这正是 20 条 single 姿态 e2e 一路绿灯的原因:从来没有 spec-conformant 的 resolver 被练到,CI 因此看不见这 个矛盾。已验证非空转:把旧的无条件规则放回去,三条 DEPTH 证明立刻失败。 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY Co-authored-by: Claude <noreply@anthropic.com>
Fixes #5858
按 issue 上 PM 2026-08-06 裁决执行(B + C + fail-closed doc)。⛔ 未重开方向讨论。
改了什么
packages/spec/src/contracts/sharing-service.ts一处契约:organizationId定为权威字段。doc 写明「承载调用方的活动组织,null= platform/unscoped」,措辞与packages/spec/src/identity/eval-user.zod.ts:129的EvalUser.organizationIddescribe 对齐,并在注释里点名依据(Unify the developer-facing org identifier: hooks exposesession.tenantIdwhile RLS/seed/columns useorganizationId(addorganizationIdas the blessed name) #3280 定名、Remove the deprecated hook/actionctx.session.tenantIdalias in the next major (converge onorganizationId) #3290 v11 移除session.tenantId别名、scripts/check-org-identifier.mjs硬门)。organizationId?: string | null改为organizationId: string | null。producer 漏填从「doc 没说清楚」变成 typecheck 失败;「没有组织」仍然合法,但必须显式写成null——null是值,不是缺省。tenantId保留为 deprecated 别名,⛔ 未删除。JSDoc 标@deprecated,写明它仍是通用 driver 层租户旋钮(database-per-tenant 内核里合法地放 environment id),并明确「resolver 不得单独依赖它,也不得拿它顶替为null的organizationId」。退役另行立单。IHierarchyScopeResolver.resolveOwnerIds补 fail-closed 义务:权威字段为null时实现方必须 fail-closed —— 「没有组织」永远不等于「所有组织」,返回 owner-only 或抛出(sharing 层同等处理),⛔ 不得静默按无租户约束构建 owner set,也不得退回读tenantId。外加
.changeset/hierarchy-scope-organization-authority.md(@objectstack/spec: major)与钉子测试。未碰packages/plugins/plugin-sharing的任何代码(生产逻辑与测试都没动 —— 见下方「机械补齐:零处」)。P1 / P2 / P3 前提核验(全部成立)
P1 ——
check-org-identifier.mjs门与 #3280/#3290 公约仍活着:成立。脚本头部原文仍在陈述该公约:「#3280 made
organizationIdthe blessed developer-facing name for the caller's active org across the JS authoring surface … #3290 REMOVED it from the hook/actionctx.sessionsurface entirely (v11 major)」。同一段还自己记录了本裁决援引的那条:driver 层tenantId在 database-per-tenant 内核里合法地是 environment id,门禁刻意不匹配它。本地跑过一遍:check-org-identifier: OK (1660 author-facing source file(s), no removed session.tenantId alias)。P2 —— 仓内唯一 producer 仍是
plugin-sharing/src/sharing-service.ts:875-880:成立。HierarchyScopeContext是结构化类型,构造点即「传给resolveOwnerIds的第一实参」。两轮普查:该类型在 spec 之外零 import,所以不存在「显式标注了该类型的第三处」。后 4 处全是 resolver 实现(消费侧,签名
c: any),不是构造点;plugin-sharing/src/sharing-service.test.ts:853/867/881/893同理,是 stub resolver 而非 stub context。机械补齐:零处。 唯一构造点 :875-880 本来就填了
organizationId这个键(填的是恒null的值 —— 语义修复归 #5859,本单⛔不做),所以必填化在本仓内是零破坏。实测两个可能受影响的包 typecheck 全绿,没有一处需要补organizationId: null:如实申报一处与裁决预期的偏差:裁决预计「qa/dogfood 与 plugin-sharing 测试 stub」需要补键,实测不需要 —— 因为它们是 resolver 实现而不是 context 构造点。裁决对生产构造点「预计不红」的判断则完全成立。
P3 —— PM 裁决评论仍是最后一条裁决:成立。 issue #5858 共 2 条评论:认领评论(12:28:59Z)+ PM 裁决评论(12:41:15Z)。裁决之后无任何评论,不存在更晚的维护者否决。
测试
钉子落在
packages/spec/src/contracts/sharing-service.test.ts(该文件在packages/spec/test-typecheck-debt.json里无条目,即错误预算为 0 —— 任何新错误直接让check:test-typecheck变红;#5286 的机制)。organizationId的对象字面量、以及只给tenantId的对象字面量,各带一条@ts-expect-error。['userId', 'organizationId'],反向用@ts-expect-error钉住tenantId仍是可选 —— 这同时是「⛔ 不删除tenantId」的守卫(删了它,反向那行引用即断)。['userId', 'organizationId', 'tenantId'](反空过),organizationIddoc 含AUTHORITATIVE/platform/unscoped/MUST scope its owner set by this field,tenantIddoc 含@deprecated/Not the authority for hierarchy scoping,resolveOwnerIdsdoc 含Fail CLOSED/never widen;并以userId作诚实反例(断言它不含AUTHORITATIVE—— 它是身份不是租户),证明匹配是有区分度的。反向验证(方向为预先判定的「红」,实测吻合)。 把
?放回去、其余不动,重跑测试层 tsc:3 条错误落在预算为 0 的文件上 → 红。恢复后复跑,
typecheck与check:test-typecheck均绿。正向证据(全部前台阻塞执行,共享 flock 串行):
生成物
按纪律整体重生成(⛔ 未手改任何生成物,⛔ 未动
authorable-surface.base.json锚点):gen:schema→gen:openapi(#5371)→gen:api-surface。结果:生成物零 diff —— 本单改的是纯 TypeScript 接口(无 Zod schema、无新增/删除导出),api-surface.json只记录名字级别的存在性,HierarchyScopeContext早已在册。authorable-surface.base.json相对72bd873滞后 16 个 key —— 生成器自己声明这是预期而非错误,按纪律不做重锚。changeset 档位
@objectstack/spec: major。必填化对 producer 是 breaking(implementer 反而更容易 —— 必填属性只会让消费更简单)。当前处于 v17 rc 窗口(packages/spec版本17.0.0-rc.2,.changeset/pre.json为mode: pre/tag: rc),窗口内@objectstack/spec: major是既有惯例而非新开先例,同窗口在库的同档 changeset 包括adr-0113-required-write-contract.md(同为「把一个宽松契约收紧成必填/强制」)、adr-0114-field-errors-rename.md、多个retire-*。故本单按 major 落,⛔ 未为了凑档位把字段降级回 optional。与同批其他单的关系(如实申报)
IStorageService.list(prefix)契约成员(零消费方,双适配器语义分叉 —— #5266 方案 2,维护者已批) #5540(packages/spec/src/contracts/storage-service.ts摘IStorageService.list):同目录不同文件,零共享符号。PM 预警的「生成物基线相交」在本单侧没有发生 —— 本 PR 生成物零 diff(上面已实测),而 spec: 按 ADR-0049 摘除IStorageService.list(prefix)契约成员(零消费方,双适配器语义分叉 —— #5266 方案 2,维护者已批) #5540 摘导出必然改api-surface.json,所以串行接力时冲突面是单向的,只在 spec: 按 ADR-0049 摘除IStorageService.list(prefix)契约成员(零消费方,双适配器语义分叉 —— #5266 方案 2,维护者已批) #5540 那一侧。packages/spec/scripts/build-openapi.ts摘路由段):不同文件;本单未动任何 Zod schema,重跑gen:openapi后openapi.json零 diff。HttpMethod,后写覆盖前写 —— 参考页只描述了 5 值那份,7 值的线上契约(HEAD/OPTIONS)不见了 #5832(shared/http.zod.ts的HttpMethoddef-key 碰撞):不同文件,且HierarchyScopeContext是纯 TS 接口、根本不进 json-schema 的 def-key 命名空间。HookEvent的枚举注释仍在说批量写把行级谓词「carried ininput」—— 与 #5273 同一句假话,在同文件 40 行之外 #5899(data/hook.zod.ts的HookEvent注释订正):不同文件,纯注释。四单均为「完全无影响」 —— 既没变简单,也没变难,更没变得不必要。没有任何一单的完成范围被本 PR 覆盖或抢跑。
未做的事(范围红线)
tenantId(也未删organizationId)—— 退役走单独流程。packages/plugins/plugin-sharing:producer 把租户值真正映射进organizationId的语义修复归 [#5852 producer 半边]resolveOwnerScopeIds按权威字段填充 hierarchy resolver context + 补「resolver 收到的 org 非空」断言 #5859(identity 车道),本单只把契约面钉住,让那处修复有一个明确的目标字段。Generated by Claude Code