发现于 #5081 (ADR-0120 17.x 收尾)的 D6 全仓扫荡,与该单契约无关,按 Prime Directive #10 单独记录,未在 PR #5314 中修改 。
事实
packages/spec/src/data/object.zod.ts:
export const TenancyConfigSchema = lazySchema ( ( ) => z . object ( {
enabled : z . boolean ( ) . describe ( 'Enable multi-tenancy for this object' ) ,
tenantField : z . string ( ) . default ( 'tenant_id' ) . describe ( 'Field name for tenant identifier' ) ,
} , { error : strictTenancyError } ) . strict ( ) ) ;
它上面的 @example 也写着 tenantField: 'tenant_id'。
但平台的租户列是 organization_id(v5.0 起 project → environment、organization_id 是 kernel 注入的那一列),而且另外两处对同一个概念给出的默认值是相反的:
packages/spec/src/security/rls.zod.ts:595,注释原文:
The default tenantField is organization_id to match better-auth's …
函数签名也是 tenantPolicy: (object: string, tenantField: string = 'organization_id')。
packages/plugins/driver-sql/src/sql-driver.ts 的 computeTenantField:声明的 tenantField 只有在该对象确实拥有这个字段时 才采用,否则隐式回落到 organization_id:
if ( tenancyDecl ?. tenantField ) {
const declared = String ( tenancyDecl . tenantField ) ;
if ( fields && Object . prototype . hasOwnProperty . call ( fields , declared ) ) return declared ;
}
if ( fields && Object . prototype . hasOwnProperty . call ( fields , 'organization_id' ) ) return 'organization_id' ;
return null ;
为什么今天不炸
正因为 driver 有那层回落:作者写 tenancy: { enabled: true } 而不写 tenantField 时,Zod 把默认值填成 'tenant_id',对象通常没有 tenant_id 字段,于是 computeTenantField 跳过它、回落到 organization_id —— 结果是对的。所以没有用户可见缺陷 ,这条按 observation-class 记录(finding 标签,不挂 pm:queue),严重度请 triage 轮次自行判定。
为什么仍值得记
这个默认值实际上没有消费方 (它唯一的作用是让 computeTenantField 的第一个分支去找一个不存在的列然后跳过),属于 ADR-0078「declared-but-inert」那一类的候选。
它是 AI 作者会照抄的面:schema 的 @example 明确示范了 tenantField: 'tenant_id'。一个照抄它、并且真的 建了 tenant_id 字段的对象,就会得到一张按 tenant_id 而不是 organization_id 组合的唯一索引 —— 与平台其余部分(RLS 谓词、写入打戳、autonumber 序列)错位。
它正是 ADR-0120 引用 多租户缺陷:unique 物化为全局唯一索引、无视 tenancy,与按租户分裂的 autonumber 序列自相矛盾(跨租户必然撞号 + 存在性探测泄露) #3696 时点名的那个模式:两个子系统给同一个概念起了不同的名字 。ADR-0120 §Terminology 为此把可授权词汇定死为 'organization'('tenant' / 'org' 一律拒绝);这里是同一个词表问题在 tenancy 这一侧的残留。
可能的方向(不预设结论)
把默认值改成 'organization_id',与 rls.zod.ts 的注释和 driver 的回落对齐,同时更新 @example。属于 spec 默认值变更,要判是否 breaking。
去掉 .default(...),让「未声明」如实表达为 undefined,由 driver 的既有回落作为唯一真相;这更贴近 ADR-0078(不留没人读的声明)。
判定为纯文档问题,只改 @example 与 describe。
顺带一提:skills/objectstack-data/** 里长期用 tenant_id 举例的地方,已在 PR #5314 的 D6 扫荡里改成 organization_id,但没有 动 spec 的这个默认值。
Generated by Claude Code
发现于 #5081(ADR-0120 17.x 收尾)的 D6 全仓扫荡,与该单契约无关,按 Prime Directive #10 单独记录,未在 PR #5314 中修改。
事实
packages/spec/src/data/object.zod.ts:它上面的
@example也写着tenantField: 'tenant_id'。但平台的租户列是
organization_id(v5.0 起project→environment、organization_id是 kernel 注入的那一列),而且另外两处对同一个概念给出的默认值是相反的:packages/spec/src/security/rls.zod.ts:595,注释原文:函数签名也是
tenantPolicy: (object: string, tenantField: string = 'organization_id')。packages/plugins/driver-sql/src/sql-driver.ts的computeTenantField:声明的tenantField只有在该对象确实拥有这个字段时才采用,否则隐式回落到organization_id:为什么今天不炸
正因为 driver 有那层回落:作者写
tenancy: { enabled: true }而不写tenantField时,Zod 把默认值填成'tenant_id',对象通常没有tenant_id字段,于是computeTenantField跳过它、回落到organization_id—— 结果是对的。所以没有用户可见缺陷,这条按 observation-class 记录(finding标签,不挂pm:queue),严重度请 triage 轮次自行判定。为什么仍值得记
computeTenantField的第一个分支去找一个不存在的列然后跳过),属于 ADR-0078「declared-but-inert」那一类的候选。@example明确示范了tenantField: 'tenant_id'。一个照抄它、并且真的建了tenant_id字段的对象,就会得到一张按tenant_id而不是organization_id组合的唯一索引 —— 与平台其余部分(RLS 谓词、写入打戳、autonumber 序列)错位。'organization'('tenant'/'org'一律拒绝);这里是同一个词表问题在tenancy这一侧的残留。可能的方向(不预设结论)
'organization_id',与rls.zod.ts的注释和 driver 的回落对齐,同时更新@example。属于 spec 默认值变更,要判是否 breaking。.default(...),让「未声明」如实表达为 undefined,由 driver 的既有回落作为唯一真相;这更贴近 ADR-0078(不留没人读的声明)。@example与 describe。顺带一提:
skills/objectstack-data/**里长期用tenant_id举例的地方,已在 PR #5314 的 D6 扫荡里改成organization_id,但没有动 spec 的这个默认值。Generated by Claude Code