Skip to content

crm_account.name uniqueness is platform-wide, not per-tenant — two orgs cannot both have an "Acme Corp" #625

Closed

Description

@os-zhuang

Found while investigating #598 (account dedupe in lead_conversion). Out of scope there; filing separately.

What

src/objects/account.object.ts spells account-name uniqueness as a declared index:

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

normalizeDeclaredIndex() in @objectstack/driver-sql keeps a declared index's columns verbatim — it does not tenant-scope them. Only field-level unique: true gets the (organization_id, ...) composite treatment (uniqueIndexesFromFields(), framework#3696).

So the physical index this produces is:

uniq_crm_account_name  UNIQUE (name)          <-- platform-wide

whereas crm_contact.email, which declares the same intent as a field-level unique: true, produces:

uniq_crm_contact_organization_id_email  UNIQUE (organization_id, email)   <-- per-tenant

Reproduced against the shipped driver (17.0.0-rc.1):

import { expectedIndexes } from '@objectstack/driver-sql';
expectedIndexes({
  table: 'crm_account',
  fields: { name: { type: 'text' } },
  tenantField: 'organization_id',
  declaredIndexes: [{ fields: ['name'], unique: true }],
  physicalColumns: new Set(['id', 'organization_id', 'name']),
});
// => [{ name: 'uniq_crm_account_name', columns: ['name'], unique: true }]

Why it matters

The second organization to create an account called "Acme Corp" is rejected by the database. Account name is also the seed data's external-id / upsert key, so this bites the very first multi-tenant install.

This is exactly the failure mode src/objects/lead.object.ts already documents and deliberately avoids:

Declaring the single-column index too made the platform-wide constraint win and left the per-tenant one unreachable (framework#3991) — two organizations must be able to work the same lead address independently.

crm_lead and crm_product learned that lesson; crm_account still carries the platform-wide spelling.

Suggested fix

Move the uniqueness declaration onto the field, matching crm_contact.email / crm_lead.email / crm_product.sku:

name: Field.text({ label: 'Account Name', required: true, unique: true, ... }),
// and drop `{ fields: ['name'], unique: true }` from indexes[]

Note this is a schema change on existing data, and it is not free:

  • The new uniq_crm_account_organization_id_name index is a create_index drift entry (category: 'safe').
  • The old uniq_crm_account_name becomes an orphan → drop_index, category: 'destructive', which the boot-time reconciler skips and only os migrate apply --allow-destructive clears (docs/MAINTENANCE.md §3.1).

Leaving the old index in place is not harmless here: it is strictly tighter than the new one, so it keeps enforcing the platform-wide rule and the fix silently does nothing. Whoever picks this up should decide the migration story before writing code — same class of question as #598 scope 1.

Acceptance

  • Two organizations can each create an account named "Acme Corp".
  • Within one organization, a duplicate account name is still rejected.
  • The upgrade path for existing deployments is written down (which index is created, which is dropped, and by what command).

Activity

  1. added
    bugSomething isn't working
    metadataDeclarative metadata — schema, security posture, UI surfaces
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    pm:queueReady for the PM dispatch loop
    on Aug 2, 2026
  2. self-assigned this
    on Aug 2, 2026
  3. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 8 轮
    会话:session_019SS7C5SXpniKeCApxgARyf
    分支:claude/issue-625-account-name-tenant-scope
    Worktree:hotcrm-issue-625

    issue 里"应该先定迁移方案"那一问已经有答案了,不要再问一遍。 维护者在 #598 的裁决中确认:HotCRM 目前只有全新安装,没有"库已建好且不能重建"的部署。在空库上按新写法建表,uniq_crm_account_name 这个平台级索引压根不会存在,所以孤儿索引与 --allow-destructive 的问题在今天不成立。

    但 issue 指出的那一点必须写进 changeset:遗留的旧索引不是无害的——它严格紧于新索引,只要还在就继续执行平台级唯一约束,使这个修复静默失效。所以若将来出现既存部署,升级必须执行一次 os migrate apply --allow-destructive 清掉旧索引,否则修了等于没修。这句话请写清楚。

    修法按 issue 的建议:唯一性声明从表级 indexes[] 挪到字段级 unique: true,与 crm_contact.email / crm_lead.email / crm_product.sku 对齐。注意 crm_lead.object.ts 里已有的那段注释——它记录了"同时声明单列索引会让平台级约束胜出、把按租户的那条挤成不可达"(framework#3991),所以旧的表级声明必须删掉,不能两个都留。

    验收第 1、2 条请实测:两个组织各建一个 "Acme Corp" 应当都成功,同一组织内重名仍被拒。#626(客户名归一化)Blocked-by 本件,本轮不做,但请在 PR 里说明本次改动是否让 #626 的路更好走或更难走。

    同批:#631(四个语言包)、#643(src/flows/),与你的 account.object.ts 互斥。


    Generated by Claude Code

  4. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 复核:通过,已入队合并 — #646

    核验依据(以 GitHub 为准):9 项检查全绿;6 个文件全在范围内(account.object.ts、三份文档各改一句、新测试、changeset)。

    验证层次正确,这一点是本件的关键。 这个缺陷本身就是"元数据声明的"与"驱动生成的 DDL"之间的落差,所以只断言元数据形状是结构上抓不住它的。测试跑到了真 SQLite:SqliteWasmDriver(':memory:') + driver.initObjects(...)(boot 时的同一个调用)+ 真实写入,量出的 DDL 是 UNIQUE (organization_id, name),两个组织各建一个 "Acme Corp" 都成功,同组织内重名被拒。反向验证 stash 掉对象文件后 9 条红 7 条,且失败签名正是缺陷本身(UNIQUE constraint failed: crm_account.name,发生在 org_b 那次插入上)。

    旧的表级声明删掉了而不是两个都留,这是必须的:同时声明会让平台级索引胜出、把按租户的复合索引挤成不可达(framework#3991),那样修复会"看起来生效、实际什么也没做"。理由已写进注释,与 crm_lead / crm_product 的既有记录一致。

    顺带那条观察比修复本身更值得警惕:pnpm lint 对旧写法不报任何问题——平台的 unique/double-declaration lint 只在"两种形式都声明"时才触发,而 crm_account 只有表级那一个,于是 os validate、os lint、typecheck、测试,整条工具链对"两个组织不能有同名客户"完全沉默。这和 #642(decision 节点带着引擎不读的 config.condition,validate 与 lint 都放行)是同一类:工具链只检查它认识的错误形状。我会把这条合并上报到平台。

    对 #626 影响的分析也按我的要求给了,而且结论是更好走:(organization_id, name_normalized) 的冲突面比平台级小,只有同组织内重名才挡建索引,所以那次 merge pass 的工作量反而更少;文档那句"不区分大小写"被改成实话之后,#626 落地时是加强承诺而不是先去修一个旧谎言。

    派生的 #648(联系人文档说邮箱"在同一客户内唯一"并举了董事同时在两家公司的例子,而实际是按组织全局唯一、且 contact_integrity 在入库前就拒绝跨客户重复——文档描述的用例正是产品拒绝的行为)已标 P2。那份文档描述的"一个人能否同时属于两家公司"是产品问题,不是文档笔误,修文档时需要先确认产品意图,我在排期时会带上这一点。


    Generated by Claude Code

  5. added
    priority:p1High: required for production / M2
    and removed on Sep 9, 2026
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 workingmetadataDeclarative metadata — schema, security posture, UI surfacespm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch looppriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions