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 思路。
当前所有业务都跑在 test 环境,还没有任何东西真正上 prod — callytics-prod 这个 Neon 库虽然存在(有 2026-02 起的历史同步数据),但没有对外服务的 live 负载。一切按 pre-launch 阶段处理,由此定下三条优先级:
- 代码逻辑必须对 — 缺口 2/3/4/5 的机制(fallback 退役路径、NULL 回填、持续对账、status 语义)是正式上线前要补齐的正事
- 现存脏数据可接受 —
test/test b脏 tenant、orphan mapping 都是测试期产物,不构成事故,不用为它们拉警报 - 清理数据看成本 — 简单(一两条 UPDATE 能搞定)就顺手清;麻烦就算了,不为清理专门开工程
下文每个缺口的"怎么做对"都按这个优先级写:机制是必须项,数据清理是 opportunistic。
0. 先看实测:当前铺开状态(2026-07-01)
一句话: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 已经全量生效。
怎么做对:
把 prod 铺开 checklist 固化成文档或 issue已完成(2026-07-01):prod-rollout-checklist.md— 前置条件 + 6 步顺序,每步带验证 SQL(opportunistic)清掉 control plane 里的 2 个脏测试 tenant已完成(2026-07-01):test/test b标churned,其 members 标removed(验证过它们名下 0 条 mapping,不影响任何计数)- 在
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_overrides → derived(从 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,否则解析结果不一致。
怎么做对:
- 给 derived fallback 的命中加计数(metric / structured log)
- mapping 覆盖率拉到 100%(靠缺口 4 的对账机制)
- 命中数归零持续 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-1 的 LastModified = 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_id。fail-open 把失败静默吞成 NULL,没有告警,三周无人发现——这正是本缺口要防的事故形态,在 test 提前上演了一遍。
风险:空白行静默累积;等到 tenant 层真正承担 billing count 或 dashboard boundary 时,历史数据全是洞。且如 lead-tracking 案例所示,没有 NULL 写入率告警,fail-open 的失败就是隐形的——代码 review、CDK review 都发现不了,只有数据对账能发现。
怎么做对:
- 先修眼前的活案例:重新部署 lead-tracking(根因已定位:部署积压,见上)——跑
deploy.ymlworkflow,部署后验证新 leads 带tenant_id。已建 issue 跟踪:lead-tracking#253(Meegle m-23860242;范围含消化积压 + 补 test 环境部署套 + 部署自动化/积压告警) - 姿态决策写明(这半条是设计不是修复):写入路径长期保持 fail-open——电话数据不能因为 control plane 不可用就丢,这是 availability 优先的正确取舍
- NULL 写入率加 metric + 告警(lead-tracking 案例证明"log warning"不算告警,没人看;要按表/按写入方出 NULL 率指标)。现成模板:infra 已有
storeid-coverage-monitor(hourly 扫 6 张表store_id IS NULL率,breach 走 Discord/Lark/Sentry)——扩一个 tenant_id 维度即可,不用从零建 - 定期 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。定时化之前先修表清单,不用从零写 - 转 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 漏店。
怎么做对(机制是必须项,清理是顺手项):
写入路径闭环已验证存在(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 遗留,不是持续漂移源- 对账 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)。写入闭环已在,这是唯一还缺的机制 (opportunistic)现存脏数据清理:6 条 orphan 标已完成(2026-07-01):6 条 orphan 已标removedremoved+ 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-context → store-guard middleware),只查 tenant_members.status 和 tenant_store_mappings.status,从不 join tenants 表读它自己的 status;common 的 routing.ts 同样只读 member/mapping/override 的 status。suspended / churned 的 tenant,成员照常访问一切。
风险:两个误解都危险 — 读者以为加了 tenant 层"更安全了"(它 V1 不管安全);或以为 suspended 一个客户就能挡住访问(现在挡不住)。
怎么做对:
- 文档显式声明(本节即是):V1 tenant 层 = 记账边界,非安全防线;成为防线的触发点 = Pool/RLS,那时业务查询强制
tenant_id - 定义 status 生命周期:谁把
provisioning翻成active(onboarding 完成的判据)、suspended/churned时入口 guard 挡不挡。V1 最小做法 = studio-api 入口检查status='active',不做也行,但必须明写"V1 只标记不拦截" - 命名对照(防混淆,一次讲清):
退出条件: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 阶段就要同时处理"改名 + 退役 + 补账"三件事。
缺口的落地实施属于
callytics-common/callytics-infrastructure/studio-api,不在本 docs repo。建 issue 时按meegle-sync规则同步。