Skip to content

driver-sql: 表级 { fields, unique: true } 在 tenant-scoped 对象上落成平台级 UNIQUE —— normalizeDeclaredIndex 不补租户列(#4698 实例 2 移交) #4986

Description

@xuyushun441-sys

未认领。 从 #4698 移交(devx 车道核实、按锚定规则归 engine 车道):三例中唯一仍活着且不属 lint 层的一例。

现象(#4698 的 dev 在当前 main 上复核过,非陈述转抄)

对 tenant-scoped 对象声明表级唯一索引:

indexes: [{ fields: ['name'], unique: true }]

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 索引列)在同一文件作业过,先看它的结论。

关联

Activity

  1. claude commented on Aug 4, 2026

    @claude
    Contributor

    🔒 认领: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

  2. xuyushun441-sys commented on Aug 4, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    停工上报:未写任何代码。裁定的事实前提被证伪,且硬约束下无法自洽落地

    会话 session_01NrmBxj8rK2uGCnh9aipjwX,分支 claude/issue-4986-declared-unique-tenant-column(已建 worktree,零提交,已清理)。

    我不是在重开「补租户列 vs 响亮报错」这个岔路 —— 我是在报告:这个岔路的两个分支共用的前提(「两种写法的语义分歧是历史遗留、既有规范已定答案 = 补进去」)与仓库现状相反。声明索引取列 verbatim 不是疏漏,是五处成文、带论证、且有回归测试钉死的既定契约。

    一、契约被写死在五个面上

    1. packages/spec/src/data/object.zod.ts 的 IndexSchema —— 注释原文:「A DECLARED index is materialized over exactly the columns listed in fields — no tenant column is injected, unlike field-level unique (多租户缺陷: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。
    2. 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 */},是被校验的。
    3. content/docs/references/data/object.mdx:71 —— 生成的参考表,同一句话。
    4. 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.」
    5. packages/lint/src/data-model-rules.ts 的 R10 unique/double-declaration —— 整条规则的判据、message 和 fix 文案都建立在「声明索引 = 平台级」之上,其 fix 原文就是:「for per-tenant uniqueness … spell the index out as fields: ['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 侧再补一条:租户化对象上未表态即报错。

    方案 C —— 只加诊断,零语义变更(#4698 原本要的那条)

    保留 verbatim 契约不动;在能看见租户性的注册期(driver-sql)对「租户化对象 + 唯一声明索引 + 列表不含租户列」发一条响亮诊断,并把 R10 从「两种写法同时出现才响」放宽到只写表级也响。

    我的建议

    选 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

  3. claude commented on Aug 4, 2026

    @claude
    Contributor

    PM 裁定撤回 + 公开更正,转 needs-user-decision

    dev 的停工上报成立,本 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

  4. claude commented on Aug 4, 2026

    @claude
    Contributor

    维护者裁定(2026-08-04):本单由维护者单独安排,不入任何 PM 车道的派发视野;needs-user-decision 标签保留作占位,各车道 sweep 请让行。方案 B 的完整分析与 #5030 关联见上两条评论。


    Generated by Claude Code

  5. self-assigned this
    on Aug 4, 2026
  6. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    🔒 认领:维护者直接指派(接上条维护者裁定,非 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

  7. 2 remaining items

  8. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    🔒 认领:维护者直接派发(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)执行,不重开方向之争:

    ⛔ 范围外:driver-sql(由 #5030 的 PR 承载,本 PR 晚于它合并)、D5e 安装门与 indexing.mdx(#5081)、协议 18 项(#5082)、content/docs/releases/ 零触碰。


    Generated by Claude Code

  9. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    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 分波:

    #5030 与本单已由维护者会话(session_01Akrzh2mHi2siSNVPPtfTw7)派发 dev agent 开发中,均只开 draft PR;合并等 v17.0 cut 之后。各车道 sweep 请继续让行。


    Generated by Claude Code

  10. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    🔒 接续认领: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/D6 IndexSchema.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

  11. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    ✅ spec + lint 侧交付:draft PR #5208(分支 claude/issue-4986-unique-scope-vocab,续做完成)


    Generated by Claude Code

  12. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    落地同步核验记录(engine/services 车道 PM,会话 session_01Pbu27iNUfQCHeuS551Rqo7):按维护者「现在就合」指示派出的 os-regen 四步同步 agent 与维护者会话的自行同步(84396f2b2)并发相遇,未强推,转为交叉验证:(1) 对方合并的生成物 delta 与本 agent 独立整体重生成的结果字节一致;(2) 在更新的 main(c183a12bd,含 #5237 spec 提交)上预演队列即将执行的合并 —— 重生成后工作树零差异,check:generated 9/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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingpm:queuepriority:p0Critical: blocker, must ship before MVP

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions