Multi-Tenant V1 已知缺口与补齐路线

why-tenant.md 讲的是设计为什么对;这份讲的是设计承诺和当前机制之间的差距。V1 identity-only 的方向没问题,但"设计说了"和"机制兜住了"是两回事。每个缺口固定四段:现状(证据)→ 风险 → 怎么做对 → 退出条件。

实测口径

2026-07-01 用 Neon MCP 直查三个 project:control plane retaintive-identity、data plane callytics-prod / callytics-test。代码证据来自 callytics-common / callytics-infrastructure 当日 main branch。数据会变,结论不会自动更新——重跑对账见各节的 SQL 思路。

阶段定位(2026-07-01 团队定调)

当前所有业务都跑在 test 环境,还没有任何东西真正上 prodcallytics-prod 这个 Neon 库虽然存在(有 2026-02 起的历史同步数据),但没有对外服务的 live 负载。一切按 pre-launch 阶段处理,由此定下三条优先级:

  1. 代码逻辑必须对 — 缺口 2/3/4/5 的机制(fallback 退役路径、NULL 回填、持续对账、status 语义)是正式上线前要补齐的正事
  2. 现存脏数据可接受test / test b 脏 tenant、orphan mapping 都是测试期产物,不构成事故,不用为它们拉警报
  3. 清理数据看成本 — 简单(一两条 UPDATE 能搞定)就顺手清;麻烦就算了,不为清理专门开工程

下文每个缺口的"怎么做对"都按这个优先级写:机制是必须项,数据清理是 opportunistic。

0. 先看实测:当前铺开状态(2026-07-01)

事实实测数据
control plane 表内容tenants 3 行(1 个真实 tenant 仍 provisioning;2 个脏测试 tenant 已于 2026-07-01 标 churned)/ active tenant_members 1 行(脏 2 行已标 removed)/ active tenant_store_mappings 8 行(6 条 orphan 已标 removed)/ tenant_store_overrides 0 行
3 个 tenant 是谁1 个真实种子 tenant(orangeTheory,2026-06-11 seed)+ 2 个脏测试数据(test / test b,member user_id 是字面量字符串;已清理,见缺口 1)
tenant_id 列(业务表)test 已有(calls / messages / contacts / tasks / leads / contact_timeline / ai_* 共 10 张表)/ callytics-prod 库没有(migration 未 apply——该库尚无 live 负载,属 pre-launch 待办)
callytics-prod 库里 13 家店的归属0 家有 active mapping;3 个店主 user_id 都不在 tenant_members → mapping 和 derived fallback 双双落空,该库任何店都解析不出 tenant(同上,pre-launch 待办)
当天早间 14 条 active mapping 指向谁8 条指向 test 的店,6 条指向 test/prod 两边都不存在的 store_id(orphan,已于当天标 removed + reason);test 16 家店里 8 家没有 mapping(留给对账 job 自动补)
运行时读路径已生效且很热:tenant_store_mappings idx_scan 108k 次 / tenant_members 27k 次(pg_stat,解析链每事件都在跑)
业务表 tenant_id 填充率(test)calls 100%(近 7 天)/ messages 99.9% / contact_timeline 98% / contacts 94% / tasks 71%(lead 来源仅 29.3%,contact_analysis 来源 95.5%)/ leads 0%(近 7 天全 NULL;累计 9,308 行全 NULL,当日晚间复测仍在持续)

一句话:V1 control plane 目前只对 test 数据面生效——还没上 prod,这是 pre-launch 阶段的正常状态,不是事故;但 orphan 漂移证明"缺对账机制"是真缺口。 下面 6 个缺口按此展开。

缺口 1:上 prod 前的铺开 checklist 还不存在

现状:control plane 代码已 merge、test 已接线;callytics-prod 库侧三件事还没发生 — ① 业务表 tenant_id migration 未 apply;② 店没有 mapping;③ 店主没有 tenant membership。当前一切都在 test 跑、还没上 prod,所以这不是漏,是待办——缺的是把待办成文,没有文档说明上 prod 时按什么顺序铺、每步怎么验证。

风险:上线不是一天内的原子动作,没有 checklist 就容易漏步骤(比如 migration apply 了但 mapping 没 seed,tenant 解析全部落空还不报错——fail-open 会把这种漏吞掉);另外读文档的人会误以为 V1 已经全量生效。

怎么做对:

  1. 把 prod 铺开 checklist 固化成文档或 issue 已完成(2026-07-01):prod-rollout-checklist.md — 前置条件 + 6 步顺序,每步带验证 SQL
  2. (opportunistic)清掉 control plane 里的 2 个脏测试 tenant 已完成(2026-07-01):test / test bchurned,其 members 标 removed(验证过它们名下 0 条 mapping,不影响任何计数)
  3. why-tenant.md 的落地口径 callout 里同步铺开状态

退出条件(正式上 prod 时验收):prod 全部 active 店 100% 有 active mapping,店主 user_id 全部在 tenant_members,业务表新写入行带 tenant_id

缺口 2:"转让公司只改 tenant_members" 的前提还不成立

现状:callytics-common/src/control-plane/client/routing.ts 的 store→tenant 解析链是三级 fallback:active mapping → legacy tenant_store_overridesderived(从 rc_stores.user_id 找 owner 的 active tenant)。derived fallback 有开关,default 开。只要它活着,rc_stores.user_id 就仍然是 ownership 的输入。实测:tenant_store_overrides 已经是 0 行(第二级已死);prod 店因为缺 mapping 和 membership,连 derived 都解析不出。

风险:文档承诺"owner transfer 只改 tenant_members,公司和 stores 不变",但在 fallback 退役前,transfer 仍然要动 rc_stores.user_id,否则解析结果不一致。

怎么做对:

  1. 给 derived fallback 的命中加计数(metric / structured log)
  2. mapping 覆盖率拉到 100%(靠缺口 4 的对账机制)
  3. 命中数归零持续 N 天 → 开关翻 false → 删 fallback 代码 + drop tenant_store_overrides 表(已 0 行,随时可删)

退出条件:fallback 代码删除之日,"transfer 只改 tenant_members"才算成立。

缺口 3:fail-open 写入,空白 tenant_id 没有回填机制 — lead 管道已实测中招

现状:callytics-infrastructure/lambda/shared/utils/tenant-resolver.ts 头注释明确写了 rollout 期策略:control plane 配置缺失或解析失败时 fail-open — pipeline 照常写入,tenant_id 留 NULL,log warning。

实测案例(2026-07-01,test 环境):电话管道填充率健康(calls 近 7 天 100%),但 leads 表 0%(累计 9,308 行全 NULL),lead 来源的 tasks 也只有 29.3%。lead-tracking 的代码是对的——tenant stamping 2026-06-07 已 merge(lead-tracking #197/#200),INSERT 明确带 tenantId,CDK 也配了 SSM param;且这些 leads 大多属于可正常解析的店(同店的 calls 100% 解析成功)。

根因已定位(2026-07-01 当晚 AWS 实测):merge 了但从没部署。线上 ImapPollerFunction-us-east-1LastModified = 2026-05-19,早于 #197/#200 的 merge 日(2026-06-07)三周;其环境变量里根本没有 CONTROL_PLANE_DATABASE_URL_PARAM。lead-tracking 的 deploy.yml 只有 workflow_dispatch 手动触发,最后一次 run 就是 2026-05-19——之后 6 周的 merge(含 @retaintive/common 3.0.0→4.3.1 和 Orchestrator 对齐 #238)全部积压未上线。修复 = 重跑 deploy workflow(CDK default environment='prod',对应的 /studio/prod/control-plane-database-url 在 us-east-1 已存在,部署即接通);但注意这次部署会一次性带上 6 周积压改动,部署后要立即验证新 leads 带 tenant_idfail-open 把失败静默吞成 NULL,没有告警,三周无人发现——这正是本缺口要防的事故形态,在 test 提前上演了一遍。

风险:空白行静默累积;等到 tenant 层真正承担 billing count 或 dashboard boundary 时,历史数据全是洞。且如 lead-tracking 案例所示,没有 NULL 写入率告警,fail-open 的失败就是隐形的——代码 review、CDK review 都发现不了,只有数据对账能发现。

怎么做对:

  1. 先修眼前的活案例:重新部署 lead-tracking(根因已定位:部署积压,见上)——跑 deploy.yml workflow,部署后验证新 leads 带 tenant_id已建 issue 跟踪:lead-tracking#253(Meegle m-23860242;范围含消化积压 + 补 test 环境部署套 + 部署自动化/积压告警)
  2. 姿态决策写明(这半条是设计不是修复):写入路径长期保持 fail-open——电话数据不能因为 control plane 不可用就丢,这是 availability 优先的正确取舍
  3. NULL 写入率加 metric + 告警(lead-tracking 案例证明"log warning"不算告警,没人看;要按表/按写入方出 NULL 率指标)。现成模板:infra 已有 storeid-coverage-monitor(hourly 扫 6 张表 store_id IS NULL 率,breach 走 Discord/Lark/Sentry)——扩一个 tenant_id 维度即可,不用从零建
  4. 定期 sweeper(cron):扫业务表 WHERE tenant_id IS NULL → 重新走解析链 → 补上;幂等、可重跑(顺带能把 lead-tracking 事故期累计的 9,308 条历史 leads 补上)。底座已有但表清单过时(2026-07-03 codex review 发现):studio-api 的手动脚本 apps/api/scripts/backfill-tenant-ids.ts 已实现回填逻辑(默认 dry-run、只补 NULL 行、冲突拒绝 apply),但它的 TABLES 清单包含 2 张已从 schema 移除的表(task_playbooks / task_progress_events——跑到会报错)且漏了 ai_task_decision_assessments / coaching_reviews。定时化之前先修表清单,不用从零写
  5. 转 fail-closed 的触发点 = tenant_id 成为隔离键那天(第一个共享 DB 的 Pool 客户),在那之前不转

退出条件:sweeper 上线且 NULL 率有告警;"何时 fail-closed"的触发条件写进文档。

缺口 4:"每家店恰好归一家公司"没人持续对账 — 机制缺失已被实测证明

现状:tenant_store_mappings.store_id 是跨 Neon project 的软引用(control plane 和 data plane 是两个库),没有 FK。schema 注释说 "every active store should have exactly one active mapping row",但这个不变量只在 seed 时成立过一次。实测:14 条 active mapping 里 6 条指向两边都不存在的 store_id(orphan),test 16 家店里 8 家没 mapping——种下去 3 周就漂了。测试期漂移本身无伤大雅(阶段定位第 2 条),它的价值是提前证明了"没有机制,不变量必然漂"——上 prod 后同样的漂移就是计费多收(orphan 计入 active store count)和 dashboard 漏店。

怎么做对(机制是必须项,清理是顺手项):

  1. 写入路径闭环 已验证存在(2026-07-01 复核):studio-api 的 ensureTenantMappingsForStores(PR #516)在 RC sync(store-sync.ts)和手动建店(store-config-manual.ts:199,322)两条路径建店后都写 mapping(source='import',带单一 active tenant 校验 + 跨 tenant 冲突拒绝)。现存 orphan 是 #516 之前的 seed/import 遗留,不是持续漂移源
  2. 对账 job(cron,daily):data plane active 店 对 control plane active mapping 做双向 diff → unmapped store 数、orphan mapping 数 → metric + >0 告警(接现有 Lark notifier);可选 auto-heal(unmapped 走 derived owner 补,orphan 标 status='removed' + reason)。写入闭环已在,这是唯一还缺的机制
  3. (opportunistic)现存脏数据清理:6 条 orphan 标 removed 已完成(2026-07-01):6 条 orphan 已标 removed + reason;test 8 家未 mapping 的店等对账 job 上线后让它自动补,不用手工

退出条件:对账 job 上线,unmapped = 0 且 orphan = 0 持续稳定;不变量从"seed 时对一次账"变成"每天自动对账 + 漂移告警"。

缺口 5:tenant 层现在是记账不是防线,status 也没有语义

现状:V1 真正防止 A 客户看到 B 客户数据的,仍然是 store_id 隔离那套(WHERE store_id IN + 入口 guard);tenant 层只是归属记账,pipeline lambda 对写入只做 tenant 标注,不做 enforce;没有 RLS 第二层。另外真实 tenant 仍卡在 status='provisioning'(2 个脏 tenant 已标 churned)——没有任何流程把它翻成 active,也没有任何代码检查它:2026-07-01 复核 studio-api 守卫链(tenant-contextstore-guard middleware),只查 tenant_members.statustenant_store_mappings.status,从不 join tenants 表读它自己的 status;common 的 routing.ts 同样只读 member/mapping/override 的 status。suspended / churned 的 tenant,成员照常访问一切。

风险:两个误解都危险 — 读者以为加了 tenant 层"更安全了"(它 V1 不管安全);或以为 suspended 一个客户就能挡住访问(现在挡不住)。

怎么做对:

  1. 文档显式声明(本节即是):V1 tenant 层 = 记账边界,非安全防线;成为防线的触发点 = Pool/RLS,那时业务查询强制 tenant_id
  2. 定义 status 生命周期:谁把 provisioning 翻成 active(onboarding 完成的判据)、suspended / churned 时入口 guard 挡不挡。V1 最小做法 = studio-api 入口检查 status='active',不做也行,但必须明写"V1 只标记不拦截"
  3. 命名对照(防混淆,一次讲清):
层级含义
tenant owner / admintenant 层(tenant_members.role)客户层角色,默认可见 tenant 下所有
tenant viewertenant 层(tenant_members.role)客户层角色,默认不带任何业务数据可见范围,靠店级授权缩放
store EDITOR / VIEWERstore 层(store_members.role)某一家店的编辑/只读授权,不存 OWNER
store ownerstore 层(rc_stores.user_id)隐式店主(legacy,缺口 2 退役后由 tenant 层接管语义)

退出条件:status 生命周期有 owner(人或流程);隔离声明进入 why-tenant.md 系列文档(本文即完成)。

缺口 6:onboarding 全程不建 tenant,「有店无 tenant」状态不可见

现状(2026-07-01 studio-website-monorepo 代码实测):新客户 signup(只有 Google OAuth,无公司名表单)→ 连 RingCentral → 建店,没有任何一步创建 tenant。建店时的 ensureTenantMappingsForStores 在 user 无 active tenant 时静默 skip(control-plane.ts:157-168),调用方不检查返回值;tenant-context middleware fail-open 放行。全 repo 仅有的 tenant 创建路径是手动 seed 脚本和 control-plane-admin 内部后台(只能建 tenant/member,不能建 mapping)。结果:客户能正常用产品,但 control plane 里「三无」,且无人知晓。

风险:每个新客户默认掉进「三无」状态;tenant 层的覆盖率靠人肉记得去补,上 prod 后就是 billing / dashboard boundary 的洞。

怎么做对:V1 用 admin-led 建档 + 对账 auto-heal 兜底(运营在 admin 后台建档,顺序无关——店先建则对账 job 在 derived owner 恰好有 1 个 active tenant 时自动补 mapping;无 tenant / 多 tenant / 冲突只进日报),self-serve 自动建 tenant 推迟到获客产品化。完整设计、待桥接 3 个店主的动作、3 个待拍板决策见 tenant-onboarding.md

退出条件:对账 job 日报包含「有店但 owner 无 tenant」清单;新客户建档动作进运营 SOP;存量 3 个待桥接店主处理完成。

缺口总览

按阶段定位排优先级:机制类(代码逻辑要对)是正事,数据清理 opportunistic,铺开类上 prod 前执行

另外,why-tenant.md §十一 承诺的"未来只加表、不重做"演进保证,前提是缺口 2-4 的机制先补上——fallback 不退役、mapping 不对账,到 Final 阶段就要同时处理"改名 + 退役 + 补账"三件事。

#缺口一句话类型 / 处理方式(2026-07-01 状态)
1铺开 checklist 不存在control plane 只对 test 生效;上 prod 的步骤没有成文checklist 已成文(prod-rollout-checklist.md)+ 脏 tenant 已清;上 prod 时执行
2fallback 未退役"转让只改 tenant_members" 前提不成立机制类 — 计数 → 覆盖 100% → 删 fallback(计数缺失已复核确认:routing.ts 无任何埋点)
3NULL 无回填fail-open 写入,空白 tenant_id 静默累积;lead 管道已实测中招(leads 累计 9,308 行全 NULL)机制类 — 根因已定位:lead-tracking 部署积压 6 周(merge≠deploy),重新部署即修;再上告警(扩 storeid-coverage-monitor)+ sweeper
4无持续对账测试期 3 周即出现 6 条 orphan,证明缺 reconciliation机制类 — 写入闭环已存在(#516,两条建店路径都写 mapping);orphan 已清;唯一还缺对账 job
5记账≠防线隔离仍靠 store_id;tenant status 无语义声明类 — 本文已声明(studio-api 守卫链不读 tenants.status 已复核);status 生命周期上线前定
6onboarding 不建 tenant新客户全程不碰 tenant,「有店无 tenant」不可见设计类 — admin-led 建档 + 对账 auto-heal 兜底,见 tenant-onboarding.md;3 个拍板点待定

缺口的落地实施属于 callytics-common / callytics-infrastructure / studio-api,不在本 docs repo。建 issue 时按 meegle-sync 规则同步。