> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# Multi-Tenant 上 Prod 铺开 Checklist

[`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md) 缺口 1 指出"上 prod 的步骤没有成文"——这份就是那个成文。当前(2026-07-01)所有业务都在 test 跑,`callytics-prod` 库无 live 负载;正式上 prod 那天按下面顺序逐步执行,**每步验证通过才进下一步**。上线不是一天内的原子动作,fail-open 会把漏掉的步骤静默吞成 NULL,所以顺序和验证都不能省。

## 前置条件(不满足先补,都在 v1-gaps 里追踪)

| 前置                       | 为什么                                                         | 验证方式                                |
| ------------------------ | ----------------------------------------------------------- | ----------------------------------- |
| 缺口 3 运行时修复已验证            | lead-tracking 部署积压导致 test 环境 leads 100% NULL;prod 上重演就是正式事故 | test 环境新写入 leads `tenant_id` 非 NULL |
| `tenant_id` NULL 率告警已上线  | fail-open 的失败没有告警就是隐形的(test 已实证:三周无人发现)                     | 按表/按写入方的 NULL 率 metric 存在且有告警阈值     |
| sweeper 回填 job 已上线       | 铺开期间任何解析失败留下的 NULL 行要有自动回填,不靠手工                             | cron 存在,幂等可重跑                       |
| mapping 对账 job 已上线(缺口 4) | Step 3 seed 完之后靠它保持不变量,不是 seed 一次就完                         | daily job 存在,unmapped/orphan 有告警    |

## Step 1 — Apply 业务表 `tenant_id` migration 到 `callytics-prod`

走 CI migration 流程(callytics-common 的 migration workflow),**不手动跑 SQL**。

验证(在 `callytics-prod` 上跑,期望 = 10,与 test 一致):

```sql
SELECT count(*) FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'tenant_id';
-- 2026-07-01 实测: test = 10(ai_feedback, ai_task_decision_assessments, calls,
-- coaching_reviews, contact_timeline, contacts, leads, messages, task_suggestions, tasks)
-- prod = 0(未 apply)
```

## Step 2 — 为真实客户建 tenant + tenant\_members

- member 的 `user_id` 必须是真实 Cognito user\_id——`test` / `test b` 两个脏 tenant 的教训就是用了字面量字符串,永远解析不出任何东西
- 每个 prod 店主的 user\_id 都要进 `tenant_members`(2026-07-01 实测:prod 3 个店主 user\_id 都不在,mapping 和 derived fallback 双双落空)

验证(control plane `retaintive-identity`):

```sql
SELECT t.id, t.name, t.status, count(m.user_id) AS members
FROM tenants t LEFT JOIN tenant_members m
  ON m.tenant_id = t.id AND m.status = 'active'
GROUP BY t.id, t.name, t.status;
```

## Step 3 — Seed prod 店的 mapping

每家 active 店恰好一条 active mapping。注意:studio-api 的 `ensureTenantMappingsForStores`(#516)只在**建新店时**写 mapping,存量店必须显式 seed(`source='backfill'`)。

验证(双向 diff,跨两个 Neon project 手动两步,或直接用缺口 4 的对账 job):

```sql
-- data plane(callytics-prod): 拿全部店（按 ID 排序方便对比）
SELECT id FROM rc_stores ORDER BY id;
-- control plane: 拿全部 active mapping（按 store_id 排序方便对比）
SELECT store_id FROM tenant_store_mappings WHERE status = 'active' ORDER BY store_id;
-- 两个集合做差:unmapped = 0 且 orphan = 0 才通过
```

## Step 4 — 确认写入方带 tenant 标注

- 每个写入 lambda 的 env 有 `CONTROL_PLANE_DATABASE_URL_PARAM`,且**部署 region 的 SSM 里真有那个参数**——SSM parameter 是 region-scoped 的,2026-07-01 实测 us-east-1 只有 `/studio/prod/control-plane-database-url`,没有 test 版;us-west-2 只有 test 版
- lead-tracking 部署版本必须含 #197/#200(检查 Lambda `LastModified` 晚于修复部署日,deploy.yml 是 `workflow_dispatch` 手动触发,merge 不等于 deploy)

验证(上线后按表查新写入 NULL 率):

```sql
SELECT 'leads' AS t, count(*) FILTER (WHERE tenant_id IS NULL) AS nulls, count(*) AS total
FROM leads WHERE received_at > now() - interval '1 day'
UNION ALL
SELECT 'calls', count(*) FILTER (WHERE tenant_id IS NULL), count(*)
FROM calls WHERE synced_at > now() - interval '1 day';
```

## Step 5 — 跑一次完整对账

缺口 4 的对账 job(或 Step 3 的手动 SQL)再跑一遍:unmapped = 0 且 orphan = 0。这一步是为了抓 Step 1–4 之间新 sync 出来的店。

## Step 6 — tenant status 翻 active

把真实 tenant 从 `provisioning` 翻成 `active`。当前没有任何代码读 `tenants.status`(v1-gaps 缺口 5),翻它不改变行为——但上线前必须把 status 生命周期的 owner 和语义定下来(谁翻、`suspended` 挡不挡访问),不然"suspend 一个客户就能挡住访问"的误解会变成真事故。

## 退出条件(= v1-gaps 缺口 1 的验收)

- prod 全部 active 店 100% 有 active mapping
- 店主 user\_id 全部在 `tenant_members`
- 业务表新写入行带 `tenant_id`(NULL 率告警安静)
