Repository navigation
driver-sql: 表级 { fields, unique: true } 在 tenant-scoped 对象上落成平台级 UNIQUE —— normalizeDeclaredIndex 不补租户列(#4698 实例 2 移交) #4986
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 3, 2026 🔒 认领:PM 循环第 6 轮
会话:session_01NrmBxj8rK2uGCnh9aipjwX(账号xuyushun441-sys)
分支:claude/issue-4986-declared-unique-tenant-column
Worktree:objectstack-issue-4986
域:domain:engine
文件面:packages/plugins/driver-sql/src/schema-drift.ts(normalizeDeclaredIndex 及 drift 两侧)及同目录测试方向裁定(正文的「并列 vs 报错」岔路,按既有规范自决):补租户列,与字段级
unique: true的uniqueIndexesFromFields同语义 —— 同一意图两种写法必须一个语义,既有规范已定答案;drift 两侧同步,防造新假漂移(#4884 的作业先读)。
⛔spec/**/protocol.ts零改动;releases/ 零触碰。在飞 #5025(metadata-protocol)与本文件面不交。
Generated by Claude Code
xuyushun441-sys commented
on Aug 4, 2026 CollaboratorAuthorMore actions停工上报:未写任何代码。裁定的事实前提被证伪,且硬约束下无法自洽落地
会话
session_01NrmBxj8rK2uGCnh9aipjwX,分支claude/issue-4986-declared-unique-tenant-column(已建 worktree,零提交,已清理)。我不是在重开「补租户列 vs 响亮报错」这个岔路 —— 我是在报告:这个岔路的两个分支共用的前提(「两种写法的语义分歧是历史遗留、既有规范已定答案 = 补进去」)与仓库现状相反。声明索引取列 verbatim 不是疏漏,是五处成文、带论证、且有回归测试钉死的既定契约。
一、契约被写死在五个面上
packages/spec/src/data/object.zod.ts的IndexSchema—— 注释原文:「A DECLARED index is materialized over exactly the columns listed infields— no tenant column is injected, unlike field-levelunique(多租户缺陷:unique 物化为全局唯一索引、无视 tenancy,与按租户分裂的 autonumber 序列自相矛盾(跨租户必然撞号 + 存在性探测泄露) #3696). The distinction is deliberate: 字段级unique没有写复合的语法,所以它的默认值必须承载租户范围;而这里作者已经把列写全了,且很多声明索引本来就是平台级的(DNS 主机名、保留 slug、外部 provider id)。Tenant-scoped declared indexes are written explicitly and always have been ——fields: ['organization_id', 'code']。」并且:「'global'在这里什么都不改变,只是同义词。」这段describe()会进生成的 JSON Schema。content/docs/data-modeling/indexing.mdx—— 一整节《Two ways to say "unique" — and they mean different things》,带三行对照表,明确写「Declared index{ fields: ['email'], unique: true }→(email)→ Platform-wide」,并给出改法:「To scope one per tenant, list the column yourself」。该代码块带{/* os:check */},是被校验的。content/docs/references/data/object.mdx:71—— 生成的参考表,同一句话。packages/plugins/driver-sql/src/sql-driver.ts的syncDeclaredIndexes文档块 —— 「The column list is taken VERBATIM — no tenant column is injected here…so guessing would break it.」packages/lint/src/data-model-rules.ts的 R10unique/double-declaration—— 整条规则的判据、message 和 fix 文案都建立在「声明索引 = 平台级」之上,其 fix 原文就是:「for per-tenant uniqueness … spell the index out asfields: ['organization_id', '<name>']if you want it explicit」。
二、已有回归测试直接钉死了裁定要求的用例 (d)
packages/plugins/driver-sql/src/sql-driver-unique-tenancy.test.ts头注把契约列成四行,第四行是:* - declared `indexes[]` → verbatim columns, never rewritten其中一条用例叫
leaves declared object-level indexes exactly as authored,场景与裁定要求的测试 (d) 逐字相同(带organization_id的对象 + 单列声明唯一索引 + 两个租户写同一个值),断言方向正好相反:expect(Object.values(uniques)).not.toContainEqual(['organization_id', 'slug']); await driver.create('slug_reservation', { organization_id: 'org_a', slug: 'acme' }); await expect( driver.create('slug_reservation', { organization_id: 'org_b', slug: 'acme' }), ).rejects.toThrow(/UNIQUE constraint failed|duplicate key value/);
裁定的测试 (d)(「两个租户能持有同一个
name」)与这条用例不可能同时为绿。另有一条accepts unique: 'global' on a declared index as a synonym of true钉死了'global'在声明索引上不改变任何行为 —— 而「补租户列」方案必须让'global'变成退出开关,否则平台级唯一在租户化对象上无法表达。我在当前
origin/main(e001a1f)上跑过,16 个用例全绿:Test Files 1 passed (1) Tests 16 passed (16) (sql-driver-unique-tenancy.test.ts)三、硬约束让这次裁定无法自洽落地
本轮 ⛔
packages/spec/**零改动、⛔packages/lint/**零改动。可是照裁定改完之后:- spec 的
describe()(→ 生成的 JSON Schema,正是 AI 作者读的那份)仍然写着「no tenant column is injected; list the tenant column explicitly for a per-tenant index」,而运行时已经在注入 —— 这就是 Prime Directive chore: version packages #10 明令禁止的 declared ≠ enforced,而且是发生在给 AI 作者看的那一份契约上; - lint R10 的 message 会变成假话:它警告「二者在租户化对象上互相矛盾、全局索引胜出、租户级约束永远够不着」,而改完之后两种写法产出的是同名同形的同一个索引,只剩冗余,不再矛盾;fix 文案给的补救也失效。两处我都不许改。
Prime Directive #13 的原话适用:推翻一条已记录的决定本身是一个决定,需要新的 ADR / 修订状态行,而不是一个悄悄反着做的 changeset。
四、照改会造成的静默数据完整性回退(已清点)
applySystemFields只对managedBy: 'better-auth'早退,所以engine-owned/config/append-only/system-data这些桶都会被注入organization_id,即在 driver 眼里都是 tenant-scoped。这些对象上现存的单列/多列声明唯一索引会全部被改语义:对象 声明索引 managedBy sys_job{ fields: ['name'], unique: true }engine-owned sys_notification{ fields: ['dedup_key'], unique: true }engine-owned sys_email_template{ fields: ['name', 'locale'], unique: true }config sys_presence{ fields: ['session_id'], unique: true }append-only http_delivery{ fields: ['source', 'dedup_key'], unique: true }engine-owned notification_delivery{ fields: ['notification_id', 'recipient_id', 'channel'], unique: true }engine-owned notification_receipt{ fields: ['notification_id', 'user_id', 'channel'], unique: true }engine-owned notification_subscription{ fields: ['topic', 'principal'], unique: true }system-data notification_preference{ fields: ['user_id', 'topic', 'channel'], unique: true }system-data 这些全是幂等 / 去重键,
syncDeclaredIndexes的文档块自己点名了「the convergence-on-conflict pattern the messaging pipeline relies on」。而它们由 sudo writer 在无租户上下文中写入时organization_id是NULL,叠加 SQL 的 NULL-distinct 语义,补进租户列之后这些约束不是收窄,是彻底消失。我已实测确认这条 NULL 通路是真的(探针输出threw= null,第二行同值写入成功),并作为独立缺陷单独归档为 #5030 —— 它目前只影响字段级unique,照本裁定改则会扩散到上表全部九张表。顺带一提,真正想要租户级的那几张表,作者当年就是按文档显式写出来的:
sys_team是['name', 'organization_id'],sys_business_unit是['code', 'organization_id'],sys_member是['organization_id', 'user_id']。这不是偶然,这是契约被遵守的证据。五、来源 issue 自己的建议方向也是「诊断」而非「注入」
#4698 正文对实例 2 的收尾原话:「Instance 2 wants a narrower rule — a declared unique index on a tenant-scoped object whose columns do not lead with the tenant field is almost certainly a mistake, and the existing lint already knows the concept, it just only fires in the two-declaration case.」以及全文的落脚不变量:「a key that nothing reads should not validate clean」。这是在要响亮的诊断,不是在要运行时静默改写列表。
需要裁决的问题
真实缺陷是存在的:AI 作者在租户化对象上写
indexes: [{ fields: ['name'], unique: true }],意图多半是「每租户唯一」,拿到的是平台级 —— 而且现有 lint 只在两种写法同时出现时才响,只写表级的一个字都不说。这个 authoring hazard 该修。分歧只在修在哪一层。方案 A —— 照原裁定:driver-sql 里给声明唯一索引补租户列,
'global'变退出开关- 对本项目的长期健全性:❌。它在没有 ADR 的情况下推翻一条五处成文的决定(违反 PD [WIP] Add Chinese version of the documentation #13);在硬约束下必然留下 spec 的
describe()与 lint R10 两处与运行时相反的成文契约(违反 PD chore: version packages #10);并且把「平台级唯一」的表达能力挂到一个 spec 明文声明「在这里什么都不改变」的关键字上。 - 让 AI 写的元数据难以出错:❌ 净负。裸
unique: true确实变成多租户安全的默认值(+),但代价是:平台级唯一(含凭证、去重键)从此依赖作者记得写'global',而漏写的失败模式是静默的 —— 在 NULL 租户行上约束直接蒸发(driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030),没有报错、没有 WARN、os migrate plan也看不出来。这正好是「把响亮的约束违例换成静默的无约束」,与本轮的取向相反。 - 迁移现实:存量库上会对上表九张表(以及全部应用侧同形声明)判
recreate_index,把已生效的平台级 UNIQUE 换成 NULL 行上不生效的复合索引 —— 一次放宽真实完整性约束的重建,dev 的autoMigrate: 'safe'还会在启动时自动做掉。
方案 B —— 契约层显式化:
IndexSchema上把范围变成必须表态(需 ADR +packages/spec)新增 ADR 记录对 #3696 决定的修订;
IndexSchema上让 scope 成为显式表态(unique: 'global' | 'tenant',或独立scope键);同时加一条 ADR-0087 D2 转换层条目,把存量声明索引的unique: true一律改写成'global'—— 语义与今天逐字相同,存量库零漂移;转换之后,裸true才可以安全地开始表示租户级,或者干脆保持不可用、强制表态。lint 侧再补一条:租户化对象上未表态即报错。- 长期健全性:✅ 最佳。走的是仓库已有的正规通道(ADR + 转换层),
packages/spec保持唯一契约源,文档/lint/驱动三方同时更新而非互相打架;driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 也可以在同一个 ADR 下一并定方向。 - 让 AI 写的元数据难以出错:✅ 最佳,而且是结构性的 —— 作者在 authoring 期就必须说清楚是平台级还是租户级,漏说在 publish 期被响亮拒绝,而不是在生产库上静默变成一个不约束的索引。这正是「declared = enforced、在编写期结构性堵死」优于「消费端宽容」的那条取向。
- 代价:必须解除本轮 ⛔
packages/spec/**/ ⛔packages/lint/**的硬约束,并且要写 ADR。工作量比 A 大,跨包。
方案 C —— 只加诊断,零语义变更(#4698 原本要的那条)
保留 verbatim 契约不动;在能看见租户性的注册期(driver-sql)对「租户化对象 + 唯一声明索引 + 列表不含租户列」发一条响亮诊断,并把 R10 从「两种写法同时出现才响」放宽到只写表级也响。
- 长期健全性:🟡 尚可。不违反任何既定决定,零漂移,零迁移风险。但在没有
'global'这个「我确实是故意的」词汇的前提下(spec 明文说它在这里没作用),上表九张表每次启动都会各响一条 —— 这正是 [schema-drift] Fresh DB boots "drifted": detector can't read COALESCE index columns, then tells the operator to --allow-destructive away a legitimate unique index #4884 刚刚花力气消灭的「框架自己制造启动期假告警」。所以 C 实际上依赖 B 的一小半(让'global'在声明索引上变得有意义)才能落地干净;而那一小半仍然是packages/spec改动。 - 让 AI 写的元数据难以出错:🟡 中等 —— 出错时会被告知,但仍然可以带着错误上生产。
我的建议
选 B,并请求解除本轮对
packages/spec/**与packages/lint/**的零改动约束、允许配套写一份 ADR。两条轴上 B 都是更优解,而且它们在这里不冲突:B 既是唯一不制造「成文契约与运行时相反」的路径(长期健全性),也是唯一让作者在编写期被迫表态、漏表态即被 publish 期响亮拒绝的路径(AI 难以出错)。A 在第二条轴上看似便宜(裸
true变安全默认),但它把凭证级/幂等键级的平台唯一性押在「作者记得写'global'」上,而漏写的后果是静默零约束 —— 净负。如果本轮无论如何不能动
packages/spec,那么正确的动作是把本 issue 挂起等 ADR,而不是退而求其次在 driver-sql 里单边改语义:后者会让 spec、docs、lint、测试四处同时开始说假话,清理成本远高于现在多花一轮。
Generated by Claude Code
PM 裁定撤回 + 公开更正,转
needs-user-decisiondev 的停工上报成立,本 PM 逐条核验属实:pin 测试(
sql-driver-unique-tenancy.test.ts:225「exactly as authored」断言与我要求的测试 (d) 完全相反)、docs 章节(indexing.mdx §Two ways to say unique — and they mean different things)、#5030 的 NULL-distinct 探针,均真实存在。我此前「既有规范已定答案」的裁定前提是错的 —— 字面保留是五处成文 + 测试钉住的刻意决定,不是欠账;照裁定强推会同时制造 declared≠enforced(spec/lint 冻结下)并静默放松九个平台级幂等/去重约束(NULL-distinct 使折叠后的约束消失而非收窄)。以本条为准,原裁定作废。方向上采纳 dev 的 B(ADR + spec 显式 scope 词汇 + ADR-0087 conversion,存量零漂移):两轴同向 —— 长期上唯一不让契约面自相矛盾的路;防 AI 上唯一在 authoring 期强制表态、漏写即响亮拒绝的路。但 B 需要动
packages/spec(协议变更)并解除本轮 spec/lint 冻结,超出本车道授权,交维护者:批 B(建议移交 spec 车道执行)或先 PARK 等 v17 后。⛔ 在拍板前,任何人不应在 driver-sql 单侧改语义。#5030(复合唯一在 NULL 租户行上零约束)是比本单更深的前置缺陷,建议与 B 同一张 ADR 一并定。
Generated by Claude Code
维护者裁定(2026-08-04):本单由维护者单独安排,不入任何 PM 车道的派发视野;
needs-user-decision标签保留作占位,各车道 sweep 请让行。方案 B 的完整分析与 #5030 关联见上两条评论。
Generated by Claude Code
🔒 认领:维护者直接指派(接上条维护者裁定,非 PM 车道派发)
会话:session_01Akrzh2mHi2siSNVPPtfTw7(账号os-zhuang)
分支:claude/issue-4986-analysis-24ol6q
Worktree:objectstack-issue-4986-adr本轮交付物:方案 B 的 ADR 初稿(仅 ADR 文档,零代码语义改动),按维护者要求一次性覆盖各类业务需求场景(平台级唯一/租户级唯一/幂等去重键/单租户栈 NULL 租户行/存量迁移),并把 #5030 的方向纳入同一张 ADR。⛔ 维持上条 PM 撤回令:拍板前不动 driver-sql 单侧语义;本轮不改
packages/spec/packages/lint任何代码,仅提交 ADR 供评审。
Generated by Claude Code
- added a commit that references this issue
on Aug 4, 2026 2 remaining items
🔒 认领:维护者直接派发(ADR-0120 17.x 波第二块 —— spec + lint 侧)
派发来源:维护者会话session_01Akrzh2mHi2siSNVPPtfTw7(接上条维护者裁定;issue 已 assign 给共享身份,不改 assignee)
分支:claude/issue-4986-unique-scope-vocab
Worktree:objectstack-issue-4986-spec-lint本轮交付物(范围 =
packages/spec+packages/lint,含测试与再生成),照 ADR-0120(Status: Accepted 2026-08-04)执行,不重开方向之争:- D1:
UniqueScopeSchema扩为boolean | 'global' | 'organization'(17.x 纯增量,零语义变化);§Terminology:'tenant'/'org'不接受、不做别名,zod 报错指名'organization'; - D1/D6:
IndexSchema.unique注释块与describe()按新契约重写(verbatim 收窄到'global';裸true17.x 警告 → 协议 18 拒绝,指向 ADR-0120 协议 18 项:D2 conversion(声明索引 unique: true → 'global')+ 裸 true 硬拒 + synonym pin 退役 —— 挂 18 列车,现在勿动工 #5082); - D6:
UniqueScopeSchema文档块「复合索引退化为单列」假豁免替换为 D3 真相(NULL 桶 + COALESCE); - D5a/D5b:新 lint 规则
unique/unscoped-declared-index(警告)+ R10unique/double-declaration四象限重写,测试齐全; - 再生成 + 门禁 + changeset + ADR-0120 锚点。
⛔ 范围外:driver-sql(由 #5030 的 PR 承载,本 PR 晚于它合并)、D5e 安装门与 indexing.mdx(#5081)、协议 18 项(#5082)、
content/docs/releases/零触碰。
Generated by Claude Code
- D1:
ADR-0120 已 Accepted 并合入 main(#5054 →
docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md,维护者裁定 2026-08-04)。此前 PM 撤回令中的 ⛔「拍板前不动 driver-sql 单侧语义」随 D8 解除,实现按 D7 分波:- driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 — driver 侧 D3+D4(
COALESCE(organization_id,'__global__')物化 + drift 两侧 + 重复预检),最先合; - driver-sql: 表级
{ fields, unique: true }在 tenant-scoped 对象上落成平台级 UNIQUE —— normalizeDeclaredIndex 不补租户列(#4698 实例 2 移交) #4986(本单) — spec 词汇'organization'+ lint(D5a 新规则 + R10 重写),必须晚于 driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 合(否则 spec 收词而 driver 未物化,declared≠enforced); - ADR-0120 17.x 收尾块:D5e isolated 安装期姿态硬门 + D6 文档/pin 测试扫荡 + 三姿态 conformance(阻塞于 #5030、#4986 的实现 PR) #5081 — D5e 安装门 + D6 文档扫荡 + 三姿态 conformance,阻塞于上两块;
- ADR-0120 协议 18 项:D2 conversion(声明索引 unique: true → 'global')+ 裸 true 硬拒 + synonym pin 退役 —— 挂 18 列车,现在勿动工 #5082 — 协议 18 项(D2 转换 + 裸
true硬拒),挂起勿动。
#5030 与本单已由维护者会话(
session_01Akrzh2mHi2siSNVPPtfTw7)派发 dev agent 开发中,均只开 draft PR;合并等 v17.0 cut 之后。各车道 sweep 请继续让行。
Generated by Claude Code
- driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 — driver 侧 D3+D4(
🔒 接续认领:ADR-0120 17.x 波第二块(spec + lint 侧)—— 前一开发会话在环境回收时中断,本会话接手续做
派发来源:维护者会话session_01Akrzh2mHi2siSNVPPtfTw7(同上条派发,任务与范围不变)
分支:claude/issue-4986-unique-scope-vocab(同分支续做,不另起)
Worktree:objectstack-issue-4986-spec-lint(前会话未提交改动完好,已全量审计后继续)范围与上条派发一致:D1 词汇(
'organization','tenant'/'org'拒绝并指名)、D1/D6IndexSchema.unique契约重写、D3 真相替换假豁免、D5a 新规则 + D5b R10 四象限、再生成 + changeset + ADR 锚点。⛔ 不动 driver-sql(#5030)、不动 D5e/indexing.mdx(#5081)、不动协议 18 项(#5082)、不碰content/docs/releases/。仅开 draft PR,合并等 v17.0 cut 且晚于 #5030。
Generated by Claude Code
✅ spec + lint 侧交付:draft PR #5208(分支
claude/issue-4986-unique-scope-vocab,续做完成)- 继承改动全量审计:前会话 14 文件改动逐条对照 ADR-0120 D1/D3/D5a/D5b/D6/§Terminology/Resolved ✨ Set up Copilot instructions #2 核对,全部正确保留;审计发现并修掉一处缺口 —— 导出的
lintUnscopedDeclaredIndexes未入 AUTHORING_RULES 注册表([cli/lint] 作者时规则的命令覆盖漂移是系统性的:23/26 条手工接线规则只跑部分命令,9 条 error 级有门禁盲区(os build 会发布 os lint 拒绝的流) #4409 wiring 测试红),已改为独立注册条目(validate/build)+lintDataModel直调(os lint),三命令全覆盖、每 finding 恰好一报。 - 测试:spec 7808 / lint 1181 / cli 626 全绿;typecheck(spec/lint/cli)绿;
check:generated8/8 绿;check:adr-anchors绿。 ⚠️ 合并顺序不变:等 v17.0 cut,且晚于 driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 的 driver PR(其 draft PR 尚未开出)。- Resolved ✨ Set up Copilot instructions #2 的示例/脚手架输出扫荡已备注到 ADR-0120 17.x 收尾块:D5e isolated 安装期姿态硬门 + D6 文档/pin 测试扫荡 + 三姿态 conformance(阻塞于 #5030、#4986 的实现 PR) #5081(D6 扫荡同一动作面)。
Generated by Claude Code
- 继承改动全量审计:前会话 14 文件改动逐条对照 ADR-0120 D1/D3/D5a/D5b/D6/§Terminology/Resolved ✨ Set up Copilot instructions #2 核对,全部正确保留;审计发现并修掉一处缺口 —— 导出的
- added a commit that references this issue
on Aug 4, 2026 - addedpriority:p0Critical: blocker, must ship before MVPCritical: blocker, must ship before MVP
on Aug 4, 2026 落地同步核验记录(engine/services 车道 PM,会话
session_01Pbu27iNUfQCHeuS551Rqo7):按维护者「现在就合」指示派出的 os-regen 四步同步 agent 与维护者会话的自行同步(84396f2b2)并发相遇,未强推,转为交叉验证:(1) 对方合并的生成物 delta 与本 agent 独立整体重生成的结果字节一致;(2) 在更新的 main(c183a12bd,含 #5237 spec 提交)上预演队列即将执行的合并 —— 重生成后工作树零差异,check:generated9/9 最新,双方兄弟条目全部在位(#5237 allowAddTab、#5022、#5212__global__、#5203、#5182 与本 PR 的isOrganizationUnique/UniqueScopeSchema三态/两条 lint 规则);spec 306 文件 7871 测试 + lint 1194 全绿。结论:队列合并的 os-regen 静默吞并风险已实证排除,#5208 在队列中的头提交可放心落地。附带:adr-anchors.json 冲突按集合并集解决(ADR-0120 五锚全保留,26 文件 OK)。
Generated by Claude Code
- added a commit that references this issue
on Aug 30, 2026 - added a commit that references this issue
on Sep 1, 2026
未认领。 从 #4698 移交(devx 车道核实、按锚定规则归 engine 车道):三例中唯一仍活着且不属 lint 层的一例。
现象(#4698 的 dev 在当前 main 上复核过,非陈述转抄)
对 tenant-scoped 对象声明表级唯一索引:
normalizeDeclaredIndex(packages/plugins/driver-sql/src/schema-drift.ts:813)原样保留声明的列,不补租户列 —— 产出的是平台级UNIQUE(name):任意两个租户不能有同名记录。而字段级unique: true走的uniqueIndexesFromFields会构造(tenantField, col)复合索引,语义正确。同一个意图、两种写法、两种语义。为什么不能在 lint 层修(#4698 已论证,勿再试)
判定需要的事实 —— "这个对象是否 tenant-scoped" —— 不在作者写的元数据里:
organization_id由 kernel 在注册期注入。packages/lint/src/data-model-rules.ts:110的lintUniqueDeclarations自己的注释就写明拒绝在 authoring 期猜租户性,猜一半会错一半。修复点只能在 driver-sql 的 normalize 一侧(注册期,租户注入已发生)。建议方向
normalizeDeclaredIndex在对象为 tenant-scoped 时把租户列并进声明的唯一索引(与uniqueIndexesFromFields同语义),或至少在产出平台级 UNIQUE 前响亮报错让作者显式选择。注意 schema-drift 检测两侧(declared vs actual)要同步改,否则会制造新的假漂移;#4884(drift 检测读不了 COALESCE 索引列)在同一文件作业过,先看它的结论。关联