# Helix Platform — Architecture Reference

> **DAX catalog writes are manual-only (2026-09-09).** The daily `Seed DAX Dataset Catalog` GitHub schedule is removed. Repository catalog projection must not automatically overwrite production admission settings. The remaining manual importer defaults to dry-run and retains authoritative semantics only for explicitly committed imports. See [catalog operations](../../deploy/data-access/datasets/README.md#github-actions).


## 语言判定的两个入口,和一个不该存在的第三条路 (2026-09-09,合并后复审)

`reply_language` 现在有两个入口,区别只在**有没有一句话可判**:

| 入口 | 判定 | 用在哪 |
|---|---|---|
| `for_turn(text=…)` | 这句话 → 会话记忆 → 偏好 | 飞书消息、网页提问、点开场白 |
| `for_card()` | 会话记忆 → 偏好 | 卡片回调、机器人菜单、网页线程恢复、网页「新话题」 |

**开关词不参与判定。** `/help` / `/reset` / `reset` / `new topic` 在任何语言的员工
手里都长成那样,而 `/help` 恰好是四个拉丁字母 —— 足以被判成英文,于是一个中文
同事打 `/help` 收到英文帮助卡。`_is_channel_command` 整批排除它们(CJK 的那几个
也一起,退回的是已知语言,不会更差)。

**改语言这件事没有 MCP 入口,而且是故意的。** HTTP 孪生 `PATCH /auth/me/profile`
明确拒绝 PAT(ADR:被偷走的 PAT 不该能把人的语言换成他读不懂的那种),而 MCP 的
鉴权只有 PAT / MCP-OAuth。`set_my_language` 曾在 2026-09-09 加进来又当天删掉,
理由与「非 jwt 一律拒绝」为什么也不行,记在 `mcp/tools.py` 的 hint 注释与
`test_the_catalogue_cannot_grow_unnoticed` 的 docstring 里 —— 那是下一个人会看到
的两个地方。反向断言在
`tests/test_assistant/test_language_self_service_2026_09_09.py`。


## 开场白只有一份,三个面各自铺开 (2026-09-09)

`app/assistant/starters.py` 是八类 × 四句开场白的唯一出处,现在有三个消费者:

| 面 | 形状 | 为什么不一样 |
|---|---|---|
| 飞书帮助卡 | 两级(选类 → 选句) | 聊天里没有横向空间,八类 × 四句一屏铺不下 |
| 网页 `/me/support` | 一行分类 + 下面铺开当前类 | 一屏能同时给出「有哪些类」和「这一类能问什么」 |
| 移动端 `/m/support/ai` | 同上,按钮整行 | 拇指够得着 |

网页与移动端走 `GET /api/v1/support/copilot/starters?lang=`(`+1 端点`)。**语言按页面
当前语言给**,不是按 `preferred_language` —— 一个今天在读英文界面的人不该在这一块
看到中文;认不出来的标签降级到偏好而不是 422(它不是员工打进去的东西)。

**前端不带本地兜底文案。** 取不到就不画这一块,输入框照常能用。带一份兜底等于让
两份文案在下一次编辑时分叉,而这些句子是「助手真能回答它」的承诺 —— 分叉的那一份
会承诺一件已经不成立的事。


## `preferred_language` 现在有三个写入面,写法收在一处 (2026-09-09)

这一列决定的是**这个人收到的每一封通知、每一张卡**用哪种语言(ADR 2026-08-09),
而它此前只有一个写入面(`PATCH /auth/me/profile`,个人设置页)。这一版加了两个:
飞书帮助卡底部的语言按钮,和 MCP 工具 `set_my_language`。

三处各写一遍「校验 → 赋值 → 落审计」迟早会漏,所以写法收在
[`app/identity/preferences.py`](../../server/app/identity/preferences.py):
`set_preferred_language(db, person=…, lang=…, source=…)` 负责短码↔存储拼法的映射
(`zh`↔`zh_CN`)、值没变时不写审计、以及审计行本身
(`identity.person.language_set`,`detail.source` 记明从哪个面改的 —— 排查
「我的通知怎么突然变成英文了」时第一个要问的就是这个)。各面自己负责「谁能写」:
个人设置页拒 PAT,卡片认飞书回调里的操作者,MCP 走 `tool_runtime` 的调用者。

**卡上的打勾用 `preferences.current_lang`,不是 `resolve_lang`** —— 后者会按国家
推断,而推断出来的那个不是他选的;把它画成选中态,「我明明没选过」就成了一个没人
能回答的问题。这也是这两个函数唯一一次故意分开用。

**点完要 `session.forget_lang`**:会话里那格语言记忆记的是「他刚才用什么语言说
话」(见上一节),而他刚刚明说了要用哪种。不清掉,下一句「ok」会把他拉回旧语言。


## 回复语言:对话跟着「这一句」走,通知仍跟着「这个人」走 (2026-09-09)

两个问题长得很像,答案不一样,而平台此前只有一个答案:

| 问题 | 谁回答 | 用在哪 |
|---|---|---|
| 这个**人**读哪种语言? | `app.core.locale.resolve_lang`(ADR 2026-08-09) | 通知、邮件、卡片模板、入离职文案 |
| 这**一句**是用什么语言问的? | `app.core.locale.detect_text_lang` + `app.assistant.reply_language.for_turn`(2026-09-09) | 只有对话回合 |

`for_turn` 的顺序是 **这句话 → 这个会话上一次用的语言 → 这个人的偏好**。中间那一层存在
Redis(`assistant:lang:<person>:<chat>`,TTL 与线程同长),它是这套东西能成立的原因:判不
出来的句子占相当一部分(「ok」「VPN」、裸资产号、工单号),而它们几乎总是上一句的延续。

`detect_text_lang` 只看字符脚本,三条规则各自对应一个具体的错法:

1. **假名出现即日语** —— 日语句子必然带假名,假名不出现在中文里。
2. **汉字与拉丁字母比分量,不比有无**(`_HAN_WEIGHT = 2`)—— 只看「有没有汉字」会让
   「How do I connect to 飞书?」判成中文。
3. **少于 4 个拉丁字母不算数** —— `OK` / `VPN` / `hi` 返回 `None`,交给上面第二层。

**两个面同一个判定**:飞书 `handle_message_event` 与网页 `/copilot/chat` 都调 `for_turn`,
否则同一个人在两个面上问同一句话会拿到两种语言的答案(#3413 刚把两个面并成同一个助手)。
**菜单事件不参与** —— 它没有文本,按 `resolve_lang` 走。

**身份没解析出来的人也按写来的语言答**:`person is None` 时用 `detect_text_lang` 的结果,
判不出来才是 `DEFAULT_LANG`。以前那一支恒为中文,而收到它的恰恰是平台还不认识的那个人。


## 助手的帮助从一堵字墙变成一张可以点的卡 (2026-09-09)

机器人菜单的「使用帮助」和 `/help` 原来回的是 `msg_type="text"` 的一整屏文字。
纯文本渲染不了 markdown,层级只能靠【】和「•」,而且读完之后员工仍然要自己想
「那我现在打什么」。现在它是一张**两级卡片 2.0**。

**三个文件,各管一件事:**

| 文件 | 管什么 |
|---|---|
| [`app/assistant/starters.py`](../../server/app/assistant/starters.py) | 八类 × 每类四句开场白的**唯一注册表**,zh/en/ja 三语,key 稳定 |
| [`app/assistant/help_card.py`](../../server/app/assistant/help_card.py) | 一级卡(能力三行 + 分类阵 + 转人工)、二级卡(问题按钮 + 返回),用 `notifications/card_design` 的令牌 |
| `card_actions._help_action` | 四颗按钮的分派:`home` / `category` 纯换卡,`ask` / `human` 交给后台 |

**点一句 = 打一句。** `ask` 走 `feishu_events.ask_starter`,它和打字进来的一轮
**共用**限速窗口、每聊天的回合锁、`deliver_turn`(这一版从 `handle_message_event`
里抽出来的共享投递路径)和审计行。审计的 `action` 故意仍是
`assistant.message.received`,只多一个 `source: help_card` 和 `starter: <类>.<条>`
—— 管理台那几条曲线不该因为员工用了按钮而漏计,而 `starter` 顺带回答了「八类里
哪几类真的有人点」。

**按钮回传 key,不回传句子。** 载荷是 `{"c": <类>, "s": <条>}`,句子由服务端按
当事人的语言从注册表取。于是载荷小、老卡片改文案即生效,而**伪造一个 payload 也
只能选到表里已有的那 32 句** —— 注入不进任意文本,进不了模型的输入。

**分类口径不是新造的**:前六类沿用员工已经熟悉的飞书服务台分类(IT Equipment /
Account & Access / Network & VPN / Onboarding / Using Lark+Google Workspace+
Meetings / Spam & Security),后两类(申请与进度、云与数据权限)是 Helix 才有的
能力 —— 服务台答不了「**我的**申请到哪一步了」。

**空对话里的「转人工」不再开空工单。** 这颗按钮在一级卡上,员工完全可能一句话还
没说就按它,而 `draft_ticket_from_history([])` 产出的工单标题是「AI 求助升级」、
正文只有一行说明 —— IT 拿到手还得回头再问一遍「你遇到什么事了」,正是这条链路
存在的理由。现在 `escalation.offer_ticket` 在**线程为空**时先请员工用一句话说明,
并在 Redis 记一笔 15 分钟的标记(`session.arm_handoff`);下一条消息由
`take_handoff_armed`(`GETDEL`,一次只兑现一条)取走,直接做成工单,**不再过
模型**。这条规则放在 `offer_ticket` 里而不是按钮那一侧,所以打字说「转人工」和
点按钮拿到的是同一个行为。

**版式上不能回退的几条**(踩过的坑都在 `help_card` 的模块文档里):卡片 2.0 没有
`note` 和 `action` 两个元素;分类阵是 `column_set` + `flex_mode:"bisect"` + 按钮
`width:"fill"`(桌面端不留中缝、手机端不折行);问题句子比分类名长,所以二级卡
一行一颗、整行铺满。

**验证**:23 条用例(`tests/test_assistant/test_help_card_2026_09_09.py`),含
「每颗按钮的动作都被分派器认领」这条成对契约 —— 画按钮和认领事件对不上的长相是
「按钮在,点了没反应」,那在代码里看不出来,只在员工的聊天窗里看得出来。三处变异
验证各自转红(不 spawn 真回合 / 去掉「已发送」那一行 / 摘掉 armed 标记的消费)。
卡片本身用 Helix 飞书应用凭据**真发给自己**实测,zh / en 一级卡与二级卡均
`code: 0`。


## 同步失败的三个问题必须分开问 —— 「目标活着吗 / 谁没同步上 / 还有活要补吗」(2026-09-07)

起因是一条 P2:`identity_sync.dispatch: 1 due target(s) failed this tick; first
errors: **google-dept: failed**`。这就是全文 —— 没有人名、没有原因。三个缺陷叠在
一条 `IdentitySyncRun.status` 上,拆开各修一处(0 migration / 0 cron / 0 RBAC / 0
MCP / 0 API)。

**① 诊断在库里,但没写进任何人读的那一列。** 按人失败的 sweep 从不 raise,所以
`_finalise_run` 收到 `error_message=None`;而下游三个消费方**只**读这一列:
`run_due_targets` 拼 `error_message or 'failed'`(上面那条 P2 的正文)、
`alert_notifier` 把它渲染成卡片的错误行(空白)、`compute_failure_signature` 拿它算
去重签名 —— 于是**同一目标上所有按人失败共用一个签名**,第二种、不同原因的失败被当
成第一种的重复而静音。真相一直躺在 `result_summary.errors[0]`:
`"chunlin.zhuang@pingcap.com: RefreshError: ('unauthorized_client: …')"`。现在按人
失败也合成 `error_message`(前 3 条,压成一行 —— 消费方都只取第一行),与 2026-07
就存在的 dept-phase 合成同一段代码。

**② 「写成功」不是「调通了」。** `_sweep` 的状态判定把 `created+updated+
deactivated+deleted == 0` 读作「目标挂了」(`failed`)。可**读-比对型 runner 稳态下
本来就一个字都不写**:那一跑 `google-dept` 对 122 人做了 GET → 比对 customSchemas
→ 已一致 → `skipped`,其余 288 人 hash-skip,只有 1 人 token 刷新撞了瞬时
`unauthorized_client`。122 次成功的 Google API 调用换来一句「目标 DOWN」+ P2,下一
个 tick 就自愈了。区别在于 **runner 返回的 `skipped` 是厂商答复过的往返,hash-skip
一个 HTTP 都没发** —— 新计数器 `RunSummary.verified_skipped` 只数前者,proof of life
改成 `created+updated+deactivated+deleted+verified_skipped`。**2026-08-14 的
`aws-ai` 凭据吊销仍判 `failed`:生产取证确认那一跑记了 127 条 failed、0 条 skipped
run_item(282 次全是 hash-skip),`verified_skipped=0`。** 90 天回放里只有 2 跑翻转,
都是本次这个形状。

**③ 状态标签同时被当成「要不要告警」和「要不要补跑」。** `partial` 之前既不进
`ran_failed`(dispatcher 不 raise)也不触发当日重试(游标直接推到明天)—— 于是「目标
活着、某个人没同步上」这一类**既没人被叫醒、那个人的修复还要等 24 小时**。而
`alert_recipient_ids` 撑不起这个兜底:生产 12 个目标**全是空**,且按设计没有管理员
fallback(DECISIONS 2026-06-19)。现在 `_RUN_STATUSES_WITH_FAILURES = ("failed",
"partial")` 一个谓词同时驱动两处 —— 与 `alert_notifier._ALERTING_STATUSES` 同集合
(测试断言相等),告警面和分页面不许对「什么算失败」有分歧。自动化异常台账确实会收下
这条失败 run_item 并派给 owner,但那是采集节拍上的复核队列,是后续不是警报。

判据/回归:[`test_credential_outage_run_status_2026_08_14.py`](../../server/tests/test_identity_sync/test_credential_outage_run_status_2026_08_14.py)(两个方向的不变量现在同文件对写)、[`test_item_failure_is_diagnosable_2026_09_07.py`](../../server/tests/test_identity_sync/test_item_failure_is_diagnosable_2026_09_07.py)。后者的 dispatch 用例**跑真的 `run_due_targets`**:既有那条用例 monkeypatch 掉 `run_due_targets` 手工喂 `ran_failed`,只能证明「非空列表会 raise」,永远证明不了列表会被填上 —— 正是这次出问题的那一行。

预期的生产可见变化:`partial` 跑会开始触达 IT(近 60 天全目标合计约 11 跑,每条带诊断正文,且 scheduled-job 通知器按签名去重),并会占用当日重试预算(5/10/20/40/60 分钟,共 5 次,已有上限)。

## 模块地图 —— 「这东西为什么在这儿」的答案 (2026-09-06)

写这一节是因为一个具体的问题:**「很多东西臃肿地分布在各种位置,我不太能理解怎么
分布的。」** 下面是现状的**如实描述**,包括几处确实不一致的地方 —— 不是理想设计。

### 后端:`app/domains/<域>/` 是常态,17 个顶层目录是例外

`app/domains/` 下 51 个域,每个自带 `models / service / router`(需要时还有
`scheduler_handlers` / `connector`)。**新功能默认进这里。**

顶层的另外 17 个目录不是历史遗留,它们各自是**跨所有域的一层**,放进任何单个域都会
造成域间 import(违反本仓第 3 条铁律):

| 目录 | 它是什么层 |
|---|---|
| `core/`(102) | 平台原语:DB、异常、幂等、迁移助手、RBAC、协调器 |
| `scheduled_jobs/`(65) | 调度器本身。**注意**:handler 优先放在**域自己的** `scheduler_handlers.py`,这里只放框架与跨域任务 |
| `integrations/`(52) | 连接器与凭证。驱动在 `drivers/` |
| `assistant/`(41) | 助手/copilot 的会话与工具面 |
| `identity/`(38) | 人的主数据模型。**与 `domains/identity_sync`(出站同步)是两件事** |
| `intelligence/`(38) | 情报中心:事实包 → 叙事 → 建议 |
| `notifications/`(26) | 出箱、模板、类目、偏好 |
| `auth/`(18) | 登录与令牌 |
| `hr_lifecycle/`(18) | 入转离的**编排**。**与 `domains/hr`(HR 数据)是两件事** |
| `approval/` `api_clients/` `mcp/` `mobile/` `dashboard/` `audit/` `compliance/` `observability/` | 各自一层,规模较小 |

**最容易被搞混的两对**,记在这里省得下次再推一遍:

* `identity/` 是**人是谁**;`domains/identity_sync/` 是**把人推给谁**。
* `hr_lifecycle/` 是**入转离怎么走**;`domains/hr/` 是**HR 的数据与工具**。

### 前端:按域,`pages/admin` 是漂移出来的例外

`web/src/pages/` 下 46 个目录,绝大多数**按域**(`asset` / `cloud` / `finance` …),
而且**域目录里本来就放管理端页面** —— `pages/asset/AssetInventoryAdminPage.tsx`、
`pages/cloud/DomainRegistrationsAdminTab.tsx` 都是。

`pages/admin/`(59 个文件)因此**不是第二种约定,是漂移**:里面约 21 个文件明确属于
某个域(`ScheduledJobs*` / `RoleChange*` / `Notifications*` / `Support*` / `Identity*`),
另外约 6 个是真正的管理外壳(`Cockpit` / `PlatformSettings` / `Integrations` /
`McpConnections` / `EmailTemplates`)。

⚠️ **本轮没有搬这 21 个文件。** 搬家本身是机械的(typecheck + build 兜底),但给每个
文件挑新家需要约 20 次判断,而挑错会让布局比现在更糟 —— 「`RoleChange*` 该进
`pages/hr` 还是新建 `pages/roleChange`」这类问题没有唯一答案,而 `pages/provisioning`
里已经有一个 `RoleChangePage.tsx`。**建议:新增页面一律放域目录,`pages/admin` 只留
管理外壳;存量按需要动到哪个搬哪个,不做一次性大搬家**(一次 59 个文件的 diff 会让
之后每一次 `git blame` 与每个在途 PR 都付代价)。

`my/` `me/` `team/` 是按**受众**分的自助面,与按域分的管理面并存,这是有意的。

### 菜单与路由

* **菜单**:`layouts/menu/roleMenus.tsx`(每个视角一份数组)。`MainLayout` 只负责外壳。
  `roleMenus.test.ts` 钉住每个视角的完整目的地集合 —— 分组可以随便改,丢页面会被看见。
* **路由**:`App.tsx` 一棵树。**刻意不按域拆** —— 路由与 `RoleRoute` 守卫是交织的,
  拆开会把「哪些页面在哪道守卫后面」这件事藏起来,而那正是需要一眼能审的东西。
  `mobile/routes.tsx` 是唯一的例外,因为移动端外壳整体独立。

## 自动化异常台账 —— 「一次没走通的自动化」是一等对象 (2026-09-06)

新域 [`app/domains/automation_exception/`](../../server/app/domains/automation_exception),
迁移 618,两张表 + 三条 cron。**先读这一节再去给某个域加第六份失败日志。**

### 为什么是两张表,不是一张

平台已经有五份失败记录(建号任务 `failed` / 出箱 `dead` / 定时任务运行 `failed` /
同步条目 `failed`+`blocked` / 集成 `last_test_status`)。缺的从来不是第六份,而是
**上面那一层**:*这一个问题,自周二起 47 次,在这个集成上,这样修*。

| 表 | 粒度 | 身份 |
|---|---|---|
| `autoexc_exceptions` | 归并后的**问题** | `(source, scope_key, signature_hash)` |
| `autoexc_occurrences` | 每一条失败**源行** | `(source, source_ref_type, source_ref_id)` |

* `scope_key` 进身份是有意的:同一句厂商报错来自两个集成 = 两个问题、两种修法。
  它用**空字符串不用 NULL** —— MySQL 唯一约束里 NULL 不等于 NULL,可空会让重复簇静默堆积。
* 子表那个唯一键就是**所有采集器的幂等键**,每 30 分钟重扫整个窗口靠它成立。
* 父行计数(`occurrence_count` / `open_occurrence_count` / `affected_person_count`)
  **一律从子行重算,绝不累加**。

### 加第六个来源 = 写一个 `collect` + 加一行

采集器只回答「我这个域现在什么在坏」,归并 / 归属 / 严重度 / 生命周期全在引擎里。
注册表在 [`collectors/__init__.py`](../../server/app/domains/automation_exception/collectors/__init__.py),
顺序有意义:集成排第一,这样今晚要是一把过期凭证是其它一切的原因,它的异常已经
建好、并且已经是页面上最高严重度的那一行。

🔴 **新采集器必须自己回答「缺席能不能证明恢复」**,`CollectorResult.absence_means_recovery`
默认 `False`。扫完整当前失败集的(建号 / 集成体检)可以说 `True`;历史事件类的
(定时任务运行 / 同步条目)永远不行 —— 周二那次失败永远都会是一次失败,只能给出
「之后成功过」的显式证据。采集器读挂了要标 `partial`,引擎此时**不做任何**基于缺席
的关闭;否则一次读取异常会把该来源下所有未解决的异常静默判成已修复。

### 已经存在、别再造第二个

* **根因 → 处置**是闭集且全覆盖(`taxonomy.REMEDY_BY_CAUSE`,有测试断言)。
  要加根因就同时加处置,别让工作台出现一行没有下一步的问题。
* **规则层拿不准就返回 `None`**(`rules.classify_cause`),数字错误码全部按词边界匹配。
* **负责人解析**只走既有权威来源(系统台账 owner → backup → 软件台账 owner → IT 接口人),
  每跳校验在职,解析不到就是 `none`。**不要兜底到 IT** —— 兜底既隐藏缺口,又让这一列失去意义。
* **naive/aware 归一**统一走 `timeutil.aware`(本域两个模块各踩过一次)。

### Scheduled jobs (3 — migration 618 seeds, 全部 enabled=1)

| handler_key | cron (UTC) | 做什么 |
|---|---|---|
| `integrations.health_check` | `20 1 * * *` | 🔴 **补上的盲区**:此前 `last_test_status` 只在管理员手点「测试连接」时被写过,集成层从不知道凭证过期。逐个启用的集成跑驱动自带 `test_connection`,每个独立 SAVEPOINT、间隔 1 秒。**只确立事实,不发通知**(告警统一归异常台,避免一次凭证过期变成四张卡)。weight=heavy。 |
| `automation_exception.collect` | `*/30 * * * *` | 走五个采集器,归并 + 登记 + 判恢复。每个来源独立 SAVEPOINT。 |
| `automation_exception.reconcile` | `10 4 * * *` | 把每条未关闭异常从发生记录重算(采集只碰本次报到的簇,源头停产的簇需要兜底)。 |

`integrations.health_check` 活在 `app/integrations/health_check.py`,**不在**
`scheduled_jobs/handlers.py` —— 域自带 `scheduler_handlers` 是既有形态,新 cron 优先这样放。

### 面

* `/admin/health` 从单屏变成两个 tab:**自动化异常**(可处置,默认打开)+ **健康总览**
  (原有四块 KPI 面板,原样保留)。路由、菜单位一个都没动。
* 列表按严重度排序走**显式 CASE**:`critical` < `high` < `low` < `normal` 是字母序,
  裸 ORDER BY 会把 low 排到 normal 前面。
* 一切读数引用 `open_occurrence_count`,**不是** `occurrence_count`;差额是已自愈的部分。
* 词表(来源/状态/严重度/根因/处置)由 `GET .../vocabulary` 提供,前端只存标签不存列表;
  `tests/test_automation_exception/test_i18n_label_parity.py` 两侧各读各的定义再比对。
* MCP:3 个只读工具(hint 623)。四个人工状态迁移**不给工具**,同「不给按人回收账号」的先例。

## 自动化异常的回音与复发升级 (2026-09-06)

`automation_exception/echo.py`,cron `automation_exception.echo`(每小时 :20,排在
采集 :00 与分诊 :35 之后)。**没有新列** —— PR1 的 `subject_notified_at` /
`escalated_at` 就是为这一轮留的。

* **类目从 template_key 派生**(`categories.classify`),所以两个 key 注册进**既有**
  `lifecycle` 类目 ⇒ 员工已有的静音与免打扰天然管住它。**不要为这种东西新增类目** ——
  等于悄悄给所有人塞一条他们从没同意过的推送。
* **员工那条不含失败细节**:只说「现在可以用了」。把「它失败过 47 次」讲给一个只是在
  等的人,是把已解决的问题变成新的担心。失败叙事只给负责人,因为他能处置。
* **一次复发是噪声,两次才是模式**(`RECURRENCE_ESCALATION_THRESHOLD = 2`)。
* **发送失败不落戳**(会重试),**无人可告知则落戳**(是终态,不该每 tick 重走)。

### 🔴 「我刚才读过它」就是让重新读失效的那个条件

`select()` / `Session.get()` 都返回 identity map 里的旧对象;`expire_on_commit=False`
让 commit 也不帮你过期。**只有 `populate_existing` 会刷新。**

本轮两处真缺陷都是这个形状:重放冷却期与回音去重,各自「先读来判断,再走一段会改它的
逻辑」,两个并发调用都读到旧值、都放行 —— 把去重本身要防的事原样做了一遍。
四条查询现已 `.with_for_update().execution_options(populate_existing=True)`。
**锁本身不解决问题,是 `populate_existing` 让锁有意义。**

⚠️ 补洞的测试会**空转**,除非:Core UPDATE 显式 `synchronize_session=False`
(默认 `'auto'` 会把新值写回内存实例,替你把「陈旧」抹平),**并且**在调用被测函数前
先断言一句「内存副本此刻确实是旧的」。

⚠️ 时间比较必须过 `timeutil.aware()`:**MySQL 返回 naive datetime**,而本进程在内存里
写进去的是 aware。只在内存里流转时看不出来,**在数据库里走过一个来回就炸** ——
也就是第一次之后的每一次运行。

## 自动化异常的处置与重放 (2026-09-06)

`automation_exception/replay.py`。**没有自己的 schema** —— PR1 就把
`replayable` / `replay_attempt_count` / `last_replay_at` / `last_replay_error`
四列留好了,PR3 只是来写它们。

### 🔴 「这一行能不能重放」只有一个权威:行上的 `replayable`

由**产出它的采集器**逐行标注,因为只有它知道那一行带没带够重试所需的东西。

我在 PR3 初版里按**来源**判定,造出了第二套且**互相矛盾**的定义 —— 它会重放
`integration` 行,而 `integration_source` 明确标了 `replayable=False`。
**一个问题两个权威,就是它们漂移的方式。** 现在两处查询与循环体内都以行上的标记为准,
来源表(`_REPLAY_BY_SOURCE`)只用于分派。

* 有 handler 的三个来源 = 采集器会标 `replayable=True` 的三个来源(测试断言相等)。
* `scheduled_job` 没有 handler:一次运行是历史事件,「重放」它等于触发整个任务 ——
  那是另一个动作、另一个爆炸半径,不该藏在同一个按钮后面。
* `integration` 没有 handler:重新测一次连接不是「重放失败的工作」;
  集成级入口重放的是它**下面**那些行,那才是有用的动作。

### 其余四条性质

* **只驱动既有原语**(`retry_task` / `resend_outbox_row` / `sync_person_to_target`),
  三者各自已有行锁、审计与幂等。在这里重写一遍 = 同一件事的第二个实现。
* **有界**(`MAX_REPLAY_PER_RUN`):给刚恢复的厂商灌一整个坏发布的重试量,
  是自己制造一次故障。
* **成功也不写 `recovered_at`**:一次返回 200 的重试不是底层工作成功的证据。
  恢复由采集器按自己的证据判定 —— 在这里替它们下结论,等于往唯一一张
  记录「失败真相」的表里写一句谎。
* **失败也留痕**:`last_replay_at` 即使失败也推进,冷却期内的第二次点击是
  **看得见的空转**,而不是悄悄把厂商负载翻倍。

### 面

详情抽屉的「重新执行(N)」,`N` 来自 `replayable_occurrence_count`,与引擎同一个权威 ——
按钮**永远不会提供一个可证明什么都不会做的动作**。回音分三种且不四舍五入成成功。

## 平台自身的 LLM 调用台账 —— 我们是唯一没有账本的那个消费方 (2026-09-06)

`llm_call_ledger`(迁移 619)。**在此之前:29 个模块调共享 Bailian 客户端,只有
`ai_labs` 与 `intelligence` 记过 token。** `ai_spending` 按 API key 拆厂商账单给财务看,
但账单答不出「哪个功能」。

### 三条性质(都是刻意的,改之前先读理由)

* **不存内容。** 表里没有放提示词/回复的列 —— 这是 schema 的性质,不是约定。
  运营表一旦积累了员工工单文本与赏金报告,就成了没人签字同意的披露面。
* **记 token 不记钱。** 本仓没有按模型的单价表,`ledger.py` **不发明一张**。
  厂商发票仍是钱的真相。想把两者相乘的人是在做按比例分摊的假设,应当明说。
  这句话写进了 payload、前端 Alert 和 MCP 工具描述 —— 读的人会按看到的数字行动。
* **记账绝不失败调用。** 每次写用自己的短命 session 并吞错误,调用方事务一个字不碰。

### 埋点位置

`BailianLLMClient` 的四个出口,**包一层**而不是在里面记 —— 这正是失败路径也进台账的原因。
`_call`(`chat_json`/`chat_text`)/ `chat`(compat)/ `chat_stream` 各自 `_record_call`。
`purpose=` 是构造参数,30 处构造全部带上(逐个解析确认);够不到构造点的用
`ledger.purpose_scope("...")` 上下文管理器。没声明的落 `unattributed` 而**不是丢掉**。

⚠️ **测试里 `record()` 会打到应用引擎。** 把 `async_session_factory` 重定向到测试库的钩子
挂在 `client` fixture 上(见其 docstring),只取 `db_session` 的测试会**写进共享 dev 库**。
这与前端测试逃出 jsdom 打到真 dev server 是同一个形状:它会「通过」,原因却与代码对不对无关。

⚠️ **读侧不要加 `called_at <= now` 上界。** MySQL 把 `DATETIME` 四舍五入到整秒,
12:00:00.6 落库成 12:00:01,会被 12:00:00.8 的上界排除。「不在未来」这个上界永远排除不掉
真行,却会随机排除掉**最新的**行。

## 自动化异常的 AI 分诊 (2026-09-06)

`automation_exception/triage.py`,cron `automation_exception.triage`(每小时)。
形态照 `hr_lifecycle/mover_review_ai.py`:事实包 → 建议,人决定。

* **按签名调用,不按发生次数。** 一个出现 4000 次的签名是一次调用,答案缓存在
  `advice*` 列上(PR1 就是为此留的)。
* **四种情况不问模型**:人判过 / 规则定过 / 这个签名问过 / 行已解决。
  **所以规则表越长,这条任务越便宜** —— 方向是对的。
* **闭集根因。** 模型只能从 `ROOT_CAUSES` 里选;自由文本会破坏 `REMEDY_BY_CAUSE` 的
  全覆盖,把行重新变成没有下一步的死胡同。
* **数字必须在事实包里**,否则整条回复作废(不是裁掉那一句)。
* **不关闭、不通知、不覆盖人工判定**,并在模型返回后**重查一次**人工判定 ——
  模型调用要好几秒,那是一个真实的窗口。
* 无 key 时记 `AI_NO_KEY` 但**不**盖 `advice_generated_at`:key 缺失是临时配置状态,
  不是答案;盖了这些行就再也不会被重访。

## AI 实验室桌面端 —— web/src 里第一个流式消费者 (2026-09-03)

一级菜单「AI 实验室」+ 五个页面,在 [`web/src/pages/aiLabs/`](../../web/src/pages/aiLabs)。
只在**普通员工**视角出现;路由**不套 `RoleRoute`** —— 这个面按构造自收口,没有哪个
角色能看得更宽,真正的闸是 fail-closed 的 `ai_labs.enabled`。

| 路由 | 页面 |
|---|---|
| `/me/ai-labs` | 对话(会话侧栏 + 流式 + 模型切换 + 思考开关 + 贴图/附文档) |
| `/me/ai-labs/image` | 图像工坊(文生图 / 图片编辑 两 tab) |
| `/me/ai-labs/video` | 视频工坊 |
| `/me/ai-labs/audio` | 语音与语言(合成 / 转写 / 翻译 三 tab) |
| `/me/ai-labs/gallery` | 我的作品 |

### 🔴 SSE:第一个流式消费者,以及三个只在真实网络下出现的坑

此前 `web/src` **零** SSE / ReadableStream 消费者(每个 LLM 面都是「请求-响应 +
长超时 + 转圈」)。对话这一条必须流式,所以:

- **走 `fetch` 不走 axios**:axios 在浏览器里会把整个响应体缓冲完才 resolve,
  正好抵消流式的全部意义。代价是鉴权头与 `X-View-Role` 收窄要**手工照抄拦截器**
  —— 少了后者,这一个端点会比会话里其它每一次调用看到更宽的角色。
- **帧解码器是纯函数**([`api/aiLabsStream.ts`](../../web/src/api/aiLabsStream.ts),
  无浏览器 API,可在 node 环境单测)。它防的三件事在本机手测里全都看不见:
  ① **一帧被切在两个分片之间**(中文回答每几个 token 发一帧,TCP 不尊重我们的边界);
  ② **一个分片里挤了好几帧**(HTTP/2 下常见);
  ③ **一个多字节字符跨在分片边界上** —— `TextDecoder.decode` 少了
  `{stream: true}` 就会产出一串 U+FFFD,对中文回答来说是「大部分内容」。
- `data:` 后按规范只剥**一个**空格。用 `trim()` 会把 ` world` 的前导空格吃掉、
  把单词粘在一起。
- 解析不了的帧**丢弃并继续**:为一帧坏数据杀掉写了一半的回答,对读者严格更糟。

### 一轮对话有四种结局,各有各的渲染

[`aiLabsChat.ts`](../../web/src/pages/aiLabs/aiLabsChat.ts) 是纯归约(完成 /
模型返空 / 传输断 / 员工按停止)。在组件里用一堆布尔量推导,就会得到那个转不停的
spinner。两条不显然的规则:

- **`error` 之后到达的 `done` 帧被忽略** —— 后端可能晚失败,让 `done` 覆盖掉错误
  就是用一个愉快的空回答替换一次真实故障。
- **答案开始后不再退回「思考中」** —— 有些模型交错输出,把界面打回 thinking 会让
  已经写完的文字看起来像被撤回。

直播中的一轮与刷新后重载的同一轮**走同一个渲染组件**(`liveTurnAsMessages` 把在途的
一轮塑成持久化历史的形状),所以不存在第二条会漂移的代码路径。

### 控件全部由服务端目录生成

模型选择器、每个参数下拉、上传的 accept 列表、字数上限,全部来自
`GET /ai-labs/catalog`,**不写字面量**。两个后果:厂商弃用某个模型时改一条设置即可、
不必发版;厂商停止接受某个选项时它从界面上**消失**而不是留在屏幕上当一个 422 的按钮。
视频页因此**没有画幅控件** —— 厂商接受 `size` 然后忽略它。

### 三个「朴素做法就是错的」UI 细节

- **用量条只显示真正生效的上限**:配额默认全关(0 = 不限),朴素渲染就是一排
  `0 / 0` 进度条,凭空发明一个不存在的限制。
- **作品下载链接按 tile 懒取**:15 分钟有效的预签名链接,进页面就为整个画廊铸一批
  等于发出几十条读者可能永不点开的活链接,而等他真点时一半已过期。
- **轮询一个定时器、只轮未完成、标签页隐藏就停**:每 tile 一个定时器会漂成一条连续
  请求流;完成的行不会再变;一个视频任务开着过夜不停就是到早上约 14,000 次请求。

### 合规气泡

[`ComplianceNotice.tsx`](../../web/src/pages/aiLabs/ComplianceNotice.tsx) 四句,
每句对应代码真的在做的事,外加一句「请不要输入客户数据/密钥/未公开财务信息」
(对人的提醒,不是对提示词的加工)。可关闭并按浏览器记住 —— 它是一次安抚而不是
同意闸。`localStorage` 读写全部 try/catch:隐私窗口或阻止站点数据的策略会让访问器
本身抛异常,而一条横幅绝不该是页面渲染不出来的原因。

### 三种不可用状态,不是一个

「未开启」/「已开启但未配连接器」/「目录读不到」后续动作完全不同(找 IT / 找管理员 /
重试),合成一个「不可用」页会把所有人指向错的地方。见
[`AiLabsUnavailable.tsx`](../../web/src/pages/aiLabs/AiLabsUnavailable.tsx)。


## AI 实验室 —— 员工侧的裸 Qwen 面,以及两个静默的厂商行为 (2026-09-03)

新域 [`app/domains/ai_labs`](../../server/app/domains/ai_labs)(表前缀 `ai_labs_`,迁移 **601**,失败原因表 **602**)。

管理端(全部 `require_admin_read`,即 `global_admin` / `auditor` / `management`):`/ai-labs/admin/settings`(写为 `require_admin`)、`/usage`、`/usage/people`、`/usage/people/{person_id}`、`/usage/failures`、`/generations/health`。**只有 `/usage/people/{person_id}` 记审计**(`ai_labs.admin.view_person_usage`)—— 查一个**具名**员工用了多少是正当操作,也正是应该留痕的那种操作;聚合列表不记,否则每次翻页一行审计会把真正要看的那些查询埋掉。本域**刻意没有 MCP 孪生**(见 `presign_upload_get` 的描述),所以这几个端点不配 MCP 工具,不是漏了。
把公司已经在付费的 DashScope/百炼通道包成一个所有人都能开的面 —— 此前它只被客服
copilot、平台情报简报、AI 花费摘要这几条内嵌流程用着。凭据、区域**共用同一个
AI/LLM 连接器**(`ai_spending.llm.api_key` / `.region`),所以轮换 key 只有一处、
花费也只出现在一处;但**模型不读 `ai_spending.llm.model`** —— 那是 copilot 的选择,
指向它会让管理员调 copilot 时悄悄改掉员工看到的模型清单。

整域挂在 fail-closed 的 `ai_labs.enabled` 后面(默认 `False`,每请求重查不缓存)。

### 两条产品承诺是结构性的,不是检查出来的

1. **Helix 不看员工在这里写什么。** 本域没有任何管理端点能读到对话 / 消息 /
   提示词 / 作品 —— 不是有闸拦着,而是管理面只查 `ai_labs_usage_daily` 和
   `ai_labs_failures_daily`(迁移 **602**),**这两张表没有一列能装下这些东西**。
   审计记的是谁 / 何时 / 哪个能力 / 哪个模型 /
   多长(字符数、附件个数),**不记内容**;`ai_labs.chat.turn` /
   `ai_labs.translate` / `ai_labs.generation.cancel` 三条行的 detail 有测试断言
   提示词不在里面。删除是**硬删**(刻意没有 `deleted_at`),并连带删 S3 对象。

   ⚠️ **「读不到内容」不等于「读不到人」,这两件事被混过一次。** 管理端最早只能
   报「8 次调用 / 2 次失败」,再往下问「谁用的、哪里失败的」就没有答案了 —— 但
   **`person_id` 一直在 `ai_labs_usage_daily` 上**,按人下钻缺的是一个 GROUP BY,
   不是一道隐私边界。真正缺的只有**失败原因**:`failures` 是个裸计数,错误码只写在
   `ai_labs_messages.error_code` / `ai_labs_generations.error_code`(内容表,管理面
   不许查、且员工可硬删)。所以 602 加了 `ai_labs_failures_daily`
   (`person × 日 × 能力 × 模型 × error_code → 计数 + last_seen_at`),
   刻意**不是**给 `ai_labs_usage_daily` 的唯一键加第六列 —— 那张表同时是额度闸的读模型,
   加宽键会让每次额度检查多 SUM 出一批行。

   🔴 **这张表上唯一的非数值列是错误码,写入必过 `usage.normalize_error_code`。**
   厂商的 `error_code` 是任意字符串,而异步媒体那一族在内容策略拒绝时会**把被拒的
   提示词原样引回来**(`executor._run_async` 的 `TASK_FAILED` 分支里有原文注释)。
   不是码的形状 ⇒ 一律记成 `AI_LABS_VENDOR_UNCLASSIFIED`,原文只进日志。
   **没有这道闸,整域唯一一张管理员能读的表就是提示词泄出去的地方。**

   `failure_overview` 同时报 `counted_failures`(权威、早于本表)与
   `classified_failures`,差额显式命名为 `unclassified_failures`,前端也一定要显示:
   602 之前的失败原因写在员工的内容行上、按设计读不到,**补不回来**;把覆盖 2/8 的
   拆分当成全部展示,会把运维引向错误的方向。
   `presign-get` 的 ACL 链里 `ai_labs_*` 那一条是**仅上传者、无管理员兜底**,
   是整条链唯一一条任何角色都放宽不了的分支。

   🔴 **这条承诺有一个不在本域里的漏点,已修,值得记住形状**:对象键原本是
   `{purpose}/{date}/{person}/{rand}-{文件名}`,而键会被抄进
   `storage_upload_grants` 台账行和 `storage.upload.authorize` 审计行 ——
   后者**全公司可读**(`/audit/logs` + CSV 导出),而且**活过员工自己的硬删**。
   `离职谈话录音.m4a` 是内容,不是元数据。修法是根因层的:
   `storage_uploads.OPAQUE_NAME_PURPOSES` 让这三个 purpose 的键**不带文件名**
   (只留扩展名),展示名随请求走(`ChatTurnBody.attachment_names`)并只存在
   员工自己的消息行上。教训通用:**一条「谁都读不到」的承诺,要顺着这条数据
   会被抄到哪里去查,而不是只查本域读接口的权限**。同类的第二处是
   `core_idempotency_records` —— 缓存的响应体在本域之外躺满 24h TTL,硬删碰不到,
   靠 `redact_cached` 摘掉。

   厂商错误正文也**不再转达给员工**:厂商拒绝时常把出问题的输入引回来,逐字
   转达等于把提示词片段写进 `error_message` 再渲染出来。只给短码,正文进日志。
2. **Helix 不加工员工的提示词。** 不注入 system message、不改写、不接地、不带
   工具。由 [`test_prompt_passthrough_2026_09_03.py`](../../server/tests/test_ai_labs/test_prompt_passthrough_2026_09_03.py)
   盯着**真正发出去的 HTTP body**:无 `system` 角色、用户文本逐字节一致、无
   `tools`/`response_format` 之类能夹带指令的字段、每个生成能力的
   `prompt_extend` 恒 False。`GenerationCapability` 这个 dataclass **刻意没有**
   `system`/`preamble` 字段,并有一条测试断言它不会长出来。

### 🔴 两个厂商行为:按文档写会上线一个死功能

- **`wan3.0-video-prime` 静默忽略 `size`。** 三次分别请求 `1280*720` /
  `960*960` / `720*1280` 全部 200 成功,返回画幅 `1:1` / `16:9` / `16:9`,
  与请求毫无关系,`SR` 每次钉在 1080。**所以目录里没有画幅控件** —— 一个点了
  不起作用的下拉框比没有更糟。真正生效的是 `resolution`(逐个渲染实测
  480P→SR480 / 720P→SR720 / 1080P→SR1080)。这条是「阿里云数组参数被静默忽略」
  那一类的又一例:**绿的编码器测试证明不了厂商接受**。
- **`403 AllocationQuota.FreeTierOnly` 不是账单问题。** 那是百炼控制台里
  **每个模型一个**的「免费额度用完即停」开关:开着且该模型零免费额度时,每次
  调用必拒、永远不转按量。而错误文案写的是「请完善支付信息」,把人往计费页带。
  半数看起来合理的模型 id 当时都是这个状态,按文档挑「一个成熟稳定的老模型」
  恰好会挑中。[`media_client._raise_for_payload`](../../server/app/domains/bailian/media_client.py)
  把这一个厂商码单独映射成 `BAILIAN_MEDIA_FREE_TIER_GATE`,消息里直接写出开关
  的位置。2026-09-03 已把 123 个模型的开关关掉(大语言 52 / 视觉 30 / 语音 28 /
  全模态 13;向量那一类无需关)。**批量关闭只作用于当前 tab + 当前搜索框结果**,
  必须清空搜索逐 tab 做。

### 前端认自己那一轮,靠的是客户端自己发的 token,不是文本

`ai_labs_messages.client_token` 现在会随消息行返回给**本人**(内容为零)。桌面端与
移动端的 `visibleMessages` 用两个都精确的来源认身份:`meta` 帧给的持久化 id,以及
在那之前 —— **客户端自己 mint 的 `client_token`**,它从发送那一刻起就存在,而服务端
本来就把它存在行上(它同时是 `uq_alm_conv_client` 那条重放闸的键)。

**按问题的文本匹配试过两次,两次都错,而且不可能对。** 「服务端已经存下这一轮」和
「员工重发了一句一样的话」在文本上无法区分,却需要相反的渲染:只看末行会漏掉
「流在 `meta` 帧那一刻被关掉」那种情况(它会持久化一条 `AI_LABS_INTERRUPTED` 的
assistant 行,而客户端从没拿到 id)—— 问题画两遍;回扫两行则会匹配到上一轮的问题 ——
新的一轮整个不渲染。所以这里不是选一个更好的启发式,而是把歧义去掉。

### 三个新的平台原语,和一条「记不记账」的判据

- **`storage_uploads.grant_ttl_for_purpose`** —— 上传凭证的有效期**按 purpose**,不再是全局 10 分钟。AI 实验室是 4 小时,因为它**选文件时上传、发送时认领**,中间是「员工写这段话花了多久」;其余 purpose 一律不变(它们的间隔就是一次表单提交)。配套改了 `storage_handlers._eligible_claim`:它原来只看 `created_at`、从不读 `expires_at`,那**只是因为**过去所有 TTL 都短于清理的一小时下限;4 小时凭证把这个顺序颠倒了,于是保留期设成 1–3 小时(这个 job 接受的正常调参)时会把员工还能用的附件删掉。现在闸门读凭证自己的到期时间,顺序是不变量而不是巧合。
- **`storage_uploads.safe_display_filename`** —— 给**要展示**的文件名用,和给 S3 键用的 `safe_upload_filename` 是两个函数。后者会把 `年度报告.pdf` 洗成 `pdf`(非 ASCII → `_`,再 `strip("._")` 连点一起吃掉),在一家中文公司这是常见情况。展示版保留 CJK,去掉 C0/C1、路径分隔符,并按 Unicode **`Cf` 类别**去掉全部格式控制符 —— 枚举写法漏了 U+00AD / U+2060 / U+FFF9-FFFB,而 U+202E 那种双向覆盖能让 `x<RLO>gnp.exe` 显示成 `xexe.png`。
- **`executor.heartbeat_interval`** —— 租约心跳间隔由函数给出,循环和测试读**同一条代码路径**。第一版导出了常量而循环留着自己的算式,于是改常量测试变红而行为不变、改循环什么都不红。

🔴 **判据:钱在哪一刻花掉,就在那一刻记 —— 不是在终态记。**
`_note_charge` 由**接受这次工作的那个调用**在原地触发:同步是 `multimodal_generate` 返回那一刻,异步是 `submit_task` 成功那一刻(重轮询已有 task 的那一跑不触发)。终态只补产出(作品数、媒体秒数、失败),永不再加第二个 `requests`。
这条判据被写错过两次,两次都漏账:先是按「终态写入有没有落地」判,于是同一个事件按「哪个观察者先发现」得出两种结果;然后改成在**函数入口**算「这一次跑有没有花钱」,而记账在终态 —— 提前离开的路径压根到不了那一行,于是**每一个换过手的异步生成(含任何 reaper 恢复)都一行用量都没记**。`requests` = 厂商接受了一次计费工作;`failures` = 任务最终失败;在厂商接受任何工作之前就失败的,只记后者。

### 目录在代码里,且每条都带实测日期

[`catalog.py`](../../server/app/domains/ai_labs/catalog.py) 是唯一真源:7 个对话模型、
5 个生成能力、每个参数的**闭集**选项、14 种翻译目标语言、全部限额。
[`scripts/verify_ai_labs_catalog.py`](../../server/scripts/verify_ai_labs_catalog.py)
把整份目录对着线上 key 重跑一遍并逐条给判据 —— 图片尺寸的判据是
**「请求的像素 == 返回的像素」而不是「调用成功」**(厂商接受一个参数和遵守它
是两件事,这份目录存在就是因为它们分开过一次)。加目录项、或百炼控制台有任何
改动之后,跑它。

### 生成是持久任务,不是慢一点的 HTTP 调用

`queued → running → succeeded | failed | cancelled`,行在联系厂商**之前**就落库。
**租约必须在长 await 期间被续。** `_LeaseKeeper`(`executor.py`)开自己的
session、每 `LEASE_SECONDS/3` 跳一次,包住整个厂商调用。原因是同步生成就是
**一个**长 await(没有轮询循环可以顺便心跳),超过 180s 租约后行变成可抢、
reaper 抢走并**再提交一次** —— 同一次点击付两遍钱。异步任务成功后下载几十 MB
视频的那一段同理。`keeper.lost` 为真表示租约被别人拿走(或员工取消了),此时
本 runner 不写终态、也**不记用量** —— `_finish` 返回的布尔值是唯一可信的
「我的终态真的落地了」信号,忽略它会让一次厂商调用被计两次。

请求路径提交后 `spawn` 一个 runner;`ai_labs.reap_generations` 对**租约已过期**的
未完成行调**同一个函数**。三件事让它安全:

- **厂商 task id 在受理那一刻就写进行里**,所以「续跑」= 接着轮询已经在跑的那次
  渲染,而不是再提交一次(否则一次点击两次计费,员工拿到第二条片子)。
- **协作式租约**(180s + 轮询心跳续期):抢占是一条对 `lease_expires_at` 的条件
  UPDATE,只有 `rowcount == 1` 的继续。租约短是为了让死掉的 runner 几分钟内被
  接管,而不是等到任务自己的 deadline。
- **作品在任务被判成功之前就搬进 Helix 自己的桶**:厂商结果链接实测 24 小时过期,
  存下来的行会在评审时正确、当天手测正确、**下周对员工坏掉**。行里只存 S3 key。

### Scheduled jobs (2 — migration 601 seeds)

| key | 周期 | 做什么 |
|---|---|---|
| `ai_labs.reap_generations` | `*/5 * * * *` Asia/Shanghai | 续跑租约已过期的未完成生成任务(spawn,不 await —— reaper 的职责是重启滞留的活,不是坐等它跑完);重试次数用尽的在此判失败,免得一条毒任务每个 tick 占一个槽位。 |
| `ai_labs.sweep_retention` | `40 3 * * *` Asia/Shanghai | 仅当 `ai_labs.retention_days > 0` 时清理超期的对话与作品(含 S3)。默认 0 = 不清理。 |

### 平台设置

`ai_labs.enabled`(默认 false)、`ai_labs.quota.{daily_tokens,daily_images,daily_video_seconds,daily_speech,daily_transcribe}`
(**全部默认 0 = 不限**)、`ai_labs.retention_days`(默认 0 = 不清理)、
`ai_labs.reporting_timezone`(默认 `Asia/Shanghai` —— 配额窗口必须是员工经历的
真实一天,UTC 日会在北京的下午重置)、`ai_labs.model_overrides`
(`{目录 key: 厂商模型 id}`,给厂商中途弃用某模型时不必发版重指的逃生口)。

### 上传 purpose 与内容类型收窄

新增 `ai_labs_image`(10MB)/ `ai_labs_document`(20MB)/ `ai_labs_audio`(15MB)。
顺带把内容类型校验从**全局**收窄成**按 purpose**
([`storage_uploads.PURPOSE_CONTENT_TYPES`](../../server/app/core/storage_uploads.py))
—— 全局那份是纯图片白名单,为了让 AI 实验室收文档和音频而放宽它,会顺手允许把
PDF 当动态广场的照片发。**缺席这张表的 purpose 行为逐字不变。**

图片与音频以 **base64 data URI** 内联(2026-09-03 实测对话看图 / 图片编辑 /
视频参考图三个面全部接受),这样既不必把 Helix 存储的预签名链接交给第三方,也不
依赖我们的桶从厂商网络可达。文档在**服务端**抽取文本(PDF→`pypdf` 新依赖,
.docx→`python-docx`,.xlsx→`openpyxl`),只有抽出来的文字上路,超长按
`DOCUMENT_TEXT_MAX_CHARS` 截断且**明说截断了**。

附件先按 key 自身的形状校验 purpose 与上传者、**再**取字节 —— 与动态广场 #2124
学到的同一道闸:信任客户端给的 object key,员工就能把别人的凭证照片当附件读回来。

### 流式对话为什么劈成两半

[`chat.py`](../../server/app/domains/ai_labs/chat.py) 的 `prepare_turn` 在请求内、
用请求的会话跑完校验/历史/附件并**提交员工的问题**;`stream_turn` 在处理函数
**返回之后**才驱动 `StreamingResponse`,那时 FastAPI 已经关掉了请求会话。所以
两半之间只传**纯数据**,不传 ORM 实例(传了就是过期实例,首次属性访问
`MissingGreenlet`,而症状是答案在流中间被截断)。这个间隙也是**不跨厂商调用
握住数据库连接**的原因 —— 一个思考模型的回合能跑过一分钟,空闲连接停在那里就是
MySQL 2013 的来源。`X-Accel-Buffering: no` 是承重的:少了它 nginx 会缓冲整个
响应,员工在回合结束前什么都看不到,而流式存在的意义正是避免这个。

### MCP 刻意不配孪生工具

与仓规矩「每个新自助 HTTP 流程同 PR 补 1:1 MCP 工具」的一次**显式豁免**
(见 DECISIONS 2026-09-03):AI 实验室是给人用的裸模型面,让一个 AI 客户端代员工
去调另一个模型没有产品意义,只增加一条私人工作区的暴露路径。MCP 那侧的
`presign_upload_get` **显式拒绝** `ai_labs_*`(原本靠 fail-closed 的 `else` 兜住,
但那是巧合而非明写的意图,而 PAT 恰恰是 AI 客户端可能被劝说去重放的凭据)。
`_REGISTERED_TOOLS_COUNT_HINT` 因此不变。

### 一条会静默把测试数据写进 dev 库的坑

流式回答结束后的写入、用量计数、executor 都刻意开**自己的会话**。测试基架
monkeypatch 的是**模块属性** `app.core.database.async_session_factory`,所以
**模块级 `from ... import async_session_factory` 会在导入时绑定原对象,补丁永远
不生效** —— 写入静默落进 `helix` 开发库,唯一症状是后面一条关于历史的断言查不到
行。仓里既有代码(`audit.log_denied_action`)用的是**函数内导入**,本域已全部对齐。
另外:只用 `db_session` 而不用 `client` fixture 的测试,那个补丁根本没生效
(它住在 `client` fixture 里),需要自己绑一次(见
[`test_executor_2026_09_03.py`](../../server/tests/test_ai_labs/test_executor_2026_09_03.py)
的 `_own_sessions_hit_the_test_schema`)。


## 助手四 PR 的合并后复审:21 处修复 (2026-09-03)

四路对抗式复审跑完之后的沉淀。**每一条都是「代码和它自己的注释不一致」** —— 这批
里没有一条是「没想到」,全部是「想到了、写下来了、然后另一处没跟上」。

### 三条把这一批串起来的规律

**① 一份手工排除名单,漏收的方式和上次一模一样。** `mcp_bridge._SELF_AUDIT_TOOLS`
的注释里就记着它上一次漏收 `assistant_unhelpful_answers` —— 「这正是这份集合要防的
漂移」。一个 release 之后,四个同类工具用同样的方式漏了。**收编式的白名单
(`readonly_catalogue` 收所有非写库工具再减去排除名单)天然是这个形状**:新增一个
读工具默认进来,而「这个读工具读的是别人的话」这件事没有任何机器知道。
下一个此类工具的正确做法是给它一个可判定的标记,而不是再往名单里加一行。

**② 一个数被两个不同的分母算过。** `pass_rate` 剔除 error,可比性下限数含 error 的
`attempts_total`;`compare_runs` 拿两个覆盖面不同的 headline 相减。两处都不是笔误,
是**同一个概念(「这次量到了多少」)在两处各自实现了一遍**。落到 PLATFORM 这一层的
教训:一个会被拿来告警的比值,它的分子分母要和它的可比性判据来自同一次计算。

**③ 一个 kind 被产出,却没有被落地。** `KIND_DEAD_LINK` 在常量表里、在 lint 里、在
理由文案里(「接受即删除这条关联」),唯独不在适配器的分支里 —— 而适配器的兜底是
`raise ProposalApplyError(f"知识库不支持这种提案:{kind}")`,一个只有点下去才会
出现的错误。**`KINDS` 元组和适配器分支之间没有任何东西在对账。**

### 值得单独记住的三条

⚠️ **提示词要求的东西,不能被检查判成捏造。** 员工助手的提示词结尾无条件要求给出
`helix.pingcap.net` 的链接,而 `citation_findings` 把「不在任何工具结果里的 URL」判成
捏造引用 —— 一条**阻断级**检查。结果是每个照做的回答都不及格,而且归错类。
白名单是**精确 URL**(`reply_hygiene.PORTAL` / `PORTAL_REQUESTS`),不是主机:同一个
域下编出来的路径仍然要抓。

⚠️ **`open_fingerprint` 的语义是「这个建议现在开着吗」,不是「这个建议处理过吗」。**
所有终态都要清它,`failed` 也不例外 —— 因为没有任何地方会把提案改回 `open`,留着
就等于让一次瞬时错误把这个发现**永久静音**(下一趟 lint 撞唯一约束、`propose` 返回
None、一声不响)。失败那行照样带着错误留在 `failed` 过滤器里,可见性没有丢。

⚠️ **`fingerprint_for` 哈希 payload,所以 payload 里不能放「为什么提」。** 相似度分数
放进 payload 之后,任何一次重嵌都会生成一个新指纹 —— 同一对文档每重嵌一次多一行。
分数属于 `evidence`,而指纹刻意不含 evidence,这个分工本来就写在函数文档里。

### 第二轮:审复审本身,又找到 14 条

修复轮里「放宽一处、收紧另一处」是本仓记录在案的新缺陷第一来源,所以对着这批修复
又跑了一遍安全扫描和逻辑扫描。**两位复审各自独立找到了同一个由修复引入的新缺陷**。
值得记住的三条:

⚠️ **一个降级路径要跑通它降级到的那条落地路。** 把「改写镜像页」降级成「关联」时,
`related_ref` 和 `payload` 都空着 —— 而 `KIND_LINK` 在没有 `related_ref` 时走的是
「把新知识存成草稿再关联」,拿空正文调 `create` 必然失败。**降级产出的行必须是能
被接受的行**,否则只是把一种关不掉的队列行换成了另一种。同一处还踩了指纹:
`payload={}` + 无 `related_ref` 让所有降级到同一页的条目算出同一个指纹。

⚠️ **「只允许覆盖已声明的键」不是一个安全约束。** 对 `dead_link` 来说,声明的那个
键 `link_id` **就是目标**。约束要按 `(kind, key)` 白名单写,而且白名单外的键要
**明确拒绝**:悄悄丢掉一个审阅人打进去的值,然后让接受动作死在一个不相关的校验
上,是两次都没说清发生了什么。

⚠️ **惰性过期意味着「status 还是 pending」不等于「还活着」。** 本仓没有提案清扫
任务,`expire_if_lapsed` 只在提案读路径上跑。任何**新的**读路径只按 status 过滤,
拿到的就是一批已经死掉的行 —— 而页面会照着画一个可点的按钮。

### 交付面上的两条

**`GET /support/copilot/thread`(+1 端点)。** 网页助手的线程活在服务端(Redis,两
小时),比页面组件活得久。页面此前从不读它,于是员工回到一个空框、打「帮我提交」,
助手从一段屏幕上没有的上下文里回答。这个端点连**还没确认的提案**一起还原 —— 一个
没确认的提案活一小时,页面忘了它就是给员工留下一个既动不了也取消不掉的东西。

**`decide(payload_override=...)`。** 「缺一篇文档」类提案刻意不带正文(生成的正文
正是这套东西要挡的内容),而知识库拒绝空正文 —— 在此之前那些行只能被拒绝。现在
审阅人在抽屉里写下正文再接受,**只允许覆盖提案已经声明过的键**(填空,不是任意
payload 通道),合并后的 payload 落库,审计记下哪些字段是人写的。


## 网页 IT 求助和飞书 bot 现在是同一个助手 (2026-09-03)

**要修的是什么。** `/me/support` 自 2026-08-17 起就是 IT 求助的正门 —— 聊天框*就是*
那个页面 —— 而它一直在裸调模型内核:

```python
result = await run_copilot_turn(db, person, history)   # 就这一行
```

没有 propose 工具,所以它**提交不了任何东西**;没有只读工具桥,所以它够得着 14 个
工具而不是 150 个;没有服务端接地,所以它会对着一篇已发布的文章回答「知识库里没有」;
**没有对话记录**,所以管理端、知识缺口队列、提交漏斗、以及 #3409 的评测题库里,
**看不到这个页面上说过的任何一句话**。同一批员工、同一个产品,飞书那一侧五样全有。

这些洞每一个都已经被诊断和修复过一次了 —— 修在
[`app/assistant/conversation.py`](../../server/app/assistant/conversation.py) 里,修在
当时正在检修的那个渠道上,而第二个面**静悄悄地保留了旧行为**。

### 一条 turn,两个渲染面

[`web_turn.run_web_turn`](../../server/app/assistant/web_turn.py) 与 `run_turn` 逐步
对应:同一个 preflight、同一批工具、同一批闸、同一个 transcript writer。两处不同的
地方都写了注释说明为什么 —— 这两个函数之间**没有解释的分叉**正是当初两个面漂开的
方式。

| | 飞书 | 网页(#3413 之前) | 网页(现在) |
|---|---|---|---|
| 服务端接地(回购/KB/流程注册表) | ✅ | ❌ | ✅ 同一个 `build_preflight` |
| 软件负责人确定性直答 | ✅ | ❌ | ✅ |
| 只读工具桥(150+) | ✅ | ❌ | ✅ |
| 能提议真流程 | ✅ 卡片 | ❌ | ✅ 内联确认卡 |
| 安全答案库(#3412) | ✅ | ✅(装在共享内核上) | ✅ |
| 对话记录 / 知识缺口 / 漏斗 / 评测 | ✅ | ❌ | ✅ `channel='web'` |
| 答案可评价 | ✅ 卡片按钮 | ❌ | ✅ 同一张表 |

**新增的抽象:`build_preflight`。** 三路接地(回购政策、KB 命中、审批流程注册表)
以前在两处调用点内联、近乎重复,而第三个调用者一样都没有。现在是一个
`Preflight` dataclass:`system_context` / `docs` / `policy_conflicts` /
`kb_prefetched` / `drop_history` / `evidence_blocks`。渠道靠**要**它拿到接地,
而不是靠记得抄二十行。

> 🔴 **第四路接地:编号(`app/assistant/identifier_grounding.py`,2026-09-08)。** 问题里出现
> **云账号号码 / 订阅 id / 云资源 id / IP / 域名 / 资产序列号**时,服务端先把它查成行,再交给模型。
> 起因是生产上一句「380838443567 这个云账号谁负责」被答成了两条**审批流程**的负责人 ——
> 「谁负责」命中 `flow_grounding._ASK_PATTERNS`,60 条流程进了 prompt,模型从一份名字很像的
> 现成清单里挑,而那个号码它一次都没查(`list_cloud_scopes.search` 匹配 `external_id`,查得到)。
>
> 三条纪律,改这个模块前先读:
> ① **RBAC 不写第二遍** —— 查询走 `mcp_bridge.call_readonly_tool()`,以**提问者本人**的身份跑
> 真实只读 MCP 工具,门在工具体里。在这里写 SQL 就是一条静默正确的越权读路径。也不给
> 「行存在但你看不见」这类提示(那在泄露存在性):看不见 = 查不到。
> ② **纯认领的问句才撤掉审批流程清单** —— 两份都像答案的材料摆在同一轮里就是让模型再猜一次。
> 但混着问的不撤:「这个账号怎么申请权限」里,那一行是「哪个账号」的答案、流程清单是「怎么申请」
> 的答案。判据是 `flow_grounding.asks_how_a_process_works()`,它和 `looks_like_a_how_do_i_question()`
> 共用**同一份词表**(`_PROCESS_ALTERNATIVES` + `_IDENTIFICATION_ALTERNATIVES`,并集算出来)——
> 两条手写长正则一定会漂移,而漂移的症状是某一类问句静默地再也拿不到流程清单。
> ③ **查不到就什么都不加** —— 返回 `None`,既有路径(流程接地 / 转人工 / 工单)逐字不变。
> 这是它能安全上线的前提,也是那条护栏用例(「开通云账号要走什么流程」仍拿到流程清单)在守的东西。
>
> 加一类编号 = 往 `TOOLS_BY_KIND` 加一行 `(工具名, 收窄参数名)`,并且 `test_every_mapped_tool_is_still_read_only_callable`
> 会验证这个名字仍在只读目录里、这个参数仍然存在(工具改名会让这一类编号**静默地永远查不到**)。

**新增的抽象:`offer_delivery`。** 这是两个面唯一真正不共享的一寸 —— 飞书推一张
交互卡片,浏览器把 offer 挂在它本来就在等的那个 HTTP 响应上。这一寸就是网页那一侧
**压根没有写路径**的全部原因:propose 的 dispatcher 直接调 `channel.send_card`,
而那需要一个 `open_id`,浏览器没有。缝故意开得很窄:再宽一点(每渠道一个
dispatcher、每渠道一个 proposal service)就是「员工能提交什么」这个问题的第二个
答案。

### 确认这一步:一个和被删掉的那条路形状不同的路

[`app/assistant/router.py`](../../server/app/assistant/router.py) 的模块文档记着一条
硬结论:**确认页不能提交任何东西,by construction**。第 6–9 轮复审每一轮都调整
「谁可以 POST 那条按 token 寻址的确认路由」,每一次调整都产出下一个缺陷,因为问题
本身就是无解的 —— 一个从**可转发链接**打开、跑在一个**证明了拿不住 Helix 会话**的
浏览器里的页面,没法知道读它的人是谁。

`POST /assistant/proposals/{id}/confirm` 不是那条路复活:

* **没有 token**,调用方只报一个 proposal id;
* **有真会话**,因为 `/me/support` 本来就在登录后面 —— 行为人是通过身份验证的那个人,
  而不是「谁打开了链接」;
* 归属在 `confirm_and_execute_for_person` 里对着那个人重新核 —— 这是飞书卡片按钮
  调的同一个函数,而且这里比它更严:那条路的行为人身份来自回调 **body 内容**。

⚠️ **这个端点拒绝 PAT,而全平台只有这一个端点这么做。** `get_current_user` 到处都
接受 PAT,这对其它每一个写操作都是对的 —— PAT 是员工把自己的权限委托给脚本。它对
这一个调用是错的:proposal 的全部意义是「一个**人**读过将要提交的内容并点了头」。
拿着员工 PAT 的 AI 客户端否则可以先让助手准备一件事,再代员工批准它 —— 确认这一步
就成了它对自己履行的手续。助手自己的工具面够不到这条路(只读桥是只读的,也没有
confirm 工具),所以这条拒绝不花掉任何已交付的能力,它只是在有东西长进来之前把路
封上。

那条不变量的回归测试(`test_review_round_1.py`)因此从「这个 router 上一条 POST 都
不能有」改写成它真正要守的性质:**没有任何路由可以拿 token 当行动许可,且每一条
POST 都必须要求一个登录的人**。

### 上下文归服务端(一处行为变更)

`POST /support/copilot/chat` 现在收**一句话**,不收整段对话。模型看到的历史按人
存在 Redis 里(`chat_id = "web:<person_id>"`),和飞书完全一样;页面显示的那份只是
显示。以前后端把页面回传的整个数组直接喂给模型 —— 在这个面有了写路径之后,「调用
方提供助手记得什么」就是一个可以往助手嘴里塞话再据此行动的位置。
`corroborate` 仍然会拒绝把没查证过的 id 放上确认卡,但更便宜的修法是根本不收这个
输入。`messages` 字段保留,只为让部署窗口里还在跑旧 bundle 的页面不至于直接坏掉。

**转人工也跟着改了**:工单引用的是**服务端 transcript**,不是页面自己那份。页面可以
被塞任何东西;transcript 是助手真正说过的话,而引用别的东西等于递给人工一段从未
发生过的对话。

### 为什么没有配套的 MCP 工具

本仓的 MCP-sync 规矩是「每个新的自助 HTTP 流程同 PR 配 1:1 的 MCP 工具」。这个 PR
**不加 MCP 工具,而这是刻意的**:它没有新增任何员工能力,它把一个已有的能力搬到了
同一个渠道的第二个渲染面上。而 `confirm` 这一条,配一个 MCP 工具会直接拆掉上面那
段刚立起来的同意模型。`_REGISTERED_TOOLS_COUNT_HINT` 因此不动。


## 安全答案库接进助手,并复用同一个循环 (2026-09-03)

**要修的是什么,以及它为什么一直没被修。** 答案库的 71 条全部是按**客户安全问卷**
的口吻写的 ——「贵公司是否部署数据防泄漏(DLP)方案?」—— 而员工问的是「我能用个人
电脑写公司代码吗」。这句诊断不是新的:它就写在
[`security_faq_rag.py`](../../server/app/domains/auditor/security_faq_rag.py) 的模块
文档第一段,起因是一个员工说「这个搜索很蠢」。

员工端的安全 FAQ 页为此长出了混合检索(RRF + 成文 + 引用校验)。**而助手那一侧
什么都没发生** —— `search_security_faq` / `ask_security_faq` 只作为 MCP 工具存在,
`app/assistant/` 下对答案库的引用是 **0 处**。于是员工在飞书问「我能用个人电脑写
公司代码吗」,助手翻 IT 知识库翻不到,然后给一件公司早就写下来的事开了一张工单。

### 一个工具,两个入口

`SECURITY_TOOL_NAME = "search_security_policy"` 装在**共享内核**
([`copilot.TOOLS`](../../server/app/domains/support/copilot.py))上,不是飞书那一侧
的 `extra_tools` —— 所以网页 IT 求助和飞书 bot **同时**拿到。装在渠道那一侧的话,
网页那一半仍然答不了安全问题,而那是同一个洞的另一半。

⚠️ **这是一个独立的工具,不是把答案库并进 KB 检索。** 两套语料的所有者、复核路径
和「答错的后果」都不同:一篇过时的 IT how-to 浪费十分钟,一条过时的安全表述是**以
公司名义**给出的合规答复。两边的提示词都因此写死了两件事:权限类问题(「能不能…」)
先走这个工具而不是 KB(「一篇 how-to 不是一条政策」),以及 `covered: false` 时明说
库里没有、绝不推理。

⚠️ **工具严格只读,而这是一个决定不是疏漏。** `ask(record_gaps=True)` 会往安全团队
的缺口台账写一行,但一个模型一轮里可能换着措辞调它三次(三条重复记录),而一个
「读」工具去写库正是本仓 savepoint 闸存在的那个形状 —— 一次失败的 insert 会在任何
东西解释它之前若干条语句把整轮事务毒死。信号没有丢:员工端的 ask 路径照常记缺口,
而下面那趟 lint 是批量读它的。

⚠️ **只返回 `covered` / `lead_answer` / `entries` 三样。** `ask` 还会返回置信度分层、
逐通道来源、rerank 标记 —— 那些是给页面的追踪面板看的,递给模型等于邀请它把检索
内部状态当成政策念给员工。

### 员工怎么问的(迁移 594)

`auditor_answer_library_entries.employee_phrasings` —— 与
`support_kb_articles.search_aliases` 同一个东西在另一套语料上的镜像。语义检索补的是
「同一件事换一种说法」里语义确实靠近的那部分;它补不了词表本身:「个人电脑」和
「BYOD」在向量上并不特别近,而条目里只写后者。

刻意**不复用 `tags`**:那是安全团队自己的分类关键词,把员工口语塞进去会同时污染
分类筛选和它在嵌入文本里的语义。

**这一列要接三处,少接一处就等于没接**,而三处各自承重:

| 接线 | 服务的路径 | 少了它会怎样 |
|---|---|---|
| `_term_hit(question_only=True)` | `ask`(助手工具 + 员工页) | 真正回答问题的那条路召回不到 |
| `_search_clause` | `list_faq` 的整串包含 | 整串命中了别的条目时 `total≠0`,词项兜底**永远不跑**,真正写着这句话的条目被整个跳过 |
| `_relevance_order` | `list_faq` 的排序 | 员工问法命中和「答案里顺带提到」同权重 |
| `embedding_text()` | 语义那一路 | 只写进列不进向量,语义仍然找不到它 |

员工问法在嵌入文本里**和问句放在一起、按同样的方式重复两次** —— 放到答案旁边会被
一段顺带提到十几个相邻话题的文字埋掉。

### 这个语料在循环里只做一件事

[`security_faq_wiki.py`](../../server/app/domains/auditor/security_faq_wiki.py) 注册
`security_faq` 适配器,**只接受 `add_aliases` 一种提案**,其余一律拒绝并说清该去哪儿做。

⚠️ **刻意没有 `new_article`。** 一个没有匹配条目的缺口,意味着公司对某件事**还没有
已发布的立场**。写那个立场是安全团队的工作、要走他们的复核、而且是以公司名义说的 ——
**一份生成的安全立场草稿躺在评审队列里,是这整套东西能产出的最危险的东西**。那种
情况本来就有归属:`auditor_security_faq_gaps` 是安全团队自己的待办,他们已经在做。

**lint 的输入是那个缺口台账**:真实问过、却没被答上的问句。对每一条,先检索出「本该
回答它的那条条目」,再让模型判「是措辞对不上」还是「公司没有这个立场」;只有前者才
产出提案。模型中途不可用就整趟停下 —— 猜出来的归属会把员工的问题挂到一条答不了它的
条目上,比搜不到更糟。

**接受提案时会关掉对应的缺口行**(按台账自己的归一化问句匹配)。不关的话,安全团队
看到的是一个只会长大的待办,而里面有一部分已经被人修好了。

### 面

`/admin/knowledge` 的「知识库自检」页签加了语料切换。两个语料的 KPI **不共用一套数字**
—— 知识库的健康度是「有没有一句话说明」,安全库的健康度是「有没有员工的原话」,
一套通用数字只能说得两边都不痛不痒。安全库那一格按 **employee_visible** 作分母:
员工看不到的条目不可能被他们搜不到,算进去只会把数字修饰得好看。

## 知识循环 —— 知识库此前只会长大,不会变好 (2026-09-03)

**要修的是什么。** 每一个新知识的来源 —— 工单结案、服务台 FAQ 导入、飞书 wiki
镜像 —— 产出的都是同一件东西:一篇**全新的草稿**。没有任何东西会去更新已有的
条目、合并两条讲同一件事的、补上员工真正会用的那个词,或者发现两条条目互相矛盾。

这正是这套语料**已知的召回天花板**背后的机制:整个语料写「电脑」从不写「笔记本」,
所以「笔记本电脑坏了」只召回一条勉强相关的。那不是排序算法的问题(词法检索那一层
已经调过一轮了),修法是有人把那个词加进去 —— 而在此之前,系统里没有任何地方能
承载「该加这个词」这件事。

### 一个队列,由人决定

[`app/core/knowledge_loop/`](../../server/app/core/knowledge_loop/) —— 机器提出的
每一个改动都落在 `knowledge_proposals`(迁移 593)。放在 **core** 而不是求助域:
安全答案库之后要用同一个队列,而域之间不互相 import;两份表意味着两个评审页、
同一个决定有两套说法、一个 bug 要修两遍。

**去重由数据库保证,不是先读后写。** `open_fingerprint` 在提案打开时等于内容指纹、
决定之后置 NULL,而 MySQL 的唯一索引允许任意多个 NULL —— 于是历史无限累积,而
「同一个建议同时开着两条」不可能发生。先读后写会输:每周的 lint 和一次工单结案可以
在同一秒里得出同一个结论。指纹**刻意不含理由和来源** —— 同一个建议被两件事各发现
一次,仍然只是一个建议。

**`target_ref` 是字符串,不是多态外键。** 真外键只能指向一张表,而这一行存在的
全部意义就是与语料无关。代价是目标可能在提案之下消失 —— 所以有 `superseded` 这个
终态(「没人做过这个决定」≠「有人决定不做」),而且落地前一定重读目标。

**落地包在 savepoint 里。** 一次合并要写三张表,而失败的 flush 会让**整个事务**
失活 —— 没有隔离的话,错误处理里那句「把状态标成 failed 再 flush」本身会抛
`PendingRollbackError`,把调用方一起带走、提案永远停在 open。这正是 2026-08-06
Zoom 坐席那次的 bug 类别。用的是仓里的 `app.core.txn.best_effort`;savepoint 回滚
会让实例过期,所以失败分支**重新读一遍提案**再写。

### 三样新东西

**目录**([`kb_wiki.index`](../../server/app/domains/support/kb_wiki.py))—— 语料
作为一个**列表**:一条一行,id + 标题 + 一句话说明 + 别名。判断新知识该进已有条目
还是新开一条、发现两条讲的是同一件事、找出没有别名的条目,都需要一百条同时在眼前,
而一百篇正文放不进任何地方。`summary` 可空,读的地方一律回落到正文开头 —— 目录必须
对**今天的语料**可用,而不是只对跑过摘要之后的语料可用(生产现在 124 条已发布条目,
0 条有摘要)。

**ingest 回写** —— 工单结案不再直接落一篇新草稿,而是先问目录「这已经写下来了吗」,
常见的结果是给已有条目补一个别名或追加一节。**没配模型后端时退回到今天的行为**
(一篇新草稿):一个没有 LLM key 的部署不能因为这次改动而悄悄不再吸收知识。
⚠️ **模型指了一个不在候选集里的 id,不算一个决定** —— 那是它在命名东西,按它去改
一条我们从没递给它的条目,是这套东西能造成的最大破坏。这条有专门的守门和测试。

**互链**(`support_kb_article_links`)—— 有类型的边:`related` / `supersedes` /
`contradicts` / `derived_from`。对称的关系(related / contradicts)**只存一行**,
方向规范化:两行表示一个事实就是两行可以互相矛盾,而只查一个方向的读者会看到半张图。

### 自检:把「有洞」变成「点一下就修」

[`kb_lint.py`](../../server/app/domains/support/kb_lint.py) 四趟,每趟修一个语料
已知的缺陷:重复与冲突(此前**从来没有任何东西把两条条目互相比过**)、缺词(把零命中
的员工问句回接到本该回答它的条目上)、失效关联(飞书镜像的消失清扫每次都会造出一批)、
缺摘要。

⚠️ **相似度只决定「给不给模型看」,从不单独产出提案。** 向量分不清「同一件事」和
「同一件事的反面」——「怎么连 VPN」和「VPN 连不上」相似度很高,是两条不同的条目。
所以配对交给模型分类,再交给人决定;**模型中途不可用就整趟停下**,而不是跳过这一对
继续跑 —— 没有分类器还往队列里塞东西,等于把队列填满噪声。

⚠️ **`PAIR_SIMILARITY_FLOOR = 0.86` 目前是没有量过的**。代码刻意安排成不依赖它的
准确性:它只决定候选,后面还有模型和人两道闸。第一次真实运行的接受/拒绝比才是该拿来
调它的东西 —— 在那之前它是一个起点,不是一个结论。

**回填**:员工点过「有帮助」的助手回答走同一条「这已经写下来了吗」的路;评测失败里
判为 `kb_doc` / `retrieval` 的两类转成提案(**其余三类刻意不转** —— 数据缺陷、动作
缺失、产品不支持,写一篇文档只会把真问题盖住)。`kb_doc` 那条**刻意不带正文**:生成
的正文正是这套东西要挡住的那种「读起来很像、其实没依据」的内容。

### 检索这一侧只动了一处

语义命中之后跟着 `related` 边扩展一跳 —— 一次额外查询,零模型调用。它针对的场景是
答案被拆在两页上(一页制度、一页表单),而检索只可能召回其中带着员工用词的那一页。
**只跟 `related`**:跟 `contradicts` 等于把语料自己知道是错的那篇递给模型,
`supersedes` 指向的是刻意退役的东西。

⚠️ **刻意没有做的事:把「读目录再决定翻哪几页」放进员工的那一轮。** Karpathy 那套
用读目录替代向量检索;这里向量检索已经在工作、成本是一次嵌入调用,而读目录要多一次
模型往返 —— 加在每个员工的每个问题上。所以目录只用在批处理路径(ingest / lint / 去重)
上,那里它是免费的。这一跳到底值不值,是评测台该回答的问题,不是该争论的问题。

### 面

`/admin/knowledge` 的新页签「知识库自检」。闸与知识库页签同源但**去掉 management** ——
接受一条提案改变的是每个员工被告知的内容。批量接受只对「补说法 / 建立关联」这类
不会让条目变错的改动开放,而且确认框会说清这一批里有几条会下架已发布的内容。
镜像条目的接受按钮**禁用并给出原因**(飞书是它唯一的编辑面)。11 个端点 +
4 个 MCP 工具(580 → 584)。

### Scheduled jobs (1 — migration 593 seeds)

| Key | Cadence | What it does |
|-----|---------|--------------|
| `knowledge.loop_lint` | 周一 04:10 Asia/Shanghai | 四趟自检 + 回填,全部产出为待人工确认的提案,不自动改动任何已发布内容。排在助手回归评测(03:30)之后,这样评测判出来的语料类失败当天就能被提回队列。 |

⚠️ 与既有的 `support.kb_gap_digest` **互补而不重复**:缺口周报回答「该写哪篇**新**
文档」(语料里根本没有的东西),这道 lint 回答「已有的这些**哪里不对**」。
⚠️ 「跑完了但一条提案都没产出」**不是失败**(多半是语料干净);中途模型不可用**是**
失败(这一轮没跑完,而下一轮会从头再来)。

## 助手评测台 —— 把「它答得好不好」从轶事变成可重复的数 (2026-09-03)

飞书 IT 助手已经跑在生产上,但在此之前它的质量只有两个信号:员工按的那个
👎,和有人偶尔在管理页手打一句话试一下。前者只覆盖「糟到有人愿意点一下」的
那一小撮;后者不可重复、不可比较、也从来没有人记下来过。**换句话说:这个产品
的质量此前没有被测量过,只被投诉过。**

四张表([`app/assistant/evaluation/models.py`](../../server/app/assistant/evaluation/models.py),
迁移 592):`assistant_eval_suites` / `_cases` 是规格,`_runs` / `_results` 是测量。

- **一次「尝试」= 一条用例问一次**,走
  [`simulate_employee_turn`](../../server/app/assistant/conversation.py) ——
  飞书生产提示词、只读桥、动作发现、提案校验全都是真的,唯一被替换的是**干跑
  派发器**:发不出卡片、建不了申请、不写任何员工可见的记录。有一条测试直接断言
  跑完之后 `assistant_conversations` / `assistant_messages` 仍然是 0 行。
- **判定分两半,顺序不能反。**
  ① [`checks.py`](../../server/app/assistant/evaluation/checks.py) 是**不问模型
  就能确立**的事实:回答里的 URL / uuid 在不在本轮任何工具结果里、语言对不对
  (假名是中日唯一的分辨依据)、有没有内部占位符泄漏、出题人写的必须/不得出现
  是否满足。② [`judge.py`](../../server/app/assistant/evaluation/judge.py) 是模型
  评委,给 6 个维度打 0–5 分并分类失败原因。**评委对①的结论不知情**(盲判),
  因此两半是独立的,而一条 `blocking` 的确定性发现**评委翻不了案** ——
  一个编造出来的链接在手机上和真链接长得一模一样,而评委没有任何办法去查。
> 🔴 **第三个套件:`object_lookup`「对象定位」(2026-09-08,6 条,新增类别 `CAT_OBJECT_LOOKUP`)。**
> 员工点名了一个具体对象(账号号码 / 实例 id / IP / 域名 / 序列号),答案必须是**那个对象**
> 的信息。单独成类是因为它的失败方式独一无二:**回答看起来完全合理,只是说的是另一个东西**
> —— 生产上「380838443567 这个云账号谁负责」被答成了两条同名审批流程的负责人,每一句话都对,
> 合起来全错。评委很容易放过这种答案。
>
> 所以这一套的判据是**确定性**的:`flow_grounding` 的那几句措辞(「尚未本人确认」/
> 「平台根据审批记录推断」)只由那一个模块拼出,出现在一条「某个具体对象归谁」的回答里
> 就等于发生了替换 —— `must_not_mention` 直接判红,不依赖任何一行具体数据存在。
> `test_object_lookup_cases_keep_their_deterministic_guard` 守着「每条带编号的用例都必须带这条判据」。
>
> **为什么是新套件而不是往回归套件里追加**:`seed_builtin_suites` 对已存在的套件追加的用例
> 一律落 `draft`(刻意的 —— 悄悄变宽的闸会移动这个部署自己的基线),而 draft 不跑。
> 新套件首播全部 `active`。加一整类新用例时,这是唯一能让它当天就真的跑起来的形状。
>
> 套件里第 6 条是**护栏**:「开通云账号要走什么流程」句中没有编号,那份流程清单正是它的
> 答案 —— 把编号接地的「查到就撤掉流程清单」写宽一点,这一条立刻转红。

- **失败分类与知识缺口台账共用同一套词表**(`kb_doc` / `retrieval` /
  `tool_field_or_data` / `action` / `out_of_scope`,见 `gap_verification.py`):
  一条评测失败和一个员工的 👎 是同一类东西 —— 一个助手答错的问题 —— 必须落进
  同一个待办清单,而不是用两套词说同一件事。
- **`pass_rate` 的分母不含 `error`。** 模型后端抖一下不是「助手答得差」;把它
  算进失败,一次故障就会在趋势线上长得和一次质量回归一模一样,而这两件事要找的
  人完全不同。
- **每条用例默认问 3 遍,界面推 6 遍。** 一遍问出来的通过率是样本量为 1 的估计;
  `cases_flaky`(多遍结果不一致的用例数)往往比通过率本身更有用,而它在 `repeats=1`
  时完全不可见。
- **续跑是同一条代码路径。** 待办 = 全部 `(用例, 第几遍)` 减去已经有结果行的那些,
  所以被发布打断的运行点一下「继续」就接着跑,已跑的不会重跑(唯一约束
  `uq_assistant_eval_result_attempt` 是这件事在数据库那一侧的保证)。预算用完是
  正常结局 `partial`,不是失败。
- **每次尝试跑完立刻单独提交**(各自的 session)。一次运行是几十分钟到几小时的
  付费调用,在第五十分钟因为一次重启全丢会让人不敢再跑它。

**为什么回答正文整段存下来。** 本仓其它与助手有关的台账都只存「形状」不存原话
(`gap_verification.TurnShape`),因为那些行是真实员工的提问。评测行不是:题目是
IT 自己写下来的,以模拟身份、走干跑路径问出来的。判一个回答好不好就得读它。
**唯一的例外是从生产语料挖出来的问句** —— 它们一律以 `draft` 落库、入库时先过
一遍脱敏(邮箱 / 手机号 / uuid / 凭据形状的串),由人审过才启用;那次审阅才是
真正把别人的话摘出去的地方。

**同族偏置是被承认的,不是被假装不存在的。** 评委和被评的是同一个供应商(平台
只有一个 LLM 连接器)。三件事限制它的危害:确定性那一半不需要评委许可就能判红;
接地性 / 准确性两个硬维度的地板由**服务端**兜(`combine_verdict`),而不是信评委
自己的 `verdict` 字段;控制台把失败连同完整原文摆出来,人工抽查只要点一下。
**这套东西量「变化」是可靠的,量「绝对值」要打折看。**

**面**:`/admin/assistant-eval`(概览 / 运行记录 / 题库三个 tab),读闸是
`global_admin` + `auditor` —— 刻意**不用** `require_admin_read()`,那个工厂还admits
`management`,而评测行携带完整的助手回答,与聊天记录同一类内容;前端 `RoleRoute`
带 `excludeManagement`,MCP 侧另有一个更窄的 `_require_assistant_eval_read`,三处对齐。
22 个端点 + 4 个 MCP 工具(574 → 578)。

### Scheduled jobs (1 — migration 592 seeds)

| Key | Cadence | What it does |
|-----|---------|--------------|
| `assistant.eval_regression` | 周一 03:30 Asia/Shanghai | 跑一遍回归题库(默认每条 3 遍),与上一次终态运行对比;通过率掉超过 10 个百分点**且可比**时告警 IT 并点名回退的用例。顺带收尾被发布打断、停在 `running` 超过 6 小时的运行。 |
| `assistant.eval_object_lookup` | 周一 04:40 Asia/Shanghai | 跑「对象定位」套件(迁移 630)。**同一个 `handler_key`、第二行 cron**——那道 handler 本来就按 `config["suite"]` 选套件,而 `scheduled_jobs` 把 `name` 与 `handler_key` 分成两列正是为了这个,所以零行新代码。排在回归评测之后而不是同时:两跑占用员工同一个模型后端。⚠️ 新套件的用例首播就是 `active`,但**没有这一行它一次都不会跑** —— 每周那道任务只跑一个套件。|

⚠️ **「跑完了但通过率很低」不是 handler 失败**,那是产品变差了,走告警;handler 只在
「这一跑压根没跑起来」时 raise(没配模型、题库空了、执行崩了)。混为一谈会让一次
内容回归看起来像一次基础设施故障,而这两件事要找的人完全不同。

⚠️ **告警要求 `comparable`**:两次运行的尝试数都 ≥ 20 才比。样本太少的一次运行
「掉 20 个点」可以什么都没发生,而一条会误报的告警几周之内就会变成没人看的告警。

**新原语**:[`copilot.one_shot_json`](../../server/app/domains/support/copilot.py) ——
provider 无关的**schema 约束** JSON 补全(bailian 走 `chat_json`,anthropic 走系统提示
+ 本地解析)。评委需要的是一个结构化答案而不是一段话;`copilot.active_llm_identity`
回报当前 `(provider, model)`,记在运行上,这样趋势线上的一个台阶可以先归因到换模型
而不是当成质量回归去查。

## 飞书缺权限时返回 `code: 0` 加**字段缺席** —— 只看状态码的健康检查会一路全绿 (2026-09-01)

接 CBG 通讯录时开权限走了四轮,`GET /contact/v3/users/{open_id}` **每一轮都是
`code: 0 success`**:

| 轮次 | 返回的字段 |
|---|---|
| 起始 | `open_id` / `union_id`,仅此而已 |
| +`contact:contact.base:readonly` | 能列部门和人,但部门只有 `open_department_id` |
| +`contact:user.base:readonly` | 多了 `name` / `en_name` |
| +`contact:department.base:readonly` | 部门有了 `name` |
| +`contact:user.employee:readonly` 等 | 才出现 `enterprise_email` / `custom_attrs` / `employee_no` / `status` |

**没有任何一轮报错。** 一个断言 HTTP 状态或 `code == 0` 的 `test_connection`
会在前四轮全部判定「连接正常」,而那时它一个员工都读不出来。

两条可复用的判据:

1. **驱动的健康检查要断言「一条真实记录里有没有这些字段」**,不是断言调用成功。
   本仓 `FeishuPartnerDirectoryDriver.test_connection` 就是这么写的 —— 拉一条真
   用户,看 `name` / `enterprise_email` 在不在 body 里。
2. **飞书有两套彼此独立的权限,失败形状不同**:API 权限缺失 = 字段静默消失;
   **数据权限**(开放平台后台的「通讯录权限范围」)缺失 = `40004 no dept
   authority`。**加 API 权限治不了 40004**,反过来也一样。排查时先看
   `GET /contact/v3/scopes` 返回的 `department_ids` 有几个 —— 空的就是数据权限
   没放开。

顺带:老的伞形 scope `contact:contact:readonly_as_app` 在现在的开放平台后台
**搜不到了**(搜索结果 0 条),只能逐条加细粒度权限。逐条清单见
[`feishu_partner_directory.py`](../../server/app/integrations/drivers/feishu_partner_directory.py)
的模块文档。

## `delivered` 证明「送到了」,不证明「送对了」—— 取证要有第四层 (2026-08-31)

离职告知信发布时做了三层取证:迁移 head 到位、新列建出、`notification_outbox` 逐行 `delivered` 且双渠道。**三层全绿,而收件人拿到的邮件里少了一整段话。** 原因是 `email_layout._render_body_html` 用 `drop_url in stripped`(**子串包含**)决定丢哪一行,于是任何正文句子只要提到了这封信自己的 CTA URL 就被整行删掉 —— 那封信的「2. 公司资产归还」正文里点名了 `https://helix.pingcap.net/my/requests`,而那正好是 CTA 目标,于是收件人看到一个光秃秃的编号标题、下面什么都没有。

**为什么三层取证看不见它**:渲染残缺不会让任何 send 失败,也不写进任何状态列。`status='delivered'` 是投递层的回执,它对内容一无所知。**判据:凡是「内容由模板 + context 在发送时拼出来」的链路,取证必须有第四层 —— 把渲染后的成品拉出来看一眼**(本地 `render_email_shell(...)` 断言一个关键词在不在,或对生产 outbox 行的 `body_html` 直接 grep)。三层只能证明「有人收到了一封信」。

配套的两条渲染器事实(改之前踩的):

- **飞书卡片 markdown 原生渲染 `[文字](url)`,邮件那一侧此前不渲染。** 卡片会渲染这件事有独立证据:`me_universe/urge.py` 专门把用户填的标题里的 `[](){}*` 剥掉,注释写明是防 `[紧急·点此](https://evil)` 变活链接。两边不一致会逼模板作者在正文里塞裸 URL —— 而**飞书客户端 autolink 裸 URL 时会把紧跟的 `)` 一起吞进 href**,`(https://x)` 于是渲染成 `(https://x`,右括号凭空消失,看起来像文案漏写。现在邮件侧也渲染锚文本,但走**主机白名单**(比卡片更窄):模板正文含用户填的 context 值,不能把卡片那个已知隐患原样引进第二个渠道。
- **在多趟字符串替换的管线里,「先替换」不保护你的产出。** 邮件锚文本第一版把 `<a href="…">` 直接内联,理由是「链接先替换所以 href 安全」—— 错的:后面的 `**` / `[b]` 扫的是整个字符串,URL 里带 `**` 就会把 `<strong>` 拼进 href 中间。正解是把产出提到 opaque slot(`\x00a{n}\x00`)里,等强调替换跑完再放回去。**先跑不保护任何东西,在那几趟里不在场才保护。**

## SF 里「离职审批已完成」是可读的 —— 别再假设离职只有最后工作日那一个信号 (2026-08-31)

**已经存在,别重新问**:判断一个人「离职审批批完了、但还没到最后工作日」不需要新集成、不需要 webhook、不需要飞书审批台账 —— SF 现有的三个字段就够,`app/hr_lifecycle/pending_leaver_notice_service.py` 已经在用:

> `User.status == 't'`(仍在职) **+** `EmpEmployment.endDate` 在未来 **+** 存在 `EmpEmploymentTermination` 行(带 `customString85` 离职原因)
> ⇒ **审批已完成、尚未到最后工作日**

2026-08-31 在两个生产租户(`api30.sapsf.cn` / `api55.sapsf.eu`)实测:审批一通过,SF 立刻写 `endDate` + 离职原因行;`User.status` 要到最后工作日**过了**才翻 `'f'`。当天 CN 租户命中 6 人,Helix 侧他们 `employment_status` 全是 `active`、`termination_date` 全是 NULL。客户端方法现成:`list_termination_employments(start, end)` / `get_users` / `get_termination_reasons_for_users`。

**为什么值得单独记**:整个离职域此前把「SF 把人翻成 `'f'`」当作**唯一**的离职信号,于是所有下游都被结构性地推迟到最后工作日 —— 生产 87 条 `OffboardingProcess` 里 **80 条的 `created_at >= leaver_date`**。任何「在通知期里做点什么」的需求(提醒、交接、告知、盘点)如果挂在 `OffboardingProcess` 上,都会得到「流程还不存在」这个答案,然后被误判成「HRIS 给不了这个信号」。**给得了。**

🔴 **量「触达」时的两个坑,这两个我都踩了,并因此给出过一个反向的结论(2026-08-31)。**

- **别用 `related_resource_type='offboarding_process'` 去 JOIN 枚举「离职域通知」。** `leaver_reminder` 和 `leaver_escalation` 的 `related_resource_type` 是 **NULL**,这个口径会**静默丢掉 43 行**,其中包括 **25 行全部 `delivered`** 的 T-7/T-3/T-1 提醒 —— 也就是通知期内最主要的那条触达链。用 `template_key LIKE 'offboarding.%' OR template_key LIKE 'leaver%'` 枚举。「表里没有」和「没发生」是两件事,而 JOIN 口径让它们长得一模一样。
- **`register_scheduled_job(description=...)` 不是管理台读的那一栏。** `/admin/scheduled-jobs` 读 `scheduled_jobs.description`,那一列**只在迁移播种时写入**;没有任何 boot-time reconcile 会用 registry 的值覆盖它(`update_scheduled_job` 里那两行赋值是管理员 PATCH 路径)。所以改 `handlers.py` 里的 description **不会**改变管理员看到的文字,反之管理员看到的也不是代码里那段。要改生产上的描述只能改数据。

同时记一个已核实的**反面**事实,免得下一个人重复我的错:**上级在通知期是有触达链路的,不是零。** `offboarding.handover_assigned` 被**刻意放在 `dispatch_post_commit_actions` 的 future-leaver defer 闸之上**(注释原话:the whole value of telling the lead is the notice period),生产 10 次里 8 次发给直属上级、3 次在 LWD 之前;`offboarding.process_started_manager` 39 次里 38 次给直属上级、3 次在 LWD 之前;`leaver_reminder` 25 行全部 delivered。真正的限制是这三条**都要求 `OffboardingProcess` 已存在** —— 与员工侧同一个根因,不是第二个缺口。离职者本人在通知期的实测触达是 **87 条流程里 6 位(仅 2 位 delivered)**。

三个不能省的配套判据:

- **必须要求离职原因行存在。** 光有未来 `endDate` 不够 —— 那道闸挡的是「合同/实习期计划结束日」这类非离职终止。实测在职实习生 9 人全部 `endDate=None` 且无离职原因行,这正是敢把实习生纳入告知范围的前提;哪天 SF 改了写法,这道闸是唯一的保护。
- **必须过 `excluded_termination_reason_codes`**(HRIS 连接器 config,D03 调往中国 DC / D04 兼岗结束 / D07 人员分类转换 / D08 调往德国 DC)。这些人**写了离职原因行却没有离职**,与全量/增量同步共用 `normalize_reason_codes`,别另起一套。
- **full-sync 里 `user_status == 't'` 分支那句 "endDate is historical" 只对过去的 endDate 成立。** 那条分支的职责是「别把在职的人翻成离职」(对的,不要动它),但它不区分过去与未来的 `endDate` —— 读它的时候别把它当成「SF 不提供未来 endDate」的证据。

> **汇联易手机号:唯一约束字段上的「降级而不是变红」(2026-08-27;0 迁移 / 0 API / 0 cron / 0 MCP / 0 RBAC)。** 手机号原先故意不推(汇联易侧**租户唯一**,一个被占用的号会让整个人的更新变红);这一版补齐降级路径后纳入推送。**降级按证据分两套,不许混用**:手机号的拒绝厂商**有错误码**(120206 不合法 / 120211 已占用 / **12002008 已占用并报出占用者工号** / 120223 国家编码规则不存在)→ 按**码**丢弃 `mobile`+`countryCode` 并重试一次;直属领导**没有**错误码 → 保持「去掉它重试一次」的**探针**。两者各触发一次、最坏 3 次调用、不递归;必填字段(companyCode/custDeptNumber)永不牺牲,仍走 `blocked`。厂商原话落在台账行的 `slot_outcomes.vendor_error` 上 —— 12002008 那句话本身就是修复指引。
>
> **两个字段语义,踩过就知道**:① `mobile` 是**不含区号**的裸号,`phone_digits` transform 只去分隔符(JP「90-1234-5678」、US「(415) 555-1234」)、**故意保留** `+`/`00` 让 runner 拒绝 —— 在唯一约束字段上把区号焊进国内号可能正好撞上另一个真人;② `countryCode` 是 **ISO-2 国家码而非区号**,且**缺失 = 按中国大陆处理并跳过校验**,所以不发它会把 10 位美国号静默存成中国大陆号。
>
> **`identity_persons.mobile_country_code` 是混形态列**:多数 ISO-2、一部分 ISO-3(IDN/POL/NLD/THA/VNM…)、两个人是自由文本 `GB / UK`、**21 人 NULL 且全为外籍**。故新增派生 token **`mobile_country_iso2`**(`resolve_country_iso2`:ISO-2 直通 → ISO-3 查表 → 否则空;电话国家优先、**工作国家回退**),`COUNTRY_ISO3_TO_ISO2` 同时收敛到 `mapping.py` 单一副本(presets 改为 re-export)。**上线前生产实测**:411 人在范围、410 有号、**撞号 0**、内部重号 0、带前缀 0;234 填空白 / 170 已一致 / 6 替换(2 个厂商现值是手工垃圾串)。测试 [`test_huilianyi_mobile_2026_08_27.py`](../../server/tests/test_identity_sync/test_huilianyi_mobile_2026_08_27.py)(17)+ 两处变异验证。

> 🔴 **安全 FAQ 的检索会说自己有多确定,而且弱召回不交给模型(2026-08-28;0 迁移 / 0 端点 / 0 cron / 0 MCP 增减)。** 三件事,都不加一次调用:① `confidence_tier(channels, cosine, lexical_rank)` —— **两路都命中 = high**,压过任一路自己的分数(词法与嵌入不共享机制,同时浮出同一行不是任一方能制造的巧合);仅语义按余弦 0.65 / 0.5 分档;仅词法且排名 ≤3 = medium,更深 = low(深处的词法命中就是用词碰巧重合)。② **成文闸**:`best_confidence == low` 时**根本不建 LLM 客户端** —— 让模型总结它自己判为弱相关的材料,正是它开始伸展的地方,而这里伸展出的一句话会被当作公司规定;页面必须**明说**为什么没有引导语,未解释的缺席看起来像功能坏了。③ **重排**:成文那次调用顺带返回 `relevant_ids`,服务端据此筛掉不对题的邻居(RRF 分不出「最近」和「对」,尾部稳定混噪声)。两道安全绳:筛完为空则整个重排作废,以及 `cited_ids ⊆ relevant_ids`(否则引用 chip 指向一条已不在页面上的条目)。
>
> 🔴 **在这条路径上,用 `raise AssertionError("不该被调用")` 当探针证明不了任何事** —— `_compose` 按降级契约捕获所有异常,断言被吞掉、测试照常绿。**两处自写的用例因此是假绿的,是变异验证抓到的**(删掉成文闸,用例仍绿)。一律改成计数器 `calls == []`。同类改动还暴露出:`compose=False` **不**关掉零召回时的 id 挑选器 —— 那是对的(只挑 id、不写句子,且没有向量的部署上它是唯一能回答的),所以改断言不改行为。
>
> **明确没做**:查询扩展(嵌入本身就是「换个说法」,扩展主要救词法,而词法已在做 CJK 二字滑窗;手写同义词表有对着想象写的风险);多来源语料(`auditor_evidence_documents` 只有 title + description + S3 object key,**没有正文抽取**,拿它当第二语料库等于在标题上检索 —— 要做先得有抽取管线)。

> 🔴 **安全 FAQ 的检索是混合的(词法 + 语义),模型第一次被允许写字 —— 但只能照着召回到的条目写(2026-08-28;迁移 **561**(+3 列)/ **562**(+38 条种子)/ **563**(+1 cron);**+2 端点** / **+1 MCP → hint 533**)。** 员工的原话是「这个搜索很蠢」,而这不是权重问题:答案库 71 条全部按**客户安全问卷**口吻写成(「贵公司是否部署数据防泄漏(DLP)方案?」),员工问的是「我能用个人电脑写公司代码吗」—— 零公共子串,词法必然失败。
>
> **两个半修复,顺序别搞混。** ① [`security_faq_rag.py`](../../server/app/domains/auditor/security_faq_rag.py) 给条目建向量(迁移 561 加 `embedding` / `embedding_model` / `embedded_at`,**JSON 浮点数组 + Python 余弦**,与 `support_kb_articles` 同款、同理由:本地 MySQL 测试不用改,几百条精编条目全表打分比维护向量索引便宜),召回时**词法与语义两路并行,用 RRF(k=60)按名次融合** —— 不是「词法没命中才走语义」,那样一次勉强命中(问句里碰巧有「公司」)就永远看不到语义那一路更好的答案。语义余弦低于 **0.4** 不入融合(余弦总会返回**某一条**,只看名次分不出「最近」和「近」)。② 迁移 **562** 用**员工的问法**补了 38 条(8 条 featured —— 这个页面上线以来 featured 集合一直是空的,381/528 的 INSERT 不写这一列),事实只来自 381/528 已确立的立场 + Helix 真实存在的员工入口;超出的措辞以 `last_reviewed_at = NULL` 落库(沿用 528 的「待复核」约定)。**实测对照**(整条迁移链跑完的库,无 Key 即纯词法):10 个真实员工问法,**修前 4 个零结果、3 个命中错条目**,修后 **10/10 命中正确条目** —— 光是「换个问法重写一遍」就修掉了大半,语义那一路补的是剩下的长尾。
>
> 🔴 **接地闸:模型的散文没有引用就整段丢弃。** `compose_answer` 只拿到**这次召回到的条目原文**,必须回 `{covered, answer, cited_ids}`;服务端把 `cited_ids` 与召回集合求交,**交集为空(或正文为空)→ 整段丢掉,只列条目**。条目原文永远原样渲染在下面,前端把两者做成**视觉上不同的东西**(`.composed` 块 + 「AI 依据条目整理」标签 + 可点的来源 chip)。这是平台上唯一一处把模型散文渲染给 500 人看的地方,改它之前先想清楚谁为这句话负责。`covered=false`(召回到了、但都不对题)**记缺口台账** —— 这是旧形状看不见的一类缺口:召回几乎总能返回点什么,「给了他五条、没有一条是他要的」过去与命中长得一模一样。
>
> **降级链完整,每一环断了都还能用**:没配 AI/LLM Key → 没有向量 → 纯词法(改动前的行为);有向量但成文失败 → 只列条目;模型说答不了 → 记缺口 + 引导工单。因此 `semantic_ranked` 返回 **`None`(后端不可用)与 `[]`(搜过了,没有够近的)是两件事** —— ask 路径对两者降级相同,但管理台要靠这个区分「索引死了」和「真的没写过」。
>
> **索引会静默过期**:`pending_clause` 认三种情况(没向量 / `embedding_model IS NULL` / 模型不是当前 active —— 第三支必须显式写出 `IS NULL`,SQL 里 `col != :x` 遇 NULL 是 NULL 不是 true)。编辑条目**正文 / 标签 / 分类**时 `update_entry` 直接清空向量;**不是比时间戳** —— `embedded_at` 是应用时钟、`updated_at` 由写入向量的那条 UPDATE 自己写,比大小会让每一行永远 pending、每晚重嵌全库。改 `is_featured` / owner 不清。回填三条路:写入后 fire-and-forget(`_spawn_embed`)、`POST /auditor/security-faq/index/backfill`(管理台按钮)、cron `auditor.security_faq_embed_daily`(05:40,排在 05:10 的过时标记之后)。**种子迁移那条路径上没有任何应用代码可以挂钩**,所以 cron 不是冗余。管理台 `overview.index` 报覆盖率 —— 没有向量的条目只能靠字面被搜到,而这个缺口不产生任何一条错误日志。
>
> **MCP `ask_security_faq`(hint 532 → 533)**:之前「ask 故意不镜像」的判断在旧形状下成立(把零命中交给模型挑 id,而 MCP 调用方本身就是模型),现在不成立 —— **语义那一路 MCP 调用方复现不了**(它拿不到语料向量)。工具 `compose=False`(调用方自己会写)+ `record_gaps=False`(缺口台账的语义是「有人问了、我们没有」,agent 的探查不是那个信号;而 `mutates=True` 还会把离职过渡期的人挡在提问之外)。

> 🔴 **安全 FAQ:LIKE 元字符必须转义,以及员工端与平台自身态势可能不一致的那一处(2026-08-27;0 迁移 / +1 只读桶 / 0 API 契约变更)。** ① [`_like()`](../../server/app/domains/auditor/security_faq_service.py) 是**唯一**允许构造 LIKE pattern 的地方,转义符是 `/` 不是反斜杠(反斜杠要活过 Python 字面量 + MySQL 字符串解析,还随 `NO_BACKSLASH_ESCAPES` 改含义)。未转义时实测:`search=%` 返回全库 71 条、`a%b` 返回 63 条;而 `_LATIN_TOKEN` 允许下划线进词,`data_loss_prevention` 会连带匹配 `dataXlossXprevention`。新增任何 LIKE 都走这个 helper。
>
> ② **`stale_reason == backing_control_failing` 的条目仍然对员工可见,这是刻意的**,但 `employee_overview().at_risk` 会把它们单列出来。语义:`auditor.answer_library_freshness` 认定这条答案立论的事实不再成立,而客户问卷链路上「发出前有人看」这一步在员工端**不存在**——它已经发布了。不自动下架的理由有两条:控制项抖动会让员工页凭空少掉答案(比过时答案更糟),且多数条目是制度陈述、控制项变红并不使其为假。改这段代码前先想清楚要把「判断」交给谁。
>
> ③ **员工载荷不含 `unhelpful_count`**(`faq_view` / `_vote_view` 都不含)。公开累计的「N 人认为这条公司安全规定没帮助」读起来是对规定本身的公投,且出现在后续读者形成判断之前;计数归 `employee_overview().needs_rewrite`。投票按钮本身照常工作并回显调用者自己的那一票。
>
> ④ **提问会记录提问人身份**(`auditor_security_faq_gaps.last_asker_person_id`),员工端提示与三语手册**必须明说这一点** —— 页面同时声明「阅读不写审计」,两句话放在一起时,漏掉后一句就变成了误导。

> **汇联易的写字段名和读字段名不是一套,而「未知键」是静默丢弃(2026-08-27;0 迁移 / 0 API / 0 cron / 0 MCP / 0 RBAC)。** 汇联易「主岗直属领导」在生产上对 Helix 建的每一个人都是空的(报出来的是 P10019 陈建华;Authing 时代的行如 P0397 邱芳 → Nancy Wu 反而有值),根因不是同步失败,而是 `HUILIANYI_USER_MAPPING_RULES` 从来没有这个字段 —— 一条 2026-06 的旧决定(「审批走部门角色,不走逐人经理」)让 416 个在推人群里的 411 人在费控系统里没有直属领导。修法是把它做成人群级的映射规则,不是补一个人的数:新源字段 `{manager_employee_no}`(经理工号)由编排器**仅对 `huilianyi` 目标**注入,渲染进 `directManagerEmpoyeeId`。
>
> **两个必须原样保留的拼写。** ① `directManagerEmpoyeeId` 是**厂商自己的错拼**(Empoyee,少一个 l),是 创建员工V2 / 更新员工V2 的顶层「主岗直属领导工号」;拼写正确的 `directManagerEmployeeId` 是 `userJobsDtos` 里的**逐岗位**字段,放在顶层会被忽略。② 入职时间的**写字段是 `entryTime`**,而 `entryDate` 只是 增量查询员工 的**读**字段 —— 旧规则照着 roster 样本写成了读名,于是每次推送都带着一个厂商静默忽略的未知键,`入职时间` 在所有 Helix 推过的人身上一直是空的(生产实证:P10019 / P0989 的 `entryDate` 为 null,而 Authing 时代的 P0397 有值)。**这类 bug 没有报错、没有红灯、run 全绿** —— 只有拿生产快照对着字段表逐个核,或者在页面上看见那一格是空的,才发现得了。
>
> **两条安全边界。** ① 只有「汇联易确实有这个人」的经理工号才注入:`_resolve_pushable_manager_nos` 按目标自己的 filter 收(在职状态、P 号工号、非 HRIS 墓碑、非 HR 测试号段、非自己),解析不到就**不带这个字段** —— 更新V2 里字段缺席 = 保持原值,所以 Helix 永不把 HR 手工维护的领导抹成空(厂商的 `"[NULL]"` 清空哨兵一次都不发)。生产核过:416 人里 5 人无经理、0 人的经理在推送人群之外、0 人自指。② 厂商**没有**为「直属领导不可用」定义错误码,所以 runner 不猜 marker:被拒时**原样重试一次去掉该字段**,成功即证明是这个字段的问题 → 记 `slot_outcomes`(slot-degraded success,编排器据此清 `last_verified_at`,下一轮重试该 slot),失败即证明不是 → 原样返回**第一次**那条更有信息量的报错。一个可选字段不该让整个人变红。
>
> **漂移侧的读写别名**:`_HUILIANYI_READ_KEY_ALIASES = {"entryTime": "entryDate"}`。漂移比对拿渲染出的 payload 键去快照里查,写名在快照里必然查不到 → 会把入职时间降级成 `not_comparable`,即「刚开始推它,就同时停止审计它」。`directManagerEmpoyeeId` 没有读侧对应物(roster 端点不回吐主岗直属领导),仍是 `not_comparable`;要核它得逐人 `POST /api/open/user/all/directmanager`(返回 `mangerEmployeeId` —— 又一个厂商错拼),对快照全量扫描太贵,故只用于人工取证。测试:[`test_huilianyi_direct_manager_2026_08_27.py`](../../server/tests/test_identity_sync/test_huilianyi_direct_manager_2026_08_27.py)(17)。

> **安全 FAQ 的两个检索口径,以及它们为什么不能是同一个(2026-08-27;frontend + 1 处后端检索修复 / 0 迁移 / 0 API / 0 cron / 0 MCP / 0 RBAC)。** 页面上有两个输入框,**语义不同,不要合并**:`POST /me/security-faq/ask` 吃**问题**(整句自然语言),`GET /me/security-faq?search=` 吃**关键词**(筛选)。
>
> 🔴 **整句话从来不是答案的子串。** 「钓鱼邮件」这四个字在回答它的那条里根本不存在(条目写的是「防钓鱼」「钓鱼识别」),所以 `LIKE '%钓鱼邮件%'` 返回空 —— 拿生产种子库实测,**四个自然提问里三个返回「没有答案」**。ask 路径改用 [`query_terms`](../../server/app/domains/auditor/security_faq_service.py) 按词打分:拉丁按词切、CJK 切**重叠二字滑窗**(「钓鱼邮件」→ 钓鱼/鱼邮/邮件);命中**问题**的词 ×3、命中答案的 ×1;门槛**两个不同的词**(query 只切出一个词时降为一个 ——「MFA」本身就是完整问题)。放宽到一个词等于「公司」返回半个库,这条有测试钉着。列表筛选保持**子串**语义(筛选要精确可预期),仅在**子串命中为 0** 时回落到同一套打分。
>
> **`CATEGORY_LABELS` 必须对着真实清单维护**,不是对着一个合理的安全分类法。第一版凭想象写了 `bcp` / `vulnerability_mgmt` / `privacy` / `compliance` —— 四个键零命中,而真实存在的 `bcp_dr` / `data_privacy` / `identity_lifecycle` / `governance` / `risk_management` / `certification` / `vulnerability_management` 全部落到英文兜底,中文页面显示 "Identity Lifecycle"。测试 `test_every_category_the_library_actually_uses_has_a_label` 双向钉住(真实分类必须有标签 **且** 不许有对应不到数据的键)。加分类时同步这张表的三语。
>
> **前端**:[`SecurityFaqPage.tsx`](../../web/src/pages/me/SecurityFaqPage.tsx)(路由 `/me/security-faq`,无角色守卫 —— 服务端只返回对员工公开的条目),菜单挂在员工 `my-security` 组**第一位**;深链 `?entry=<id>` 把条目钉在库上方(它未必在当前页上);答案被撤下时提示已下架并清掉参数。管理端 [`AnswerLibraryTab`](../../web/src/pages/auditor/securityQuestionnaire/AnswerLibraryTab.tsx) 的「员工端」是**一列**(可见开关 + 常见问题星标 + 阅读/投票),并与「操作」一起 `fixed: 'right'` —— 这张表一直需要横向滚动,一个要去找才能找到的发布开关不会被用。[`EmployeeFaqTab`](../../web/src/pages/auditor/securityQuestionnaire/EmployeeFaqTab.tsx) 是安全团队看员工那一面的 tab(概览 + 缺口台账处置)。
>
> **「没找到」不接自建收件箱**:员工点「向 IT 提问」走的是既有的 `POST /api/v1/support/tickets`(前端两次调用,不跨域引用),进度在「我的求助」。缺口台账与工单**不互相替代** —— 台账决定下一条写什么,工单负责这个人现在怎么办。

> **安全 FAQ:答案库的员工面(2026-08-27;迁移 **560** = +5 列 / +2 表 / **+8 端点** / **+3 MCP → hint 531** / 0 cron / 0 新 RBAC 角色)。** 答案库(`auditor_answer_library_entries`)此前只有 `can_view_security` 一个入口。新增 [`security_faq_service.py`](../../server/app/domains/auditor/security_faq_service.py) + [`security_faq_router.py`](../../server/app/domains/auditor/security_faq_router.py):`/api/v1/me/security-faq`(**任何在职员工**,自身即授权,与 `/me/access-review`、`/me/systems` 同契约)提供 `GET ""` / `GET /facets` / `GET /{id}` / `POST /ask` / `POST /{id}/view` / `POST /{id}/feedback`;`/api/v1/auditor/security-faq`(`can_view_security` 读 / `can_manage_security` 写)提供 `GET /overview` / `GET /gaps` / `POST /gaps/{id}/resolve`。
>
> **员工面的三处收窄是硬约束,改这块代码前先读这一段**:① 只有 `status='active' AND employee_visible=1` 可达,**按 id 也不行** —— `get_visible_entry` 对隐藏条目抛 404,让撤下的深链无法充当存在性判据;② `faq_view` 只输出 question / answer / category / category_label / tags / is_featured / 两个投票数 / updated_at,**owner、usage_count、is_stale、stale_reason、review_interval_months、source_doc_ids、backing_control_keys 一律不下发**(有测试逐个断言不存在);③ 一次只回一种语言,`resolve_lang` 把 `zh-*` 归 zh、`en-*` / `ja-*` 归 en。**搜索仍然跨中英两份正文**(`_search_clause` 匹配四列),否则中文界面搜 `USB` / `MFA` 会漏掉我们已有的答案。
>
> **分类标签在后端渲染**:`category` 是自由文本(管理表单一直允许新造),所以 `CATEGORY_LABELS` 是标签源而不是约束,未收录的键在后端 humanize 成 `Foo Bar` —— 前端是纯渲染层,而员工不该看到 `data_loss_prevention` 这种东西被当成一个词。新增分类时在这张表里补三语。
>
> **`employee_visible` 默认 TRUE**(存量由列默认值全部放出,经用户确认库内不含客户可识别内容),**唯一例外是 `backfill_from_answer`,它显式写 `employee_visible=False`** —— 问卷回填是把一家客户的对外措辞原样复制进中英两个槽位,是原料不是成品;`employee_overview().pending_publish` 把它们列成待放出队列,免得「默认隐藏」变成「丢掉」。
>
> **`/ask` 是接地检索,不是生成**:关键词命中 → 直接返回;零命中才调 `inbound_ai.build_llm_client`(此次由 `_build_client` 提升为公开;那边 no-key 抛 503 是 fail-closed,这边捕获后降级是刻意的),把 ≤200 条候选的 **id + 问题**(不含答案正文)交给模型,只允许它**回 id**,越界 id 丢弃,渲染的永远是库里原文。任何异常(无 Key / 供应商故障 / JSON 解析失败)一律降级为 `matched_by='none'`,不抛错。限速独立于 copilot(`SECURITY_FAQ_ASK_RATE_LIMIT_MAX=40/h`,`check_security_faq_ask_rate_limit`)。
>
> **`auditor_security_faq_gaps` 的 upsert 语义**:`query_norm` 唯一键(**必须唯一**,`record_gap` 是 ON DUPLICATE KEY,索引退化成非唯一会让一条台账变成一行一次提问 —— 迁移测试钉住了 NON_UNIQUE=0)。更新子句只动 `ask_count` / `last_asked_at` / `last_asker_person_id` / `query_sample` / `lang`,**整个处置族(status / resolved_entry_id / resolved_by_person_id / resolved_at / note)完全不碰**,管理员的判定不会被下一次提问推翻;`gap_view.reasked_after_resolution` 由 `last_asked_at > resolved_at` 派生(严格大于:秒精度列上,处置与提问同秒会误报)。`resolve_gap(status='answered', entry_id=…)` 只接受**员工可读**的条目 —— 用一条没人能读到的答案结案等于没结。
>
> **`auditor_security_faq_votes`**:`(entry_id, person_id)` 唯一,一人一条一票、可改不可叠;条目上的两个计数是同事务维护的反范式(改票用 `GREATEST(col-1,0)` 兜底)。阅读计数走 `record_view_once`(Redis SETNX,每人每条每天一次;Redis 故障时**放行并计数** —— 软性热度指标宁可多算也不该拦住阅读)。**员工的读不写审计**(隐私 + 寒蝉成本);管理员在缺口台账上的处置写 `auditor.security_faq.gap_resolve`。
>
> **`usage_count` 的语义没有被改写**:它仍然只表示「填客户问卷时被复用了几次」,驱动安全团队的复核节奏;员工侧另开 `employee_view_count` / `employee_helpful_count` / `employee_unhelpful_count`。

> **CSS 变量:未定义的自定义属性是静默失效,不是报错(2026-08-27;frontend-only / 0 migration / 0 API / 0 cron / 0 MCP / 0 RBAC)。** `var(--m-warning)` 里那个 token 从来不存在,浏览器的处理是**把整条声明丢掉**然后继承 —— 不抛错、不打日志,`tsc` 看不进字符串字面量里,渲染出来像是「这里本来就没设颜色」,在白卡上尤其像是有意为之。[`web/scripts/check-css-vars.mjs`](../../web/scripts/check-css-vars.mjs)(已接入 `pnpm lint`)要求 `src/` 里每一处 `var(--m-*)` / `var(--helix-*)` 都能在 `src/` 下找到定义;`--ant-*` / `--adm-*` 不在范围内(那是 antd / antd-mobile 自己包里定义的,另见本文 `--ant-*` 一节)。带 fallback 的 `var(--x, …)` 照查 `--x`:fallback 救得了这一个像素,救不了下一个照抄这个名字的人。
>
> 首次运行找出 15 处既有引用,记进 [`css-vars-baseline.json`](../../web/scripts/css-vars-baseline.json);**这张单子已清空,闸的职责是它永远不再变长**。清的时候每一处都在自己的调用点单独判断,没有做统一改名 —— 因为它们根本不是同一个问题:**新增两个 token**([`--helix-font-mono`](../../web/src/theme/themeVars.css) 供赏金域的原文 / 复现步骤 / 回信模板等 7 处 code 面,写在两个 palette 块之外,因为字体栈没有明暗两套;[`--m-track`](../../web/src/mobile/styles/tokens.css) 供成就徽章条和年假已用/已授条 —— 刻意不复用 `--m-surface-sunken`(slate-50 画 5px 轨道在白卡上直接看不见,进度条就不再回答「还剩多少」),也不复用 `--m-border`(边框改色不该顺手改掉进度条));`--helix-color-primary-bg` 的 4 处**不建 token**,改用仓里既有的 `withAlpha(COLOR.primary, .12)`(另有 19 处这么写),因为它是半透明的,能跟着底下的面随主题走;其余三处是真 token 的错拼(`--helix-surface-raised`→`--helix-surface-subtle`、`--helix-surface`→`--helix-bg-container`、`--helix-border`→`--helix-surface-border`)。
>
> 其中两处不只是改名,是**修暗色下的可读性**:钓鱼演练的邮件面板和赏金域那 4 处高亮面,fallback 写的是硬编码浅色(`#fff` / `#EEF2FF`),两个主题都照画 —— 暗色下就是一张白 / 浅靛底的面,配 `--helix-text-primary` 的浅色字。逐处都截图比对过再定的,不是照着名字猜。

> **图表主题:ECharts 的 canvas 解析不了 CSS 变量(2026-08-26;frontend-only / 0 migration / 0 API / 0 cron / 0 MCP / 0 RBAC)。** `theme/tokens.ts` 的 `TEXT.*` / `SURFACE.*` 解析成 `var(--helix-*)` —— 对 DOM 正确,对图表**必然是坏的**:ECharts 经 zrender 画在 `<canvas>` 上,而 `ctx.fillStyle = 'var(--helix-text-secondary)'` 是无效颜色,浏览器**静默丢弃**该次赋值、保留上一次的 fill。不抛错、不打日志,标签就是画成一个随机颜色,暗色页面上经常等于黑字黑底。仓里 9 个文件从写下那天起就这样(运营洞察坐标轴与环形图中心数字、AI 支出、云账单、API Key 分析、云订阅、百炼、我的 IT 宇宙图谱的经理节点填充色…)。
>
> 现在**所有图表只从 [`components/EChart.tsx`](../../web/src/components/EChart.tsx) 挂载**:它注册 `helix-light` / `helix-dark` 两套 ECharts 主题,按 `useThemeStore().resolved` 选,给坐标轴/图例/分割线/tooltip/饼图切片描边一套跟随主题的默认值 —— 包括那些**从来没声明过颜色**的图表(此前吃 ECharts 自带亮色默认值 `#6E7079` / `#E0E6F1`,暗色下发灰刺眼)。调用点的显式颜色仍然优先(主题是 merge 在 option 之下的),所以需要显式指定时用 [`theme/chartTokens.ts::useChartInk()`](../../web/src/theme/chartTokens.ts) —— 按当前主题解析出的**具体十六进制**,与 `themeVars.css` 对齐;`ink` 是每主题一个稳定的模块级对象,可以直接当 `useMemo` 依赖。
>
> 两条规则由 [`web/scripts/check-chart-theme.mjs`](../../web/scripts/check-chart-theme.mjs)(已接入 `pnpm lint`)守住:① `echarts-for-react` 只有 `components/EChart.tsx` 能直接 import;② 图表文件里 `axisLabel`/`textStyle`/`lineStyle`/`splitLine`/`areaColor`/`fill`/`borderColor` 等**上 canvas 的颜色键不得取 `TEXT.*` / `SURFACE.*`**。新增图表照此办理。
>
> 相关的两个「故意不跟随主题」的面板:用户手册的 mermaid 图版面板(`.architectureCard`)**两个主题都恒亮**——每个节点自己用 `classDef` 钉了浅底深字,只有 subgraph 标题和边标签由 mermaid 亮色主题画,底翻暗只会把这些标签藏起来;`ApiDocsPage` 的代码轨同理恒暗。另外 [`FeishuCardPreview`](../../web/src/pages/hr-tools/FeishuCardPreview.tsx) 是飞书卡片的 WYSIWYG 复刻、底色恒白,所以整棵子树套 `ConfigProvider theme={{ algorithm: theme.defaultAlgorithm }}` 把 antd 钉在亮色 —— 否则 `Text type="secondary"` / `Button` / `Tag` 会跟着应用走暗色算法,在白卡上变成白字白底。

> **移动端 H5 没有暗色,但 `data-theme` 是全局的(2026-08-26)。** `mobile/styles/tokens.css` 的 `--m-*` 只有亮色一套,H5 的卡永远是白的;而 `data-theme` 挂在 `<html>` 上,桌面端选了暗色的人打开 `/m/*` 时 `--helix-*` 照样翻暗。于是移动页里任何 `TEXT.*` / `SURFACE.*` 都会变成**浅字压白卡**。移动面(`web/src/mobile/**`)一律用 `--m-*` 梯度(`--m-text` / `--m-text-secondary` / `--m-text-tertiary` / `--m-border` / `--m-surface-sunken`),`COLOR.*`(品牌/状态色,纯 hex)可以照用。

> **DAX Owner 离职交接（2026-08-25；0 migration / 0 API / 0 MCP / 0 RBAC / 0 Provider mutation）。** `OffboardingService.complete_process` 在完成离职前锁定 `data_access_resources` 中仍由离职者持有的非 deleted dataset/table 行；只有存在这些行时才调用离职域既有终态交接人解析（流程 `default_handover_person_id` → 直属经理/经理链 → `offboarding.default_handover_fallback_person_id`），候选人必须在职、未软删、有 work email 且不是本人。解析失败以 `DATA_ACCESS_OWNER_HANDOVER_UNRESOLVED` 阻断，并发生在人员 `flip_to_left` 之前；没有 DAX owner 行的离职单不新增闸门。
>
> 成功时，人员状态、离职完成与 DAX Owner 变更共用路由提交的同一事务：active/suspended dataset/table 的 `owner_person_id` 改为交接人，dataset 的 catalog/bootstrap 来源分别进入 `*_transferred`，table 保持 `dataset_inherited` / `manual` 语义；deleted 资源作为历史事实不改。审计 `offboarding.data_access_owner_handover` 记录 dataset/table 数量、交接解析层级和 `provider_mutation_attempted=false`。既有 request item Owner snapshot、审批实例、binding、compiled effect/run、撤销与 BigQuery IAM 均不回写；重复完成在既有 `PROC_COMPLETED` 幂等闸处 no-op。

> **DAX Owner 自助转让（2026-08-25；0 migration / +1 owner PUT / MCP hint 518→519 / 0 cron / 0 RBAC / 0 Provider mutation）。** `GET /data-access/requestable-resources` 与 table list 分别返回 server-resolved `caller_can_manage_owner`，只在调用者是当前 dataset/table Owner 时显示转让入口；global admin 不旁路。`PUT /requestable-resources/{dataset_id}/owner` + MCP `transfer_my_data_access_dataset_owner` 转让父数据集，并在同一事务更新所有 `dataset_inherited` child owner；manual table 保留。既有 table PUT 与 `reassign_data_access_table_owner` 名称兼容，权限改为当前有效 table Owner，只能转让自己的表。两条服务都锁行、要求目标在职、拒绝转给自己并审计；成功响应对旧 Owner 返回 `caller_can_manage_owner=false`。
>
> Dataset 手工转让以 `catalog_transferred` / `bootstrap_transferred` 记录来源；catalog refresh 仍更新 locator、状态、requestability、描述、version/etag，但保留转让后的 Owner，Bootstrap cutover 后映射为 `catalog_transferred`。运行时审批继续只读有效 `owner_person_id`；历史 owner snapshots、bindings/effects/runs 与 BigQuery IAM 不回写。本决定 supersede 2026-08-19「dataset owner 可委派任意表、manual table owner 不能再转让」的旧权限结论，表级授权粒度与 reconciler 单一 Provider writer 不变。

> **安全合规 IA 二次收敛 11→5（2026-08-24；frontend-only / 0 migration / 0 API / 0 cron / 0 MCP / 0 RBAC）。** 2026-06-05 的 16→4 收敛之后，新页面一路挂回顶层，菜单又长到 11 个叶子。同样的手法再来一次，**没有删任何页面或能力**——每个叶子变成宿主页的一个 tab（`?tab=` 持久化），原路由全部保留。当前 5 项：**合规总览** `/compliance`（待办 / 合规态势 / 风险评分 / 安全洞察 / 安全治理中心——`SecurityGovernancePage` 成为 tab）、**控制与认证** `/compliance/controls`（新壳 [`ControlsAndCertificationsPage`](../../web/src/pages/compliance/ControlsAndCertificationsPage.tsx)：控制项目录 + 访问认证；独立控制目录退到 `/compliance/controls/catalog` 供 drill-in 回链）、**自查与评估** `/auditor/self-check`（新壳 [`SelfCheckAndAssessmentPage`](../../web/src/pages/auditor/SelfCheckAndAssessmentPage.tsx)：周期自检 + 自评估 + 安全问卷；独立自检台账退到 `/auditor/self-check/register`）、**证据中心** `/compliance/evidence`（[`EvidenceAndIncidentsPage`](../../web/src/pages/compliance/EvidenceAndIncidentsPage.tsx) 增加取证问答 tab，与证据包同受 `mayReadEvidence` 闸，管理层视角只留合规事件）、**安全意识运营** `/admin/security-quiz`（新壳 [`SecurityAwarenessOpsPage`](../../web/src/pages/admin/SecurityAwarenessOpsPage.tsx)：安全题库 + 员工体验运营；两条路由都是 global_admin-only，故审计员视角不出现此项）。`normalizeMenuPath`（[MainLayout.tsx](../../web/src/layouts/MainLayout.tsx)）新增一段把被合并的深链映射回宿主菜单键做高亮，`getOpenKeys` 顺手把 `/admin/security-quiz`、`/admin/arcade` 纳入 `security-mgmt`（此前两条路径不打开任何分组）。**反漂移**：管理员组的 `children` 与审计员视角现在读**同一个 `securityMgmtItems` 数组**（审计员 `.filter` 掉 global_admin-only 项），两份手工镜像的清单曾经各自漂移过，这次结构上消除。`OnboardingTour` 的审计员高亮从已不存在的 `/auditor/governance` 菜单项改指 `/compliance`。

> **DAX table IAM Provider policy version 修复（2026-08-24；0 migration / 0 API /
> 0 cron / 0 RBAC / 0 MCP）。** BigQuery table `getIamPolicy` 及 set 前并发读取显式使用当前
> `google-cloud-bigquery` 唯一支持的 policy version 1；此前硬编码 version 3 会在 Provider I/O
> 前同步失败，使已放行的 exact-table effect 永久 retryable。Dataset ACL 独立 REST 路径的
> `accessPolicyVersion=3` 不变；精确 locator、dataViewer member、etag、unrelated-binding merge、
> no-project-IAM/no-jobUser 边界均不变。

> **DAX 自助审批链统一 Owner-only（2026-09-07；0 migration / 0 API / 0 cron / 0 RBAC / 0 MCP）。**
> 个人与 SA 的 repo Feishu definition、Helix mapping 均只保留 `data_owner_review`，由所选资源的
> Owner 审批；SA 不再有经理节点，亦不恢复早期 workload-manager/technical-owner 节点。三语指引同步。
> 最终 Feishu POST payload 的回归禁止额外审批节点。Group 新申请仍退役，历史 mapping 不改。
> Runtime 不改飞书模板：需独立发布/readback definition、upsert mapping，旧在途实例须单独处理。
>
> **DAX SA 审批即身份授权依据（2026-09-07；0 migration / 0 API / 0 cron / 0 RBAC / 0 MCP）。**
> `apply_gate.py` 和 `reconciler.py` 不再解析或检查 `service_account_apply_principal_allowlist`：
> typed SA 在审批后生成精确 binding/effect，无需管理员提前登记 principal UUID。旧名单（包括畸形值）
> 被忽略，并由下一次 apply-mode config merge 清理。integration apply 与 SA apply 两个开关、
> active binding、当前资源有效性/Owner/locator 检查、exact IAM writer/etag、审计与回收不变。
> 既有 scheduler 会自动消化符合条件的历史 pending/retryable SA effect；本条取代下述旧 principal gate。

> **DAX apply 范围收敛到 requestable 资源 SSoT（2026-08-24；0 migration / 0 API /
> 0 cron / 0 RBAC / 0 MCP）。** `service_account_apply_dataset_allowlist` 已退役；SA present
> effect 保留 integration apply、SA apply（principal allowlist 已于 2026-09-07 退役），reconciler 在写
> BigQuery 前重新读取 effect 的 `data_access_resources`，只允许 active、requestable、
> owner-resolved 的 dataset/table。Catalog projection 会把 parent dataset 的状态、requestable
> 和继承 owner 同步给 materialized table child，因此这是同一条目录读取路径。Absent/revoke
> 不受资源下架、不可申请或 owner 缺失阻断，保证旧权限可回收。遗留 dataset gate 配置被忽略，
> 下一次 apply-mode config merge 会删除该键；个人 `personal_apply_enabled`、SA 类型开关、
> 精确 IAM etag 写入以及 no-project-IAM/no-jobUser 边界均不变。

> **DAX 统一资源选择器（2026-08-22；0 migration / 0 new route / 0 MCP / 0 Provider permission）。** [`DataAccessRequestPage`](../../web/src/pages/dataAccess/DataAccessRequestPage.tsx) 不再把 dataset/table 粒度拆成顶层模式：目录行复选框始终选择整个数据集，数据集名称继续打开懒加载 [`DatasetSchemaDrawer`](../../web/src/pages/dataAccess/DatasetSchemaDrawer.tsx) 选择具体 table/view。不同数据集可在一次个人或 GCP Service Account 申请中混合整库与单表；同一数据集选择整库会清掉其子表，选择子表会取消整库。提交 schema 允许 `resource_ids` 与 `table_selections` 同时存在，要求至少一个、合计最多 50 个，并拒绝父子重叠；router 只解析实际选择的表并把合并后的 resource ids 交给既有 dataset/table owner 审批、compiled effect 与 reconciler。摘要显式标记「整个数据集 / 单张数据表」，目录行显示已选表数。不新增 BigQuery 广扫、表数据读取、Terraform 或 IAM 权限。

> **DAX 申请状态列、服务端筛选与排序（2026-08-21，排序补充 2026-08-24；0 migration / additive query parameter / 0 MCP / 0 Provider mutation）。** [`DataAccessRequestPage`](../../web/src/pages/dataAccess/DataAccessRequestPage.tsx) 为每个可见数据集显示独立「可申请 / 不可申请」标签，并提供「全部 / 可申请 / 不可申请」筛选。`GET /api/v1/data-access/requestable-resources` 接受 `requestability=all|requestable|not_requestable`，在 total/count 与分页之前执行条件，因此 50 行分页边界不会隐藏匹配项；筛选变化时 UI 回到第一页。默认「全部」也在 offset/limit 前先按后端 active + requestable 权威分组，把所有可申请数据集排在不可申请数据集之前，再按 project/dataset/id 稳定排序；前端不复制分页排序。二元展示只读取后端 `availability == "requestable"`，不从状态文案推断策略。普通员工可列举 owner-resolved 的全部 active 目录行，global admin 额外可见 suspended 行；active/non-requestable 只用于发现且不能进入表结构、勾选或提交，提交闸仍严格要求 active/requestable。筛选与排序不会创建申请、table resource、审批、Provider metadata 调用或 IAM 状态。

> **KB 有两路外部语料,性质相反 —— 别把它们当成一回事(2026-08-30;迁移 578 / +2 端点 / 0 cron / 0 MCP / 0 RBAC)。**
> 两路都落在 `support_kb_articles` 上,所以语义/关键词检索、copilot 接地、deflection、MCP
> `search_support_kb`、员工搜索全都零改动吃到 —— 但**所有权正好相反**:
> ① `source='feishu_doc'`(wiki 镜像,[`feishu_docs.py`](../../server/app/domains/support/feishu_docs.py))
> 是**机器所有**的,飞书是唯一编辑面,人在 Helix 改会被下一轮同步覆盖,所以
> `kb_service._guard_mirrored` 422 拒绝编辑/改状态/删除;
> ② `source='helpdesk_faq'`(服务台知识库导入,[`helpdesk_faq_import.py`](../../server/app/domains/support/helpdesk_faq_import.py))
> 是**一次性交接**,飞书服务台正被求助域收编,导入之后 Helix 就是编辑面,管理员照常增删改查 ——
> 给它加一个只读守门等于把编辑面留在一个要下线的系统里。
> **导入契约**:`POST /support/admin/kb/import-helpdesk-faq`(multipart,global_admin,`dry_run` 可预演),
> 按**归一化后的问题文本**在本来源内 upsert(导出没有 id 列,问题文本就是它的身份);内容一字不差的行
> **既不写库也不清 embedding**(否则重传一次就要把整个语料重新向量化);`publish` **只决定新建行的状态** ——
> 一次导入不得把已下架的条目悄悄复活、也不得把在线的条目悄悄下线;并发用
> `distributed_lock("support_kb_faq_import")` 串起来(去重是「先读全量再写」,两个管理员同时传会各自
> 看不见对方的插入,把库翻一倍)。**路由必须注册在 `/kb/{kb_id}` 之前**,否则 POST 先匹配上 `{kb_id}`,
> 把 multipart 当 `KbUpdateBody` 解析 → 422(与导出端点「先于 `/{id}` 兄弟注册」同一条规矩)。
> **富文本答案**(43/124 条)是飞书导出的 JSON 数组(`text`/`hyperlink`/`img`),渲染成 Markdown;
> **图片做成链接不做 `![]()` 内嵌** —— 那些 `…/saipan/v2/api/image/…` 实测匿名访问 **401**,内嵌只会留
> 一个碎图,而模型会把这个 URL 当成一份能打开的资料;导入结果回报「N 张图仍在飞书图床」。
> `DELETE /support/admin/kb/{id}` 是硬删(归档只是移出检索;删是给「导错了/导重了」用的),同样拒镜像行。

> **员工的知识入口是「知识库」页,不是只有制度库(2026-08-30)。**
> [`PolicyCenterPage`](../../web/src/pages/policy/PolicyCenterPage.tsx) 从「制度中心」重做成「知识库」:
> 一个搜索框同时搜 `policy_notices` 库与已发布 `support_kb_articles`,FAQ **就地展开读答案**(镜像条目另给
> 「打开原文」),制度仍是点开新标签页的文档卡片。**路由保持 `/policy-center`** —— 改名会打断每一个书签和
> 内链,而 URL 不是员工在读的东西。此前那一页只有一半:同一份被 IT Agent 拿来回答问题的语料,员工自己
> 没有入口能翻(`kbBrowse` 在 API client 里躺着,零调用方)。员工端 `GET /support/kb` 走
> `SupportKbService.browse_published`:有查询词 → **排序**的词法召回(见下一条),无查询词 → 分页浏览。
> **刻意不接语义检索** —— 这个端点没有限流,一个防抖搜索框会变成每次击键一次 embedding 调用,语义那一路
> 留在有限流的 deflection / assistant 入口上。**词法的天花板是已知的**:「笔记本电脑坏了」只召回一条勉强
> 相关的,因为整个语料写「电脑」从不写「笔记本」—— 那是内容缺口(KB-gap 周报在盯),不是算法 bug,所以
> FAQ 区**常驻**一条「没找到?去 IT 求助」,不是只在空结果时才给。右下角的 IT 小助手
> ([`ITServiceDeskMascot`](../../web/src/components/ITServiceDeskMascot/ITServiceDeskMascot.tsx))改为跳站内
> `/me/support`,不再打开飞书服务台 applink —— 那个目标在服务台还在飞书时是对的,现在它把员工直接领出
> 产品,绕过自助分流、知识库和只有这边才有的工单历史。`FEISHU_IT_DESK_URL` 仍导出,帮助中心/工单页保留
> 它作为**人工客服兜底**(有意的逃生口,不是默认的门)。

> **KB 词法检索必须是「排序」,而且必须懂中文(2026-08-30)。** `SupportKbService._keyword_search` 两层:
> ① **CJK 二元组 + 英文词 shingle** 预筛(`SupportKbArticle.title` / `search_aliases` 的 OR-LIKE,锚点按
> 「长度降序 + 字典序」取前 8 —— 纯中文探针全是二元组,不定序会让召回逐次运行漂移),再按 **Jaccard 排序**;
> 比的是**问句对问句**:条目的标题和它每一条别名各算一次取最高分(FAQ 本来就是问题)。② 只有 ① 一条不中
> 才回落到旧的分词 OR 匹配(它还读正文,所以埋在答案里的字面短语仍找得到)。基元在
> [`text_match.py`](../../server/app/domains/support/text_match.py),与「相似的已解决工单」共用一份实现。
> **两条都是拿真实的 124 条实测出来的,不是推演**:分词 OR 匹配下,「怎么连VPN」**返回 0 条**(中文没有
> 空格,整句是一个 token),而一句普通英文问题**返回 124 里的 116 条**、第一条还是无关的中文条目。
> `topical_shingles()` 再去掉**问句骨架词** —— 词表是按真实语料的文档频率选的(「如何」31% / `to` 23% /
> `how` 21% / 「怎么」14%,而同样高频的「申请」「使用」「权限」是真的在说什么,留着);不去掉它们,
> 只共享一个「怎么」(1/12 ≈ 0.083)就能越过相似度下限。最后一道是**相对相关性悬崖**:只留分数在最佳
> 命中 35% 以内的 —— 绝对下限分不出「碰巧共享一个二元组」和「就是在说这件事」,而按构造这道闸永远删不掉
> 第一名。复测 10 句真实问法:**10/10 第一条正确**,结果集从 16/27/116 条收敛到 1–4 条。
> 管理端表格的 `q` **不走这条路**,它是精确短语过滤(「找我知道存在的那一条」),两者不可互换。

> **PRIMITIVE — `app/core/xlsx_safety.py`(压缩炸弹守门)。** `preflight_xlsx` / `bounded_row`:
> 成员数、解压总量、单成员大小、压缩比、列数、单元格字符数的上限,在 openpyxl 读工作簿 XML **之前**跑。
> 2026-08-30 前住在 `domains/auditor/`,求助域的服务台 FAQ 导入要用同一套守门,而领域模块之间不互相
> import,所以移到了 core。**任何新的 xlsx 导入端点都走它**,不要再手写一套。

> **飞书回答卡片的回调与来源契约（2026-08-20；0 迁移 / 0 API / 0 MCP）。** 回答卡的 👍 / 👎 / 转人工均走 `card.action.trigger`；飞书会对历史卡片保留原始 `create_time`，不能将其当作员工点击的五分钟传输时间。卡片分支改用事件 ID 与领域幂等性保护：反馈是 `(message_id, person_id)` upsert、人工转交由支持服务去重、审批确认只接受一次条件状态迁移。知识型问题由服务端预取到的 KB 文档同样属于回答依据，必须使飞书发送含“参考文档”链接的卡片，员工可直接打开图文原文；管理员追踪保留同一来源列表。

> 🔴 **卡片回调的「不许吃掉回答」不变量（2026-09-04；0 迁移 / 0 API / 0 MCP）。** 飞书把卡片回调返回的内容**当成新卡片渲染**——所以 `card_actions` 里每一个终态分支返回什么，员工原来读的那张卡就变成什么。**答案卡上的任何按钮，其回执必须重画回答本身**：`answer_card.settled_card(reply, note=…)` 是这个形状（回答 + `参考文档` 原样、按钮花掉、底部一行状态），`rated_card` 是它的评分特化。转人工原来返回一张全新的两行卡，于是把回答连同 sources 一起顶掉了。三件配套：① **慢分支要在开始慢活之前**把该留在卡位上的卡交给 `_Handoff.in_flight`——2.2 秒预算超时后请求侧返回的就是它，否则通用 `_working_card` 会先把回答顶掉一次（建单要向全体 IT 管理员做飞书 + SMTP 扇出，这条路基本必然超时）；② 「值得回头翻」的回执（工单号）在超时路径上**另发一条消息**，走响应里的内部键 `FOLLOWUP_KEY`——`_deliver_followup` 默认发的是分支的卡，而那张卡现在就是整条回答；③ 确认提交的回执要带上 `将要提交的内容` 的参数行（`_submitted_rows`），否则聊天里只剩「提交过」而没有「提交了什么」。

> **飞书知识镜像退役与员工会话控制（2026-08-19；0 迁移 / 0 API / 0 MCP）。** Feishu mirror
> 以完整成功的目录遍历为“仍存在”依据；被移出目录、根目录配置移除或正文为空的镜像行变为
> `archived`，同时清空 `embedding` / `embedding_model` / `embedded_at`，因此不会被员工 KB、AI
> 或语义索引命中，正文只保留作管理审计。同步摘要里的 `archived` 是**该轮新增退役数**，不是
> 现存已下架总数；管理页提供标题/正文关键词筛选核验具体文档。飞书员工频道的 `/help`、`/new`
> 与 `/reset` 是 LLM 之外的确定性控制面：帮助只展示能力和安全边界，重置通过会话 epoch 清掉
> 上下文且阻止并发旧回复回写。

> **DAX 表级申请与负责人支持（2026-08-19；迁移 523 / +1 owner PUT / MCP hint
> 487→488 / 0 cron / 0 RBAC 新角色，待发布）。** Dataset catalog 继续决定可申请边界；用户
> 在 schema drawer 选择精确 table/view，Helix 按需物化 child `data_access_resources`，默认
> 继承 dataset owner；即使 child row 已存在但 owner 为空，列表也即时回退 dataset owner，
> 选表时再把 inherited owner/resolution/source 修复到 child row。该 PR 最初只允许 dataset
> owner 把单表改为 manual owner；此权限结论已由上方 2026-08-25 Owner 自助转让决定 supersede，
> 当前规则是 effective table owner 只能转让自己的表，非空 manual owner 不被继承逻辑覆盖。
> `dax-compiler-v2` 将 `target_table_id` 一路写入 compiled effect、run history 与请求详情，
> reconciler 按 `(project,dataset,table)` 分组并通过 table IAM etag 写精确
> `roles/bigquery.dataViewer`；历史 dataset effects 仍走原 dataset ACL。schema 本身不落库，
> 无 table data、query job 或 project IAM。HTTP owner mutation 的 MCP twin 是
> `reassign_data_access_table_owner`；个人/SA MCP 提交也接受 table selections。
> 2026-08-22 统一选择器上线后，此路径与 dataset 级新申请可在不同数据集间组合；同一数据集仍禁止父子重叠。
## "还在处理中" must be reachable from a real event, not a wish (2026-08-26)

[`my_requests.resolve_normalized_status`](../../server/app/domains/me_universe/my_requests.py)
collapses each source's native status into the unified lifecycle 我的申请 renders. `approved`
mapped to `in_progress` by default, and the per-type allowlist that exempted
`software_seat` / `api_key_request` **deliberately excluded hardware** on the reasoning that an
approved 电脑更换 is "approved, awaiting physical fulfilment".

Helix records no fulfilment event. Every writer of `employee_requests.status` sets
pending / approved / rejected / cancelled; the `google_*` and `onboarding_claim` families are
stamped `completed` directly and never pass through `approved`. So a hardware request could
enter 进行中 and never leave — 52 production rows across `laptop_replacement` /
`computer_borrow` / `asset_return` / `offboarding_buyback` when employees reported it.
`SRC_EMPLOYEE` is now in `_APPROVED_IS_TERMINAL_SOURCES` (whole-source) and
`_APPROVED_IS_TERMINAL_TYPES` is gone.

The general rule worth carrying: **before mapping a status to "still in flight", name the event
that will move it out.** If no writer produces that event, the state is terminal no matter what
it means semantically, and pinning it open is a lie the user has no way to clear. `raw_status`
still carries `approved` for display fidelity, so nothing is lost by collapsing the lifecycle.

## The T+0 grace card promised a hardware transfer that never happens (2026-08-26)

The `leaver_it_grace` template in [`notifications/service.py`](../../server/app/notifications/service.py)
told IT that after 24h *"硬件 → 转给当地资产管理员(若 location 匹配)或经理"*. Neither half is
real. `MANAGER_AUTO_TRANSFER_RESOURCE_TYPES` holds API keys **only** (「经理 ≠ 资产」), and
`LeaverAdvancer._handle_one_blocker` routes hardware into `_apply_hardware_disposition`, which
applies whatever the offboarding console set (`keep` / `recycle_to_office` /
`transfer_to_person`) and — with nothing set — DEFAULTS to `keep`: the device stays assigned to
the leaver as `held_by_leaver`, and `_resolve_hardware_recipient` picks the local asset admin as
the **notification addressee**, not as the new owner. Copy now says that in all three locales
(and in `helpCenter.admin.offboardingPolicy.severityAdvisory`).

Reading a recipient-resolution helper as an ownership-transfer helper is the trap here: the same
`fallback_recipient` value feeds "who gets told" and "who would own it *if* the disposition were
a transfer". Only the `transfer_to_person` branch makes it an owner.

## Every ended cloud grant has an action, never only an explanation (2026-08-25)

`endedGrantColumns` in [`MyCloudGrantsPage.tsx`](../../web/src/pages/cloud/MyCloudGrantsPage.tsx)
gives **every** row a way forward:

* revivable (`expired` inside `RENEWAL_REVIVAL_MAX_AGE_DAYS`) → 「申请恢复访问」, which keeps the
  original grant and needs one owner approval;
* everything else (perpetual holder, deliberately `revoked`, past the window, scope closed) →
  「重新申请」, which opens `RequestAccessModal` with the account **and the role** already
  selected, with the ineligibility reason kept as sub-text.

The second path exists because of the sentence that actually cost time in the incident: *"once
I've lost access I don't know exactly what I had — figuring out which account and role out of
the massive amount out there is wasting time."* The ended row holds that answer; routing the
employee to a 500-scope picker to re-derive it is the waste. Rendering only a reason string is
still a dead end.

`RequestAccessModal.preselectRoleKeys` seeds the roles. Two constraints that are easy to get
wrong: seed **inside the role-load callback** (the tail of that same effect synchronously resets
`role_key` to undefined, so an earlier write is wiped — the async seed landing after it is
deliberate), and depend on a **joined string**, never the array prop (a fresh array reference per
parent render re-fires the effect and its fetch in a loop). Roles carrying `self_grant_status`
are dropped — the picker renders them disabled and submitting one 409s.

**Check `is_promoted` before assuming a prefill will work.** `GET /scopes/{id}/roles` defaults to
`promoted_only=True`; on AWS `577523860935` only 53 of 84 roles are promoted. A role that is not
promoted is absent from the dropdown, so the prefill silently drops it and the modal opens empty
— a dead end moved rather than removed.

## A hover-only explanation is still an invisible one (2026-08-25)

`endedGrantColumns` in [`MyCloudGrantsPage.tsx`](../../web/src/pages/cloud/MyCloudGrantsPage.tsx)
renders the ineligibility reason **inline** in the Actions cell, not in a tooltip on a bare
`—`. The first cut shipped the dash-plus-tooltip form and the very first admin to open the
page reported the feature as missing — every one of his rows was `perpetual_holder`
(`global_admin` holders are perpetual, so `check_renewal_eligibility` short-circuits before
the status/window checks), and a column of dashes reads as "not built".

Two rules this encodes, both learned the expensive way:

* **Moving an explanation from nothing to hover-only does not make it discoverable.** The bar
  is readable without interaction. The PR that introduced the dash carried a comment saying
  "no button and no feature look identical" — and then hid the reason behind a hover.
* **Copy must not promise a control the row-level gate can withhold.** The compliance note
  said 「回收后 30 天内仍可点右侧「申请恢复访问」」 unconditionally; eligibility is decided per
  grant, so for perpetual holders / revoked rows / out-of-window rows there is no button and
  the sentence contradicts the screen. That is worse than silence: it converts "nothing to do
  here" into "this is broken".

Reason strings live in `cloudLifecycle.renewal.ineligible.*` (full sentence, tooltip) and
`cloudLifecycle.renewal.ineligibleShort.*` (cell text), one pair per `INELIGIBLE_*` constant
in [`renewal_service.py`](../../server/app/domains/cmdb/renewal_service.py). Adding a backend
reason means adding both, in all three bundles.

## Deflection sessions never die silently (2026-08-25)

`SupportService.expire_stale_deflections` is **not** a bookkeeping sweep any more. An
`outcome='open'` copilot session older than 3 days is now **escalated into a real
`support_tickets` row** via `SupportService.create` (so it reuses the existing IT-only
global_admin fan-out), instead of being flipped to `expired`/`abandoned` with no ticket, no
assignee and no notification. Before this, an employee who asked for help and never pressed
「这解决了我的问题」 had their request deleted after 3 days; production had **0 `deflected`
outcomes across all 10 sessions ever recorded**, so "didn't confirm" was never evidence of
"solved". The handler returns `{scanned, escalated, unescalated}` (was a bare int).

Semantics to preserve when touching this: `SupportDeflectionEvent.outcome` stays
`DEFLECT_EXPIRED` — that column records **what the employee did** and both
`support_kb_gap_handlers` (KB-gap signal) and `support_entrance_coordinator` (funnel rates)
read it that way. What changed is the `SupportServiceRequest`: `escalated` + linked ticket
instead of `abandoned`/`unknown`. `support_operations_analytics.agent_escalated` derives from
ticket linkage, so these rows leave the `agent_abandoned` bucket truthfully rather than by
relabelling. Auto-filed tickets carry `creation_actor=system` +
`creation_mode=agent_auto_escalation` with `intent_initiator=employee`, and
`employee_confirmed_at` deliberately stays NULL. Blank queries (`(空)` sentinel) and
`left`/`leaver_pending` holders are not escalated but still reach a terminal state.

## Cloud-grant revival was reachable server-side long before the UI offered it (2026-08-25)

**Before adding any "let employees restore recycled cloud access" feature: it already
exists.** `check_renewal_eligibility` ([`cmdb/renewal_service.py`](../../server/app/domains/cmdb/renewal_service.py))
returns `eligible=True, is_revival=True` for a grant in `status='expired'` whose
`revoked_at` is within `RENEWAL_REVIVAL_MAX_AGE_DAYS` (30), `POST
/scopes/{id}/renewal-requests` accepts it, `_serialize_grants` ships
`renewal_eligibility` **on every row of the `已结束` tab**, and `RenewalRequestModal`
already badges revival rows orange. Until 2026-08-25 the only missing piece was that
`endedGrantColumns` in [`MyCloudGrantsPage.tsx`](../../web/src/pages/cloud/MyCloudGrantsPage.tsx)
had **no action column at all** — the eligibility field arrived on the wire and was thrown
away, so employees (and IT) read the page as "there is no such feature". A user asked us to
build what was already built. Canonical instance of the rule that a reach's landing page must
be the page that carries the action.

## Expiry/recycling cards resolve language per recipient (2026-08-25)

Both `sweep_expiring_grants` cards — the T-14/7/1 reminder and the T+0 recycling notice —
compose `title`/`body_text`/line-items from `resolve_lang(person)` + `pick_variant`, not from
hardcoded Chinese literals. These cards each go to exactly one recipient, so the producer
resolves the language itself; `LocalizedText` (per-reader resolution) is still the right tool
for card chrome that fans out. `NotificationEnvelope.title` / `.body_text` are typed plain
`str` — the chrome fields are `CardText` — so **any producer writing title/body must pick the
language itself**; there is no platform layer that will do it for you. 330 of 443 active
employees resolve to English (`app.core.locale`), so a Chinese-only card is the majority case.

## Unified ticket analytics (2026-08-20)

The operations cockpit's `/admin/helpdesk-analytics` route now uses the unified ticket workbench. `core/coordinators/support_entrance_coordinator.py::unified_ticket_analytics` maps Helix-native and Feishu-mirror tickets to the same KPI contract (source, status, first-response hours, resolution hours, satisfaction), while retaining `source=helix|feishu` for migration reconciliation. Feishu rows remain historical data after the external helpdesk is retired.

## Assistant knowledge-gap closure — evidence, not a sentence (2026-08-30)

Closing a gap (`update_gap(action="resolve")`) now requires a `fix_type` from a
fixed list, an `evidence_ref` (except `out_of_scope`, where the decision is the
evidence), and a **passing replay**. See
[`gap_verification.py`](../../server/app/assistant/gap_verification.py).

**The replay goes through `simulate_employee_turn`, never `run_turn`.** The live
turn's proposal dispatcher really sends cards: re-asking a three-week-old
question would push an unsolicited confirmation card at a colleague, and one
about a laptop would file them a second request. `run_turn` also writes a
transcript, so replays would inflate the very conversation counts and funnel the
previous PR made honest. The simulation runs the production Feishu prompt, read
bridge, action discovery and proposal validation with exactly one substitution —
a dry-run dispatcher. Two tests assert that a replay changes neither
`assistant_conversations` nor `notification_outbox` row counts.

**It replays as the original asker**, not as the admin: the assistant's tools are
scoped to the caller, and the question being answered is "can *they* get an
answer now".

**It stores the shape, not the reply** — tools called, sources cited, outcome,
whether an action was offered. The reply is that employee's own data; keeping it
in a queue other people read would turn triage into a second copy of everybody's
personal information.

**Passing means grounded**, not merely non-empty: `outcome == answered` AND
(a tool ran OR a source was cited).

`ignore` is deliberately unchanged — it is a judgement about the *report*
("not a gap"), and putting the same bar on it would only push people to close
real gaps as ignored. `reopen` keeps the fix and evidence: the next person needs
to see what was already tried.

## Assistant funnel — what it counts, and what it refuses to (2026-08-30)

`GET /api/v1/assistant/admin/funnel` (`admin_service.submission_funnel`).
Everything is **derived at read time**, never a stored counter: a counter added
today cannot describe last month, and the whole point of this funnel is to
re-examine history that was previously mis-counted.

- **Time to result** needs no column. The need starts at the service request's
  `created_at` and ends at the link's `phase_at`. Only **settled** links are
  measured — including in-flight ones would make the number improve every time
  something gets stuck. Median and p90, not a mean: one request that sat three
  weeks would drag an average somewhere no individual request was.
  **`phase_at` is stamped on EVERY resolve, not only on settling** — so the
  settled-phase filter is load-bearing, not decoration.
- **Card replies are two numbers.** "Nobody replies to cards" (discoverability)
  and "people reply but we cannot tell which card" (plumbing) look identical
  from outside and have opposite fixes.
- **`system_verified` and `self_solved` are never summed**; there is
  deliberately no `closed_total`.
- **Every rate is `None`, never `0`, when the denominator is zero.** A 0% rate
  and "no data yet" are different findings.

Reading a JSON boolean out of `audit_logs.detail`: use
`json_unquote(json_extract(...)) = 'true'`. Casting a Python `True` through
SQLAlchemy's `JSON` type re-encodes it as the JSON *string* `"true"` and
matches nothing — the count returns 0 and reads as a plausible answer.

Writing to `audit_logs.detail` (2026-08-31): **never hand it a raw pydantic
`model_dump()`**. The engine in [`core/database.py`](../../server/app/core/database.py)
configures no `json_serializer`, so SQLAlchemy's `JSON` column serializes with
a bare `json.dumps` — a `datetime` or `UUID` field raises `TypeError` at
`audit.log`'s own `flush()`, i.e. *after* the mutation it was recording, and
the whole request 500s and rolls back. `AuditService.log`'s 16 KiB size guard
uses `json.dumps(..., default=str)` and therefore does **not** catch this.
Dump with `mode="json"` (and `exclude_unset=True` when an explicit `null` is
semantically distinct from an omitted field). Same failure mode as
`scope_filter`'s `person_ids: list[UUID]` on cycle create; it kept
`PATCH /asset-inventory/cycles/{id}` broken for every request that carried a
`due_at` until 2026-08-31.

`Decimal` is the third member of that family and it bit the same day:
`PUT /finance-flux/config` audited a `ConfigUpdateBody` carrying
`amount_threshold` / `pct_threshold` (both `Decimal`), so **every save that set
a variance threshold 500'd and rolled back** — the one field finance actually
tunes. Note what makes this class hard to spot by reading: a threshold-free
body (`{"ai_enabled": false}`) succeeds, so the endpoint looks healthy, and the
`json.dumps(..., default=str)` size probe *succeeds* on a `Decimal` and leaves
`detail` untouched for the column serializer to choke on — the guard is a size
guard, never a type guard. Both bugs were latent for months because the
offending field had no UI caller.

**Structural enforcement (2026-08-31)**:
[`tests/test_audit/test_audit_detail_model_dump_mode.py`](../../server/tests/test_audit/test_audit_detail_model_dump_mode.py)
AST-walks every `detail=` keyword under `server/app/` (including the wrapped
`detail={"create": body.model_dump(...)}` form) and fails on any `model_dump`
without `mode="json"`, naming `file:line`. Rationale: a per-endpoint
regression test only covers the endpoint someone thought to test, and the two
schemas that broke were *all-primitive when written* — the defect arrives when
a schema later grows a `Decimal` / `datetime` / `UUID` field, which is exactly
the moment nobody re-audits the audit line. The two remaining Python-mode
sites (`cmdb/router.py` `DedicatedLineUpdate`,
`scheduled_jobs/router.py` `ScheduledJobPatchRequest`) were converted in the
same PR; `mode="json"` is a no-op on their current all-primitive fields.
Alternative considered and **not** taken: configuring
`json_serializer=partial(json.dumps, default=str)` on the engine. It would
immunise every JSON column at once, but it converts a loud failure into a
silently lossy audit record (`str(datetime)` is not ISO-8601, `Decimal` and
`str` become indistinguishable) across every JSON column in the platform —
a platform-wide serialization change that wants its own ADR, not a bug-fix PR.

## Assistant request closure — what counts as solved (2026-08-30)

`record_assistant_request_verdict` used to close a `SupportServiceRequest` the
moment anyone tapped 有用, with no reference to any business object. That made
the platform's headline "self-solved" number a measure of politeness.

A tap is evidence about the **answer**. It closes the request only when every
linked object has reached `effective`, or when the conversation produced none:

| state of the linked objects | result |
|---|---|
| any still non-terminal | stays `active`, `outcome=answer_helpful_action_pending` |
| any `rejected` / `failed` | stays `active`, `outcome=action_needs_attention` |
| all `effective` | `self_resolved`, `outcome=`**`system_verified`** |
| none exist | `self_resolved`, `outcome=self_solved` |

**`system_verified` and `self_solved` are never added together** — the
difference is "we checked" versus "they said so". `/assistant/admin/funnel`
deliberately has no `closed_total` field, and a test asserts its absence.

**`needs_attention` is derived, not stored.** `SupportServiceRequest.status`
has 21 consumers; adding a status value would mean auditing all of them, and
what was actually needed is only "not counted as solved".

**`assistant.submission_reconcile`** (cron, every 30 min,
`app/scheduled_jobs/assistant_submission_handlers.py`) refreshes non-terminal
links and closes the requests behind the ones that landed. It **sends nothing**
(the owning domains already notify on their terminal states; production pushes
~3,785 cards a month without this adding to it), **never closes on a failure**,
and skips objects that changed hands. Registered in **both** `app/main.py` and
`app/worker.py` — the worker keeps its own import list and a missing handler
trips a fail-closed startup guard that stops every cron on the platform.

**The follow-up question is a tool call, not a memory.** The system prompt
routes "刚才那个申请怎么样了" to `list_my_assistant_submissions` and forbids
answering from the conversation. `prompt.SUBMISSIONS_TOOL_NAME` is guarded
against the MCP registry: a prompt names a tool by string, and a rename would
leave the model calling something that resolves to nothing.

## Assistant outcome resolvers — what state is that row in? (2026-08-30)

`app/core/coordinators/assistant_outcome_coordinator.py` reads the live state of
a linked row from the domain that owns it, and projects it onto one small
vocabulary: `submitted` / `pending_approval` / `provisioning` / `effective` /
`rejected` / `failed` / `cancelled` / `ended` / `gone` / `unreadable` / `unknown`.
Surface: `GET /api/v1/assistant/submissions` and the MCP twin
`list_my_assistant_submissions` (both self-scoped).

**No second state machine.** `AssistantObjectLink.phase` is a funnel cache
written *by* the read and never read by it. Employee-facing answers always come
from the domain.

**Each domain declares its own status map — there is no shared vocabulary.**
Four production values fall outside {approved, rejected, cancelled, pending}:
`stale_requires_resubmit` (a failure with a fix), `lapsed` (an expiry, not a
cancellation), `pending_owner` and `pending_feishu` (different approvers). An
unmapped value resolves to `unknown`, never to a default bucket.

**`provisioning` vs `effective` is real on api keys**: `approved` means the
approval passed and the key is not issued (`STATUS_ACTIVE = "active"  # key
filled by admin`); `active` means it works.

**`employee_requests` has two families with different terminal words** —
approval-backed types stop at `approved` and never reach `completed`;
direct-execution types are created already `completed`, after the provider
operation settled. Both map to `effective`, and that is safe *because* no type
produces both.

**Permission is re-derived per read, never inherited from the link.** The
ownership column has six different names across the domains
(`requester_id` / `user_id` / `assigned_to` / `person_id` /
`requester_person_id`), so it is declared per resolver. A guard test asserts
every declared column exists on its model: a renamed column would make
`getattr(row, attr, None)` return `None`, turning every read into `unreadable` —
fail-closed, invisible, and completely broken.

**Coverage is 14 actions / 9 resolvers**, one-to-one. `release_my_api_key`
shares `request_api_key`'s resolver on purpose — a release is not a new object,
it moves the SAME assignment row through `pending_release` to `revoked`.

**`pending` means two different things in the permission domain**, and they
point at different people. `user_permissions.status` goes `pending → active`
with no `approved` in between, but the row carries its own
`provisioning_task_id` — so "waiting on a human" and "approved, being
provisioned" are separable. Telling someone their permission is still awaiting
approval an hour after the approval passed sends them to chase the wrong person.

**A batch reports its worst member.** `request_cloud_grant_renewal` returns one
`request_group_id` covering N rows; six approved and one lapsed is not "done".
A batch containing anyone else's grant is refused whole rather than read partially.

## Assistant outcome contract — which row did that action create? (2026-08-30)

`app/assistant/outcome_contract.py` declares, per action, **where its business
object is** in that action's own result: `resource_type`, the exact `id_key`,
the resolver that reads live state, an optional human-facing number, a
server-composed deep link, and a `count_key` for the one action whose id covers
a batch. `locate()` follows only the declared path — no fallbacks, no scanning
for keys that look like ids, and the model is never asked to read a result.

**Why a declaration and not a heuristic.** The platform's own write tools return
five different key names for the same idea (`id`, `allocation_id`, `request_id`,
`request_group_id`, plus serializer output), `request_software_seat` puts
`allocation_id` and `approval_request_id` side by side in one dict, and
`request_cloud_grant_renewal` returns a group id covering N grants — where
"is it effective" means something different.

**Verify a locator through `get_action()`, not against `tools.py`.** Curated
actions in `app/assistant/actions.py` shadow same-named MCP tools *with
different return shapes* — `create_support_ticket` returns `ticket_id` curated
and `id` as a tool; `submit_vpn_application` returns `application_id` vs `id` —
and `get_action` prefers curated. A locator checked against the tool alone is
wrong at runtime and **silently so**, because a locator miss is designed never
to fail a submission.

**Misses are stored, not logged.** `AssistantActionProposal.link_miss_reason`
is its own column so "the contract has a gap" can be counted; on a funnel that
only counts links it is indistinguishable from "nobody submitted anything".
Linking runs inside a nested savepoint — a failed flush poisons the whole
transaction, and that transaction carries the execution's own status update.

`AssistantObjectLink.phase` is a **cache** for funnel queries. Live state is
always re-read from the owning domain, and reading it re-derives object-level
permission rather than treating the link as authorisation.

## Assistant knowledge-gap lifecycle (2026-08-20)

`assistant_message_feedback` negative verdicts are durable work items: `open → in_progress → resolved/ignored`, with `reopen` returning to `open`. The assistant audit tab exposes the queue and transition API; the `assistant.knowledge_gap_reminder` scheduled job sends the first reminder after 24 hours and repeats at most every 48 hours. Resolved and ignored rows are excluded from the sweep. Assignment, resolver, resolution note, timestamps, and reminder counters remain on the row for auditability.
> **DAX 表级双向血缘展示（2026-08-20，上游 source 反向引用补充于 2026-08-24；0 migration / +1 read API / MCP hint
> 488→489 / 0 cron / 0 RBAC 新角色，已发布到 Staging）。**
> [`DatasetSchemaDrawer`](../../web/src/pages/dataAccess/DatasetSchemaDrawer.tsx) 的 Lineage tab
> 对 dbt 生成 relation 按 `source → staging/transform → serving` 展示**上游来源**，对 manifest
> `source` 则从所选源表向下展示实际引用它的 dbt 暂存、转换和结果 relation；它不是新
> catalog、授权或运行时 BigQuery discovery 面。读取 `GET .../tables/{table_id}/lineage` 仍先验证
> active/requestable、owner-resolved dataset 和在职员工，再从随 server image 发布的版本化
> [`gcp-digital-bi-dbt.json`](../../server/app/domains/data_access/lineage_catalogs/gcp-digital-bi-dbt.json)
> 读取由 [`build_data_access_lineage_catalog.py`](../../server/scripts/build_data_access_lineage_catalog.py)
> 从 dbt `manifest.json` 投影的 relation metadata 与直接 `source`/`ref` edge。请求路径不读 raw
> manifest、不跑 BigQuery job、不读表数据、不写数据库、也不变更 DAX/Feishu/IAM；未记录表返回
> `not_tracked`，绝不按名字或 SQL 猜血缘。响应以 additive `direction=upstream|downstream` 标明方向；
> 表清单同时返回 catalog relation 的 `lineage_direction` 与直接 `downstream_reference_count`，所以 source
> 有引用时显示“被引用 N 次”，0 条 edge 时明确显示“暂无下游引用”。MCP `get_data_access_table_lineage` 是此 HTTP 边界的 1:1
> 只读孪生。artifact 标注 `dbt main@5139557c`，已用仓库固定的 `dbt-core/dbt-bigquery 1.9.0` 执行
> no-partial 全量 parse（exit 0）后重新生成；构建投影按 `project.dataset.table` 折叠 dbt 别名，
> 同一物理 relation 优先保留 dbt 产出的 model/snapshot/seed，再保留 source，并把所有依赖边重映射到
> 规范节点。最终 artifact 为 1231 个唯一 relation / 1825 条直接 edge，重复 locator 为 0。仅规范化后
> 仍为 source、且实际位于至少一条依赖边上游的节点属于外部源头；未被任何依赖引用的 source 声明不导入
> DAX inventory。HTTP/MCP 只在这些节点序列化数据集 owner。父级为空的非 source 节点在 UI 标为
> “血缘起始节点”，摘要也只称“上游依赖”，不会被误称为“原始来源”。UI 另以可滚动关系清单逐条消费
> artifact 的真实 `upstream → downstream` edge；分层概览不绘制会掩盖分支或跨层依赖的通用箭头。
> 两个方向都最多返回 300 个节点，且只在用户点击单表血缘时遍历；列表计数直接读取同一内存 catalog。
> 存量未匹配 patch/test warning 仍需由
> dbt 项目另行治理，但 Fusion compatibility error 不再阻断此 artifact。

> **历史：DAX Production 每日目录对账（2026-08-21 引入，2026-09-09 已移除；0 migration / 0 API /
> 0 MCP / 0 Terraform）。** 原每日 06:20（Asia/Shanghai）自动 committed reconcile 已删除，
> 避免覆盖生产 `requestable`。`seed-dax-dataset-catalog.yml` 仅保留显式人工 dry-run/commit。
> 自动源头投影只从 active/requestable、非血缘派生 inventory 数据集中的 dbt 结果 relation 出发，
> 沿 parent edge 向上遍历并在 `source` 终止；source-only 数据集不是新的起点。仍可达的缺失源头默认
> owner `xiaopeng.zhu@pingcap.com`，已有 owner 保持；不再可达的血缘派生行退出权威 feed，人工目录
> 不删除。加入 active/non-requestable 的 `gcp-digital-bi.alibaba_cloud` owner-only 行后，真实
> inventory dry-run 为 67 条投影、31 个可达 source dataset、47 个 active/requestable；
> 仅已物理缺失并暂停的 `usage_us_west2` 退出。owner label 只返回 display name，姓名缺失才回退邮箱。
> 该调度不增加 `bigquery.datasets.list`、不扫描全项目、不读表数据、不自动 Terraform/IAM；表目录与
> schema 继续按展开、搜索和点选懒加载，Provider controller scope 仍由独立 Terraform 门禁管理。
> CI 是 lineage-root inventory 的唯一组装点；EC2 helper 只投影内容寻址的预组装结果，绝不让较旧的
> Production image 用旧 root-selection 代码二次解释新 artifact。

> **求助工单的处理记录是聊天式的，渲染器桌面/移动端共用（2026-08-18；1 API 新增 / 0 迁移 /
> 0 cron / 0 RBAC / 0 MCP）。** [`web/src/pages/support/SupportConversation.tsx`](../../web/src/pages/support/SupportConversation.tsx)
> 取代了两处各自手写的 `Timeline`（[`SupportTicketDetailPage`](../../web/src/pages/support/SupportTicketDetailPage.tsx)
> 与 [`MSupportTicketDetailPage`](../../web/src/mobile/pages/detail/MSupportTicketDetailPage.tsx)），
> 是**纯 div + CSS module**：两端不同时具备 antd 与 antd-mobile，所以它只依赖 app 级的
> `--helix-*` 契约（`theme/themeVars.css`）+ 品牌靛蓝，**不要**用 `--ant-*`（本仓从未定义）
> 或 `--adm-*`（桌面端不加载）。四种语义各有形状：气泡=人在说话（**以读者为右**，同一页面
> 既服务求助人又服务 IT）、居中小字=`system`/`status_change`、绿框=`resolution`、首条气泡=工单
> 正文。**工单正文与解决说明不再在详情卡里另外渲染一份** —— 同页两处渲染同一段话，正是
> HELP-20260817-72F319 看起来像「IT 回了两遍」的成因。下一个要展示工单会话的界面直接用它。
>
> 配套新增 `GET /api/v1/support/admin/tickets/{id}/feishu-chat`（`require_admin_read`，只读）：
> 返回与求助人单聊的 applink。`applink.feishu.cn/client/chat/open` 对人只认 Feishu `open_id`
> （`?openId=`；`?openChatId=` 是群），而 `identity_external_identities` 存的 Feishu
> `provider_subject` 是 SSO 的 `union_id` —— 所以 open_id 必须从 contact API 取，复用
> `FeishuApprovalClient.resolve_open_id_by_email`（每条飞书推送都在用的那个）。**按点击查，不挂
> 在工单详情返回里**：详情每次打开/每次操作后都重载，逐次渲染都查一遍飞书是净亏。求助人侧的
> 按钮不碰这个端点，走的是既有的 `help_center.qa_group_url` + `FEISHU_IT_DESK_URL` 兜底。
>
> **`enqueue_then_drain` 的 `on_progress` 回调 + 「rollback 会 expire 掉后续 envelope」这个陷阱
> （2026-08-17）。** 群发原语现在接一个 `on_progress(done, total)`(逐行、best-effort、异常只记
> 日志)。它存在的理由很具体:调用方想给自己的批次行刷「还活着」的心跳,而**不能**为此把扇出
> 切成小块 —— phase 1 是全批入队才是「进程被 kill 之后剩下的行仍 PENDING、由
> `notification.outbox_retry` 兜住」的全部来源;分块把 phase 1 挪进循环,中途重启就会留下一批
> **根本没有 outbox 行**的收件人,重试 worker 无从兜住,任何「只重发失败项」的 UI 也挑不到它们。
> 下一个要做进度/心跳的扇出直接用这个回调,别再分块。
>
> 同时修掉一个此前一直存在的级联缺陷:phase 1 某一行 enqueue 失败时 `await db.rollback()`,
> 而 rollback **expire 整个 identity map** —— 包括后面尚未处理的 envelope 还握着的 `recipient`
> ORM 实例。异步代码里碰一个过期属性就是 `MissingGreenlet`,所以**一行失败会级联到剩下每一行
> 都失败**,正好违背这个 helper docstring 的承诺。修法:phase 1 之前先把 recipient id 快照
> 下来(rollback 之后连 `env.recipient.id` 都读不了),失败路径上一条 `IN (…)` +
> `populate_existing` 把身份行批量复活。任何在异步 session 上 rollback 之后还要复用先前加载的
> ORM 实例的代码,都要考虑这一条。

> **Qwen PR review automation retired (2026-08-18; 0 migration / 0 RBAC / 0
> cron / 0 MCP).** The owner removed both repository workflows and every
> dedicated repository review implementation, prompt/schema, and test:
> `deepseek-review.yml` and `deepseek-exact-main-handoff.yml`. Helix no longer
> runs provider-backed PR review, creates `Qwen Review / Required`, or invokes
> a workflow-run App controller to auto-squash a PR. The dedicated runner and
> its runner-local credential are not a repository execution path. Historical
> evidence and the explicit reintroduction boundary are in
> [`DEEPSEEK_REVIEW.md`](DEEPSEEK_REVIEW.md).




> **年假到期提醒 —— 第一个交给 HR 的通知群发面（2026-08-17；PR1/2 后端，
> 迁移 **506 + 507** / **+1 cron（只收尸、永不发送）** / **+1 MCP→hint 479** /
> RBAC 无新角色）。**
> 假期到期判定本来就有（`forfeits_at_period_end` + `expires_in_days`，员工卡片与
> 休假健康洞察共用），缺的是**主动告知**：员工必须自己打开页面才看得到，于是每年
> 9/30 都有人的法定年假静悄悄清零。
> [`leave_reminder_service.py`](../../server/app/domains/hr/leave_reminder_service.py)
> + [`leave_reminder_router.py`](../../server/app/domains/hr/leave_reminder_router.py)
> （`/api/v1/hr/leave/expiry-reminder/{config,preview,preview-message,test-recipients,
> send,batches,batches/{id},batches/{id}/resend-failed}`）。
> **试发的对象是全员，不是名单**（2026-08-18）：试发用共享 `MultiUserSelect`
> （服务端搜 `/directory/people`），谁能收到由 `resolve_test_recipients` 逐人判定，
> `POST …/test-recipients` 是它的无副作用预检面 —— **预检与发送共用同一个函数**，
> 四种结局：名单内 / 名单外但有真额度（真数字）/ 示例数字（**仅发起人自己**，
> 给第三方寄假数字不做）/ 发不了（附原因码）。判定名单外的人有没有真额度时只放宽
> 阈值·窗口·财年，**不放宽 HR 选的假期类型**。
> **口径全部是 HR 的选择**（假期类型码表从真实数据长出来、财年、剩余天数阈值、
> 到期窗口、含/不含已过期与已关闭桶），不写死「只提醒法定年假」——出厂默认是三个
> 会清零的码 `CHN_AnnualLeave` + `CHN_PL` + `MY_AnnuL`。
> **福利年假也会清零（2026-08-18，HR 确认：法定与福利都是一年半有效期、同日 9/30
> 清零）**，所以它进了 `_DEFAULT_FORFEIT_CODES` —— 这一个常量同时喂员工假期卡片的
> 倒计时、休假健康洞察的「临期未休」、和本功能的措辞，此前三个面一致地少报了它。
> **`min_balance_days` 是「人」的门槛，不是「账户」的门槛（2026-08-18）**：命中口径的
> 桶**全部**进快照（余额 0 的也在，卡片要画出「福利年假 2025 · 0 天」这一格），能不能
> 收到取决于**合计**是否过阈值 —— 于是「法定 15 + 福利 0」两格都出现，而「0 + 0」的人
> 一封都不发。**0 天的桶不参与「最早清零日」**（`resolve_cohort` 与 `build_context` 用
> 同一条判据），否则标题里的日期会指向一个什么都不会损失的日子、且名单行与信里的日期
> 会对不上。
> **四条硬语义**：① *预览即发送* —— 预览与发送共用 `resolve_cohort`，且 HR 确认
> 时看到的每个人的余额/清零日**冻结**进 `hr_leave_reminder_batches
> .recipients_snapshot`，后台只读快照（否则 06:30 的 HRIS 同步 cron 在中间跑一次，
> 发出去的数字就不是 HR 点确认时那份）；② *群发走 `enqueue_then_drain`*（先 durable
> 落 outbox 再逐条投递，避免 `reference_outbox_fk_lock_login_outage` 那类行锁跨
> HTTP）；③ *没有 cron* —— 唯一入口是 HR 手动触发，「不新增员工自动推送」的既定
> 规矩不动，要自动化必须另立 ADR；④ *投递状态只有一份真相* —— 逐人结果直接读
> outbox（`related_resource_type='hr_leave_reminder_batch'` + `related_resource_id`
> 挂回批次），刻意不建 per-recipient 表。
> **文案 HR 自助**：`email_template.hr_leave.expiry_reminder` 覆盖层（与 global_admin
> 的邮件模板编辑器同一存储，运行时 `_load_template` 无需认识本功能），写入前
> `validate_copy` 逐字段挡三类错误：未知占位符（`_safe_format` 会把 `{name}` 原样
> 发给 500 人）、位置参数/格式说明符（`{0}` / `{days:d}` 会让**整批**投递抛异常）、
> 三语任一必填字段为空。占位符白名单 = `person_name` / `account_lines` /
> `total_balance_days` / `deadline_date` / `deadline_month` / `expires_in_days` /
> `hris_url`（+ 自动注入 `cta_line` / `portal_url`）。`account_lines` 与卡片
> `CardStat` 逐人按收件人语言渲染（一个 envelope 只有一个收件人，所以 context 可以
> 是本地化成品）；新增 `leave_service.localized_type_label()` 补上假期类型的三语标签，
> 免得中文「法定年假」混排进英文邮件。
> **呈现形状（2026-08-18 两轮改版，HR 定）**：额度格子**只有余额**，标题是 HR 的措辞
> `截至目前未休法定年假(FY26)`；财年后缀走 `fiscal_label()`，判据是**周期结束年**
> （`end_date`）而不是 `fiscal_year` 列 —— 生产实测同一个 9/30 清零周期里有 261 个
> `fiscal_year=2025` 和 7 个 `fiscal_year=2026`（1~3 月入职），按列命名会把后者标成
> FY27。**这套 FY 编号比 `core/fiscal_year.py` 大一年**（那边按开始年命名），刻意不复用。
> 主数值走 `[h]…[/h]` 放大标记；
> 卡片**只有一个按钮**，落在 HR 配的 HRIS(SF)入口（`get_hris_url` 出厂值 =
> HR 指定的飞书 applink，没配才降级回 Helix 假期页；applink 的
> `client/message/link/open?token=…` 打开的是一条飞书消息，可见性取决于收件人在不在那个会话，
> 换入口只改配置不改代码）；**正文不写任何余额数字**、也不带
> `{cta_line}`（HR 要求去掉邮件里那行 Helix 链接）；**卡片副标题出厂为空** —— 内置模板
> 里直接没有 `card_subtitle` 这个 key，因为覆盖是**字段级合并**，空字符串会被当成
> 「没覆盖」而回落到内置默认。出厂正文的周期是写死的（FY26 / 9-30 / 10-01），
> 下一个周期由 HR 在页面上改。
> **不提醒名单 + 结尾寄语（2026-08-18 第二轮）**：`hr_leave_reminder_exclusions`
> （迁移 516，与 `attendance_roster_overrides` 同构）是 HR 的长期「默认不勾选」决定 ——
> 命中的人**照常出现在名单里**、带标记、初始不勾，是默认值不是硬闸；`create_batch`
> 里**不传 `person_ids` 的分支也挡**（前端正常路径总是带，这条最容易写漏）。
> 结尾寄语（`hr.leave_expiry_reminder.signoff`）替掉平台通用的「— Helix IT 平台」，
> HR 维护一组三语句子 + 随机/固定；随机是**每批次一句**（预览即发送，逐人随机会让预览
> 只对第一个收件人成立）且**可复现**（种子=批次 id），选中的句子冻结进批次快照。
> **护栏**：`reminded_recently`（`resend_window_days`，默认 7 天）默认排除但 HR 可
> `allow_resend` 强制再催；`expected_recipient_count` 不一致 → 409；同一时刻只允许
> 一个批次在投递（双击不发两遍，>30 分钟的 `sending` 残留不再挡人）；离职/软删/
> 空 work_email 永不入选（如实计入 `skipped_no_email`）；单批上限 1000；`test_mode`
> 只发给发起人且**不计入**去重（测试发送不该让真实收件人被判成「已提醒」）。
> **RBAC**：写侧与两个预览面都是 `require_hr_admin()`（global_admin + hr）——这是平台
> 第一次把通知群发交给非 global_admin，口子只有这一个模板、这一类收件人；
> `/notifications` 后台仍是 global_admin 的地盘。**只有** `GET /config` 与
> `GET /batches`（列表，只有聚合口径）放宽到 `require_hr_read()`；圈人预览**与批次明细**
> （`GET /batches/{id}`）都刻意窄于它，因为两者都是逐人 PII（姓名/邮箱/部门 + 每个账户的
> 授予·已用·余额·清零日，一批最多 1000 人）而 auditor / management 没有发送动机 ——
> MCP 的候选名单工具同理收在 `global_admin + hr`，前门关了后门开着没有意义。写侧 `PUT /config`、`POST /send`、`resend-failed` 额外要
> `require_browser_session`（群发是 catastrophic-if-stolen 动作，PAT 不许触发），因此
> **MCP 只有读工具** `list_leave_expiry_reminder_candidates`，刻意没有发送工具。
> 通知分类新增 `leave`（**action 桶**、可静音、非 critical）——到期提醒的全部价值就是
> 在某个日期前被看见，落 info 桶等于把它从红点里藏掉。
> **助手的写动作从 MCP 注册表派生（2026-08-17）。**
> [`assistant/derived_actions.py`](../../server/app/assistant/derived_actions.py) 与只读桥同源：
> 只读桥取 `mutates=False`，写桥取 `mutates=True`，两者**分区不重叠**（有测试锁）。
> 手写 `AssistantAction` 的四项职责里三项由工具自带（`schema`=`tool.parameters`、
> `normalise`=FastMCP+域服务校验、`execute`=`tool.run()`），只有**确认卡片**需要人写，
> 且改为可选：`actions.get_action()` 精修优先、派生兜底，缺卡片只降级表现。
> 模型侧是两段式 `search_helix_actions` + `propose_helix_action`（106 个 schema 塞不进提示词）。
> `OPT_OUT` 的准则是**只提案员工为自己办的事**：替他人决定、在人之间转移归属、
> 行政台账编辑三类排除，理由是缺上下文的一句话触发本身就是评审屏幕存在的意义。
> **新流程按 MCP-sync 规则带上 MCP 工具后当天即可被提案，助手侧零改动。**
> **管理面的判定改为读「声明的授权」（2026-08-18）。** 原来只认字面 `admin only`，
> 于是 `Restricted to global_admin` / `Requires IT admin role` / `RBAC: can_manage_cloud`
> 这些**真实写法**全部漏过，18 个管理面写工具是可提案的。现在 `_ADMIN_ONLY_PATTERNS`
> 读授权子句，且 **owner 型条款不算管理面**（`the CURRENT scope owner OR a cloud admin`
> 里 owner 通常就是普通员工，把它排除等于把最该一键完成的 owner 确认流程排除掉）——
> 判定只看**匹配到的那一段**，不是整段描述，否则后文解释「owner 是谁」的句子会把
> `Requires IT admin role` 抵消掉。可提案面 100 → 61（三轮:按声明的授权收窄、补上**靠引用继承授权**的兄弟工具（`server_power_off` / `server_restart` 的描述只说「Same RBAC as ``server_power_on``」,没有冒号也没有角色名,读不到）、再手工过一遍全表剔除 IT 自己的队列/配置与两个根本不是「动作」的工具）。
> **第二轮又补一条 `can_*` 谓词分支（同日复审）。** 上面的判定要求授权子句里出现
> `role` / `authority` 这个词，而最常见的写法直接点 capability 谓词、根本不带：
> `Requires can_manage_security (global_admin / auditor)` 与
> `RBAC: ``can_view_finance`` OR ``can_read_cloud``` 因此漏过，
> `complete_access_certification` / `cost_resolve_resource_creator` /
> `cost_retry_resource_creator` 仍可提案（前者的两个同族工具早已手工排除，后两者每次
> 调用还烧一次限流的云厂商审计 API）。新分支的匹配段仍然跑 owner 例外，所以
> 「Requires can_manage_cloud, or the scope owner」不受影响。可提案面 75 → 72，
> 拿真注册表实测，无自助流程误伤。
> 注意 **RBAC 本来就会在执行时拒绝**，所以这不是越权；代价是那个拒绝出现在员工**已经点完**
> 确认之后。

> **卡片上的 ID 必须是模型真查过的（2026-08-18）。**
> [`assistant/corroborate.py`](../../server/app/assistant/corroborate.py)。卡片显示名字
> （`套餐：Zoom Pro`）而提案执行 ID（`plan_id=7f3a…`）——两个值只有一个会执行，配对由模型
> 负责，校验救不了：ID 无论指向哪一行都是合法的。所以改校验**来源**：`*_id` 必须**和它的
> `*_id_display` 一起**出现在这一轮某个工具结果里，靠的是 `run_copilot_turn(observer=…)`
> 观察到的原始返回。JSON 按 JSON 解析（**同一个对象**才算），其余退回邻近窗口匹配 ——
> 只做邻近的话，短列表里相邻两行相距几十字符，「真 ID 配错名字」会被放过，而那正是危险的
> 那一个。不通过就把提案打回并告诉模型去查，代价是一次多余查询；放到执行时再拒绝，代价是
> 员工的信任。
> **判断「谁能提案」要读函数体，不只读描述（2026-08-18）。** `create_procurement_supplier`
> / `create_procurement_order` 在函数体第一行调 `_require_global_admin`，描述里只字未提
> —— 描述型判据（marker + pattern）看不见。找出它们靠的是人读 61 个函数，这种事不会有
> 第二次，所以改成 **AST 扫描**：`tests/test_assistant/test_review_round_1.py` 里那条
> `test_no_proposable_tool_gates_itself_on_an_admin_role` 遍历全部可提案工具的函数体，
> 命中 `_require_*` 即失败。**在函数体里自己设闸的工具不能被提案**——RBAC 会拒，但那个
> 拒绝出现在员工**已经点完**之后。
>
> **`card_labels.TITLES` 不只是文案，它是发现率（2026-08-27 实测）。**
> [`action_search._corpus_text`](../../server/app/assistant/action_search.py) 与
> `derived_actions.search()` 的 haystack 都是「工具名 + **各语言标题**」——缺 TITLES 条目时，
> `_title_for` 回落到 `humanise(工具名)`，于是 zh 那份 haystack 里放的是一句英文，
> 中文提问对这个流程**一条都命中不了**，助手回落到「给你开个工单」。#3283 的
> `describe_my_approval_flow` 就是这样：「帮我补一句说明」（这句写在工具自己的 description 里）、
> 「流程用途说明」、「改一下审批流程的说明」三条全部排不进前四；补上标题后三条全部命中第一。
> 所以「补文案」和「让流程可用」是同一件事，不是收尾美化。配套的三条闸
> （`test_every_card_has_a_curated_heading` / `test_every_parameter_row_has_a_label` /
> `test_the_catalogue_cannot_grow_unnoticed`）**必然同生同死**：新增一个自助写工具会自动进
> `derived_catalogue()`，既长了目录、也缺文案——上限那条红了就一定要连带跑另外两条，
> 只改数字会把真缺陷一起放过去。
>
> **条件自绑定字段（2026-08-18）。** `target_email` 只在 `target_kind` 是用户时才等于
> 「本人邮箱」。无条件绑定（那本身是修一个真实事故：模型把它填成了**正在创建的别名**）
> 让邮件组分支的卡片写着「目标邮箱 = 你的公司邮箱」而旁边是「目标类型 = 邮件组」，
> 且执行必然失败。`_CONDITIONAL_SELF_BOUND` 的规则是：**保留绑定但限定范围** ——
> 模型 schema 里保留该字段但不设为必填（否则本人分支反被要求填），`normalise` 在非本人
> 分支**重新要求**它（否则会在点完之后才失败），卡片按当次是否绑定决定渲染「你的公司邮箱」
> 还是真实值。

> **飞书消息去重字段的 50 字上限（2026-08-17，生产实测）。**
> `POST /im/v1/messages` 的 `uuid`（一小时幂等键）上限 **50 字符**，超长时飞书以
> `99992402 field validation failed` 拒绝**整个请求**——在校验收件人之前，所以错误里
> 没有任何长度线索。`assistant-proposal-<uuid4>` = 55 字，导致确认卡片 100% 发不出去。
> 归口在 wire 契约的归属层 [`feishu_client._message_dedup_field()`](../../server/app/approval/feishu_client.py)：
> 超长取 sha256 前 32 位而**不是截断**（共享前缀会被飞书当重复消息丢弃），短 key 原样透传。
> 任何新的发消息调用方自动受保护，不需要各自记住这个限制。

> **飞书渠道有自己的系统提示词（2026-08-17）。**
> **一个回合的自我保护（2026-08-19 复审）。** `run_copilot_turn` 与它的工具分发**都不 rollback**，
> 所以 `run_turn` 的 except 里必须自己 rollback —— 否则紧接着的转录写入抛 `PendingRollbackError`
> 穿出去，员工**一条消息都收不到**。实测两条容易搞错的事实：失败的 **SELECT 不毒化** session、失败的
> **flush 才会**；rollback 后 `db.new` 已空（不用 expunge），真正的残留问题是 **`person` 实例过期**
> —— 所以 `person_id` 在 try 之前捕获，并在 rollback 后 `refresh(person)` 供调用方（`feishu_events`
> 的审计行）使用。conversation 行**必须先 commit**：不然失败路径的 rollback 会连它一起回滚，
> `_record` 插消息撞 FK 1452。空回复记 `OUTCOME_FAILED`（不进 helpful-rate 分母）。
>
> **reset epoch（同上，复审后加固）。** `clear_history` 用**一个 Lua 脚本**同时 INCR
> `assistant:epoch:*` 与删历史（分三次往返时，中间启动的回合会读到**新 epoch + 旧 history**，
> 于是它的过期写反而通过了 CAS），并**返回是否成功** —— 失败时不再对员工宣称「已重置」。
> epoch TTL 必须**远长于** history TTL（10× 上限）：短了会白白拒掉合法写，且第二次 reset 对已过期
> 的键 INCR 又得到 1，让在飞回合的过期写被放行（ABA）。`save_history` 是 Lua CAS 条件写。
> `_record` 的 commit **必须捕获 `IntegrityError` 并重读 `turn_count` 重试** —— 唯一约束加上之后，
> 不捕获就等于把「转录乱序」换成「整个回合丢失」。提案的 `conversation_id` 在回合开始时解析并复用
> （旧的「取最新一行」会把新话题的第一个提案挂到上一条已结束的线程上）。
>
> **🔴 `create_time` 必须过 `math.isfinite`（2026-08-19 复审）。** `nan <= 0` 与 `nan >= 1e12` 都是
> False，`abs(now - nan) > 300` **也是** False —— 所以 `create_time="nan"` 让 300 秒鲜度窗口彻底
>失效，而那正是卡片点击豁免防重放之后剩下的唯一保障。信封路径同时**要求 `event_id`**：没有它防重放
> 无从下手，接受就等于让一整类事件零保护。
>
> **🔴 DashScope embedding 单次最多 10 条输入（2026-08-19，实测：10 过、11 拒）。**
> `BailianLLMClient.embed` 现在按 10 **分批（`TaskGroup` 并发上限 8、保序、整体 45 秒截止）**
> —— ⚠️ 必须用 `TaskGroup` 而不是 `gather`：`gather` 不取消兄弟，首批失败后 27 个协程会继续打接口
> 一两分钟且无人读结果；`BaseExceptionGroup` 要**解包回原异常**，否则 `embed` 承诺的 `AppException`
> 契约对调用方就变了。总时限用**常量本身**，`max(timeout, 常量)` 会把 ceiling 变成 floor。
> 实测（2026-08-19）：单批 10 条 2.5–2.9s，60 条语料 6 批并发 5.3s ——
> ⚠️ `timeout` 是**单次**请求的界，串行分批时 28 批各自可用满预算（后端超时时 ~15 分钟，全在一个
> 用户可见 turn 的单次工具调用里，内核 turn 预算拦不住），修在产生端所以全部调用方受益。
> 为什么这条要写进平台文档：助手的语义检索预热要一次 embed **273 个只读工具**的语料，于是
> 每一次都是 HTTP 400 —— **语义检索从 #3054 上线那天起就没工作过一次**，一直静默降级到词法
> 匹配；而词法语料是**英文**，中文查询「软件目录」命中 0，助手因此告诉员工「OpenAI 不在
> Helix 的软件目录里」（目录里有 `OPEN AI` + active 坐席套餐）。知识库那条路径每次只 embed
> 一篇文章，所以从没撞上、也从没暴露它。`action_search` 在无法 embed 时**必定**打 warning
> 写明「已降级为词法匹配」—— 旧代码 `embedded is None` 时什么都不记，这个洞才藏得住。
>
> **目录按人说的名字查（同上 PR）**：`list_software_catalog(search=…)` 匹配**忽略大小写、
> 空格与标点**（`_normalise_product_name`），「openai」→「OPEN AI」；返回带 `total_matched`。
> 默认 `page_size` 50→100，因为生产 57 条 active 条目中有 **7 条（含飞书、阿里云）在第 2 页**，
> 只读第一页就断言「没有」是够得到的错误。提示词里配套一条**接地规则**：没有工具结果不许断言
> 「Helix 没有这个」。
>
> **助手能不能答，取决于三件事，都不是模型（2026-08-19）。** 生产一次问答
> （「Netsuite 这个应用的负责人是谁」→「Helix 没有记录」，而目录里写着邱芳）拆出三层，
> 每一层都是**可复现、可度量**的，排查时按这个顺序看：
> ① **工具返不返回那个字段** —— `list_software_catalog` 当时不返回 `owner`/`it_contact`，
> 调对了也答不出。**这一类在 2026-08-19 逐个工具实测字段面时又找到三个**：`list_my_assets`
> 缺 `warranty_until`/`purchase_date`；`whoami` 只给 `manager_person_id` —— 而**能解析 person id
> 的工具是 HR 闸门**，所以一个 UUID 对助手等于没有答案，现补 `manager_name`/`manager_email` +
> `direct_report_count`/`peer_count`（复用 `build_my_team_summary`，含「离职经理不报名字」）；
> `list_my_cloud_grants` 只给 `scope_id`，现补 `scope_display_name`/`external_id`/`environment`/
> `provider`（整页两次预取）。
> **判据**：拿真数据跑一次工具，看 payload 里有没有网页那一列，**以及有没有把 UUID 当答案返回**
> —— 后者是同一个缺陷换了个样子，而且只有实跑才看得见。
> ② **描述里有没有那个词** —— 只读桥的排序是**语义**的（不是这份文档旧版说的词法），
> 语料 = `工具名 + 描述`。描述不提「负责人」，该问句就排到 39/274，桥只回 top-6 ⇒ 模型
> 拿到的是 `list_sso_apps`。**语料曾被截到 400 字符，273 条里 116 条超长**，`whoami` 写在
> 第 420 字符的「我的工号/GitHub/办公地是什么」从没被 embed 过——现改为整段（上限 2000）。
> **判据**：`action_search._corpus(CORPUS_READONLY)` + `embed_texts` 对真问句排一次序，
> 看正确工具在不在前 6。（这套评测脚本值得重跑：50 条中文问句，31 条 top-3 因此改变。）
> **写描述的两条实测规律**（改描述前先读，否则白改）：①**语料是逐字 embed 的，否认句会把它否认的
> 那个问题吸过来** —— 给三个「工号台账」工具加上「NOT the answer to 「我的工号是多少」」之后排序
> 毫无改善（那句话把「我的工号是多少」原文塞进了错误工具的向量里），改成正面陈述「这是 HR 发号
> 台账，本人的号在 whoami」才是对的。②**要用员工的词，而且要用他们的语言**：中文问句遇上英文
> 描述必然靠 embedding 跨语言，把关键中文短语直接写进描述会同时把词法降级路径也救回来。
> 复跑判据：`server/scripts/eval_assistant_retrieval.py`（50 条真实问句，可带参数问自己的；
> 它的 docstring 里记着 2026-08-19 基线和**仍然排不对的 10 条**，那就是待办清单）。
>
> ③ **结果有没有被悄悄切断** —— 桥对单次结果有 6000 字符上限，此前是
> `json.dumps(...)[:6000]`，软件目录整表 29k ⇒ 模型只看到 57 个里的 17 个、JSON 还是断的，
> 于是「平台没有 X」。现返回**显式信封** `{truncated, total_chars, shown_chars, warning,
> partial_result}`。**判据**：`assistant.message.received` 的 `detail.tool_calls` 找到那一轮
> 调了什么，再用同参数复跑量一下 payload 长度。
> 配套：桥搜索无结果时的提示指向 `search_knowledge_base`（生产 **246 篇**已发布、全部已 embed
> 的飞书镜像文档），提示词也点名了它——此前 Feishu 渠道提示词一个字没提知识库，how-to 类问题
> 是拿模型通用常识答的；关于本人的问题（工号/部门/经理/入职日期）一律先 `whoami`。
>
> **知识库语义检索的成本（2026-08-19）。** 排序读 `(id, embedding)`，命中的才完整载入；语料
> 按进程缓存，失效判据是同一组行的 `COUNT + MAX(updated_at)` 探针 + 300s TTL（`updated_at`
> 是秒精度，同一秒两次改动 + 行数不变会算出同一个签名）。**改之前每次查询 `select(SupportKbArticle)`
> 整表** —— 生产 246 篇带 5.6 MB 向量 + 0.66 MB 正文，为给 6 条结果拉 6.3 MB 并 hydrate 246 个
> 对象，在员工等着的那一轮里，且随飞书镜像线性增长。**测试里这个缓存必须清**（`tests/conftest.py`
> autouse `_clear_kb_vector_cache`）：两个各建两篇文章的测试在同一秒会撞出同一个签名。

> **MCP 目录/组织读面（2026-08-19，+2 → hint 487）。** `list_directory_people`（部门/状态/角色
> 筛选，`total` 是**筛选口径**人数）+ `list_departments`（部门树：父节点/org_path/层级/人数），
> 都是 `GET /directory/people` 与 `GET /auth/departments/tree` 的 1:1 twin，沿用
> `can_view_directory` 管理员闸。此前 MCP 只能按名字解析**一个人**（`search_directory_people`，
> 员工可用、防爬形状不变），「这个部门有多少人」「有哪些部门」**没有任何工具**。

> **助手的时钟 + 日期字段方向（2026-08-19）。** 提示词每轮注入**员工本地时区**的当天日期／星期
> （`working_hours.resolve_timezone`，与通知、入职日闸门同一个解析器）——没有它模型会回落到训练期
> 年份（生产：「有效期到 9 月 20 号」→ `2024-09-20`）。日期字段的方向**声明在字段上**：
> `app/mcp/tools.py` 的 `FutureDate` / `PastDate`（`json_schema_extra={"x_helix_date": …}`，
> 实测保留进 `tool.parameters`），助手的 `_check_date_directions` 通用地读它并在**提案时**拒绝，
> 而不是等 execute 时在员工点完之后失败；拒绝信息带当天日期。**不要改成「一律不许过去日期」**：
> 真清单里 `feed_update_my_preferences.birthday` 必须是过去、`update_my_software_catalog_entry
> .expiry_date` 可能已过期。字面 `\n` 的反转义在 `actions.unescape_literal_newlines`
> （只在整串无真换行时生效）。
>
> [`assistant/prompt.py`](../../server/app/assistant/prompt.py) 不是 web copilot 提示词的副本：
> 后者为「页面 + 按钮」而写，会让模型引导员工去点聊天里不存在的「转人工 / 开工单」。
> 渠道提示词把可提案动作**从注册表派生**（新增白名单动作即刻可提案，无需改提示词），
> 并要求「能做就提出来做，缺字段只问缺的那一个」。`run_copilot_turn(system_prompt=…)`
> 是唯一注入点，web 侧默认值不变。

> **员工意图、制度证据与管理员预演必须同源（2026-08-19；0 migration / 0 MCP）。** 员工助手的
> 通用次序是：①先判定是否有可执行流程并发现动作；②需要时读取**该员工**有权限看的本人数据；③查询
> 有关 KB 制度；④两者都没有可信证据时，明确不能确认并走人工/工单。模型不得把通用常识说成公司政策。
> 电脑回购是这个规则的代表：识别可回购设备、使用现有 `offboarding_buyback` 审批流，并仅在检索到来源时
> 补充政策；无来源就不报期限或价格。管理员的 `/support/admin/kb/debug/query` 必须选定被模拟员工，调用
> 同一 Feishu prompt、MCP 只读桥、动作发现和提案校验；它只把提案替换为 dry-run 预览，绝不写
> conversation/proposal/request 或发送卡片。预演明确是**新会话**，真实会话的实际 KB 来源则从
> `assistant_messages.sources` 返回到受限审计页。KB 的高置信语义命中不再以低相关关键词结果“补满”，
> 防止无关文档被模型视为依据。飞书根目录配置保存“说明 + URL + token”；URL 是管理员可识别、可打开的
> 配置面，token 仅是同步实现细节，旧 token 配置在读出时规范化为 URL。

> **可执行规则与历史 KB 冲突时，员工答案以当前规则为准（2026-08-19；0 migration / 0 MCP）。**
> 回购/买断是第一条落实该不变量的路径：每轮都做无副作用的 policy preflight，**不把旧 assistant
> message 当作事实**。当前 `approval.buyback_price` 的 36 个月（3 年）审批/报价规则是运行时权威；只
> 有同样支持 36 个月的 KB 文档才进入员工上下文。任何写“满 2 年”的镜像文档都不会被员工答案引用，而是
> 通过管理员 KB 预演的 `policy_conflicts` 返回，直接指向要修订的飞书源文档。该做法避免“回答第一次检索
> 错了，后续会话不断复读”以及“管理端没有检索却看似得到政策”的两类假一致；同一 preflight 同时写入
> Feishu 与管理员预演的来源轨迹。

> **Ask Helix 对话审计面（2026-08-17；PR6/6，+3 MCP→hint 475）。**
> [`assistant/admin_service.py`](../../server/app/assistant/admin_service.py) +
> `admin_router`（`/api/v1/assistant/admin/{conversations,conversations/{id},stats}`）+
> 前端是 `/admin/audit?tab=assistant`(2026-08-17 起并入审计日志的第 4 个 tab；旧的
> `/admin/assistant-audit` 302 进去)。**闸门刻意窄于 `require_admin_read`**：只
> `global_admin` + `auditor`，不含 `management`——转写里是员工描述问题的原话，比领导层只读层
> 平常看到的治理数据敏感一档。列表筛选用相关子查询 `EXISTS` 而非 join（一条对话里三条消息命中
> 不该变成三行）；转写按 `seq` 排序，失败回合同样返回。
> **被拒工具调用是一等公民**：只读桥拦下的调用落一行独立 session 的 denied 审计
> `assistant.tool.refused`（调用方事务可能回滚，跟着回滚的拒绝等于没有审计），管理页做成头条
> 数字 + >0 警示。MCP 三个 1:1 只读孪生 `assistant_list_conversations` /
> `assistant_get_conversation` / `assistant_usage_stats`，同样 admin-only。
>
> **Ask Helix MCP 只读桥（2026-08-17；PR5/6）。**
> [`assistant/mcp_bridge.py`](../../server/app/assistant/mcp_bridge.py) 让助手能调平台既有的
> 270 个只读 MCP 工具。**目录派生自 `mutates=` 真值**（`mutating_tools_from_source()`），不是
> 第二份手工清单——工具改成写数据就在同一个 commit 离开目录；`DESTRUCTIVE_TOOLS` 再排一道。
> 模型只见两个元工具 `search_helix_tools` / `call_helix_tool`（400+ schema 塞不进 prompt，
> 且模型面对几百个工具选不准）；检索**自 2026-08-18 起是语义的**（这里原写「词法的、不值得建
> 向量索引」——那个判断假设了查询是英文，见上面 2026-08-19 那条），词法保留为降级路径。调用者身份写进 `mcp_user_id` 上下文变量（工具运行时的 RBAC/审计/限速/离职闸都
> 挂在它上面），取自已验签飞书信封而非模型输出，并在 `finally` 复位（泄漏一次会让下一个调用者
> 顶着这个人跑）。隔离原本保护的两件事没有放宽：越权仍由工具内部领域 RBAC 对服务端解析出的
> 调用者强制，误写由派发前硬过滤挡住。
>
> **Ask Helix 动作目录与提案工具面（2026-08-17；PR4/6）。**
> [`assistant/actions.py`](../../server/app/assistant/actions.py) 白名单现含建单、VPN 申请、
> 设备借用/报修。VPN 走 `VpnApplicationService.submit`（服务自带离职闸与 in-flight 锁，正是
> 为绕过 HTTP 的调用方加的）；设备走 `AssetService.create_employee_request`——**该服务没有任何
> 去重**，重复调用会开第二张单，因此提案层的一次性 token 在这条路径上是承重件。资产申请类型
> 刻意只开 `self_borrow` / `repair`（claim/handover/return 转移归属，属于要看着设备操作的场景）。
> [`assistant/propose_tools.py`](../../server/app/assistant/propose_tools.py) 为每条动作生成
> `propose_<key>` 工具：只校验参数、存提案、发卡片，**没有任何工具会提交**。当事人取自
> dispatcher 闭包（已验签的飞书信封），不取自工具入参。
> [`run_copilot_turn`](../../server/app/domains/support/copilot.py) 新增可选
> `extra_tools`/`extra_dispatch`，严格增量且内建工具名优先（调用方无法遮蔽内建工具），
> 网页版 copilot 行为不变。**软件坐席未纳入**：其逻辑在 `asset/router.py` 与 `mcp/tools.py`
> 各内联约 250 行，需先提取为服务函数再接入，属独立 PR。
>
> **Ask Helix 写路径：提案 → 过目 → 确认（2026-08-17；PR3/6，迁移 486，总闸关闭）。**

> **Ask Helix 写路径：提案 → 过目 → 确认（2026-08-17；PR3/6，迁移 487，总闸关闭）。**

> **Ask Helix 写路径：提案 → 过目 → 确认（2026-08-17；PR3/6，迁移 488，总闸关闭）。**

> **Ask Helix 写路径：提案 → 过目 → 确认（2026-08-17；PR3/6，迁移 489，总闸关闭）。**


> **Ask Helix 写路径：提案 → 过目 → 确认（2026-08-17；PR3/6，迁移 489，总闸关闭）。**
> 模型永远不执行 mutation，只能**提案**。
> [`assistant/actions.py`](../../server/app/assistant/actions.py) 是唯一的白名单，每条动作
> 自带 `schema`（模型可填什么）/ `normalise`（服务端校验，**枚举一律校验不静默兜底**——领域
> 服务对未知枚举回落默认值，对网页表单是对的，但员工正要批准一份摘要，被悄悄改写的值会让
> 摘要变成谎话）/ `summarise`（确认页逐行展示，没出现在摘要里的参数＝员工没批准过）/
> `execute`（只调领域既有服务，RBAC/幂等/通知/审批行为与网页表单完全一致）。
> [`assistant/proposals.py`](../../server/app/assistant/proposals.py) 存
> `AssistantActionProposal`（迁移 489）并签发 `purpose="assistant_action"` 的一次性
> action token；确认时 ①重新读库拿参数（**模型不再被咨询**）②比对调用方回显的 `params_hash`
> ③`enforce_action_token_subject` 复核当事人生命周期（token 活一天，人可能在这天里离职）
> ④`consume_action_jti` 单次消费。**按下确认前先 commit `confirmed` 再执行**——领域服务内部
> 自己 commit（`SupportService.create` commit 两次），中途崩溃不能留下「单已提交但提案仍
> pending」的记录。失败释放 jti 让链接可重试。
> [`assistant/router.py`](../../server/app/assistant/router.py) **只剩只读 GET**
> （2026-08-19）。确认页**不能提交任何东西**——不是加了判断，是**删掉了那条路由**。
> 提交只存在于卡片的原生 `card.action.trigger` 回调，那里身份来自回调信封里的操作者。
>
> **卡片回调不带任何 `X-Lark-*` 头（2026-08-19，血的教训）。** `card.action.trigger` 只发
> v2 信封——没有签名、没有 `X-Lark-Request-Timestamp`、没有 nonce（`im.message.receive_v1`
> 是带的，所以聊天一直正常）。共享 webhook 的 token 路径原先把时间戳头当强制项，于是**整个
> 事件类型被 401**，飞书客户端显示为「Something went wrong … code: 200671」——上线以来
> 每一个卡片按钮（含「确认提交」）都没成功过一次。鲜度现在取信封自身的
> `header.create_time`（毫秒/秒按量级判定），防重放取 `header.event_id`；卡片点击是防重放
> **硬拒的唯一豁免**（飞书重投用同一个 event_id，无法与重放区分，而三个分支本就各自幂等）。
> 与此相关的另一个官方约束：**回调必须 3 秒内响应**（超时码 200341），所以
> `handle_card_action` 与 2.2 秒预算赛跑——快的就地作答，慢的返回无按钮的「正在提交」卡并把
> 结果作为独立卡片补发；它**不接收请求 session**，自己开一个（会比请求活得更久，且碰调用方
> session 会开出快照早于自己提交的事务）。
>
> 因此 `card_actions` 的授权强度＝**共享验证 token**（与所有审批回调同级），不是逐请求签名；
> 早先 docstring 与本文件都写成「飞书验签的操作者信封」，那句话是错的。
>
> 为什么删而不是补:这个面被连续四轮修改，每一轮的修复都是下一轮的缺陷——加登录墙
> （挡住了 iOS 飞书，webview 丢 refresh cookie）→ 放开 GET（按钮还亮着，点了必然 401）
> → 关掉按钮（公开路由不走 `AuthRoute`，`initialize()` 从不被调用，连本人都点不到）。
> 根因是**在给一个「从可转发卡片打开的网页」做身份认证**，这个问题本身无解。
> 删掉动作，问题消失。GET 保留:卡片单值截断 120 字，这个页面把剩下的展示出来;
> 非本人看到同样的截断、拿不到 `result`。
> 而飞书卡片**默认可转发、签名 token 就在卡里** —— 同事收到转发即可以本人名义提交；
> GET 也不只是「卡片上已有的内容」，它比卡片多 5 倍（卡片单值截断 120 字，这个页面存在的
> 意义就是展示其余部分）并且执行后还回传 `result`。摩擦落在**兜底路径**上：两种卡片现在
> 都带原生回调按钮（`escalation.py` 曾漏传 `proposal_id`，导致转人工那张卡只有外链）。
> token 有效期也从 1 天收到与提案同寿（60 分钟）。**刻意不配 MCP
> 工具**——让 agent 能替人按确认键会抵消这条路径存在的意义（MCP-sync 规则的有意例外）。
> [`assistant/confirm_card.py`](../../server/app/assistant/confirm_card.py) 复用平台既有
> `_build_interactive_card` + URL 按钮：飞书 `CardAction` 结构上没有 value/action_id 字段，
> 回调按钮无法表达，且卡片回调需另配开放平台回调地址并再发一次版；URL 按钮反而让参数快照有
> 地方真正展示（前端公开页 `/assistant/confirm`）。调 builder 时必须同时传
> `extra_action_urls`（索引对齐）——渲染器会静默丢弃 URL 为空的按钮。
> 首个动作＝转人工建单，三语关键字**确定性**触发（要人工不能取决于模型是否可用），草稿由既有
> `draft_ticket_from_history` 纯函数生成。
>
> **工单降级的守卫看「模型这轮自己做了什么」，不问目录（2026-08-19）。**
> `propose_tools._TurnFlowMemory`（`make_dispatch` 的闭包＝一个 turn，无生命周期可管）记两件事：
> 本轮哪些流程因**参数**被拒、本轮 search 搜到过什么。`_refuse_ticket_if_a_flow_fits` 按证据
> 强度三分支：①有流程只是缺值 → 拒建单并回传那些流程+校验信息（「少一个字段」≠「没有流程」）
> ②搜到过流程 → 拒建单并点名模型**自己搜到的**候选 ③压根没搜 → 拒建单但**不点名任何流程**，
> 只要求先搜。**每轮只拒一次**，第二次放行 —— 永远无法满足的守卫最终会否决合法升级。
> ⚠️ **不要在③重跑目录匹配**：无 embedding 后端时跑的词法匹配会给出「椅子坏了」→
> `complete_api_key_rotation`（CJK bigram），点一个荒谬流程比不点名更坏。旧的
> `_better_flow_than_a_ticket` 因此删除。`action_search._failed` 是**带 10 分钟冷却**的 dict
> 而不是永久 set：一次 embedding 抖动曾让整个进程生命周期都降级到词法匹配。
>
> **Ask Helix 对话内核（2026-08-16；PR2/5，迁移 489，总闸关闭）。**

> **Ask Helix 对话内核（2026-08-16；PR2/5，迁移 485，总闸关闭）。**




> **Ask Helix 对话内核（2026-08-16；PR2/5，迁移 487，总闸关闭）。**
> **Ask Helix 对话内核（2026-08-16；PR2/5，迁移 489，总闸关闭）。**
> 通道接的是**已在生产运行的**模型内核
> [`app.domains.support.copilot.run_copilot_turn`](../../server/app/domains/support/copilot.py)
> ——可插拔百炼/Anthropic、按本人收口的只读工具、grounded-or-escalate；助手层不新建第二套
> Agent 框架，只补聊天独有的几件事。
> [`assistant/session.py`](../../server/app/assistant/session.py)：Redis 短期记忆按
> **服务端认定的 person + chat** 分键（跨人隔离是「根本不加载」而非「过滤掉」）、轮数与 TTL
> 走 `bounded_int_setting`、成对裁剪（奇数裁剪会让助手消息变成线程开场白，模型会读成自说
> 自话）、三语 `RESET_COMMANDS` 确定性清空；每 chat 一把 `acquire_turn_lock`（相隔一秒的
> 两条消息否则读同一份历史并乱序作答），带 TTL 自愈，`finally` 释放。
> [`assistant/conversation.py`](../../server/app/assistant/conversation.py)：编排一个回合，
> 超过 `HOLDING_NOTE_AFTER_SECONDS`（4s）发一条占位消息（快回答只发一条），模型异常/
> 未配置/空回复统一降级为带下一步的确定性三语回复；**只有真正回答过的回合进入短期记忆**
> （把「暂时不可用」当上下文回放会污染后续每一轮）。迁移 486 的
> [`assistant_conversations` / `assistant_messages`](../../server/app/assistant/models.py)
> 是持久转写：刻意不放进 `audit_logs`（员工自由文本不属于审计脊柱，且答案的证据——工具
> 调用、provider、耗时——有自己的形状），**失败回合同样落库**，会话行按短期记忆窗口分段，
> 这是 PR5 管理端对话审计面的数据源。
>
> **Ask Helix 助手通道（2026-08-16；PR1/5，总闸关闭）。** 新增跨域编排层
> [`app/assistant`](../../server/app/assistant/)（与 `app/intelligence` 同性质：不拥有任何
> 业务表，只经既有 RBAC/审计/幂等面触达能力）。**一个飞书自建应用只有一个事件订阅 URL**，
> Helix 的应用早已把它指向
> [`POST /api/v1/approval/feishu/callback`](../../server/app/approval/router.py)，因此订阅
> `im.message.receive_v1` 后聊天消息必然落到该端点——新开 endpoint 收不到任何事件。
> [`assistant.feishu_events.try_claim_event`](../../server/app/assistant/feishu_events.py)
> 是加在**全部校验之后**（HMAC / verification token / 300s 新鲜度 / nonce 重放闸 / webhook
> 限速）的一个事件分发分叉：认领 `ASSISTANT_EVENT_TYPES`，立即 200 ACK，用
> `core.background.spawn` 在独立 session 里跑回合；其余事件路径逐字节不变。
> 身份由 [`assistant.identity.resolve_sender`](../../server/app/assistant/identity.py)
> 从 `union_id`（回退 `open_id`）解析，语义与飞书 SSO 一致（provider=`feishu`、active、
> 未软删、`is_login_identity`）——**发信人是谁由服务端查表决定，不采信消息正文或模型输出**，
> 这是提示词注入拿不到他人数据的结构性原因。`left`/软删拒绝，`leaver_pending` 保留只读。
> 出站复用 [`FeishuApprovalClient`](../../server/app/approval/feishu_client.py) 与
> `chat_approval` 同一行凭证（零新增应用/密钥/环境变量），投递失败只记日志不上抛，避免把
> 已处理的消息变成飞书重投。`event_id` 走 Redis SETNX 去重、每人每小时回合上限，二者
> Redis 故障均 fail-open。`sender_type != user` 的消息一律忽略（机器人回应机器人＝
> 死循环）。**群聊自 2026-09-08 起在通道内(见下条),不再一律忽略。**
> 总闸 `assistant.enabled` fail-closed 且严格 `is True`。审计
> `assistant.message.received` 逐条落库（不含员工自由文本）。运维见
> [docs/ops/assistant-feishu-channel.md](../ops/assistant-feishu-channel.md)。
> 决策见 DECISIONS 2026-08-16（改写 2026-06「聊天面到不了 MCP」）。

> **助手进群:判据是「我被 @ 了」,不是「消息被投给了我」 (2026-09-08; 迁移 632 / 0 端点 / 0 cron / 0 MCP 增减 / 0 RBAC)。**
> [`assistant/group_chat.py`](../../server/app/assistant/group_chat.py) 是群聊侧的全部策略。
> - 🔴 **入口判据。** `addressed_to_bot(mentions, bot_id=…)` —— `bot_id` 来自 `GET /bot/v3/info`(Redis 缓存 1h,无需 scope)。**刻意不用「飞书投递了这条消息」来推断「我被 @ 了」**:投递范围取决于应用持有哪些权限(`im:message` vs `im:message.group_at_msg`),权限一变,推断静默失效成「机器人回答它偷听到的每一句话」。身份取不到时降级为「有任何 @ 就应答」,**没有 @ 仍然不处理** —— 那是「偷听/应答」的分界线,不因降级而移动。
> - **两种群两个开关**:工单群(`assistant_handoffs.feishu_chat_id` 认得出)走 `assistant.group_chat_enabled`(默认**开**),其余走 `assistant.group_chat_any_enabled`(默认**关**);另有 `assistant.group_turns_per_hour`(默认 120)是**每个群**的额度,与每人额度独立。三行由迁移 632 播种进 `platform_settings` —— 设置页列的就是这张表,**不种行 = 管理员找不到这个开关**。
> - **`channel.ReplyTarget`**(dm / chat / chat+reply_in_thread)取代散在各处的 `open_id=`:入口解析一次,十余个发送点共用。两个可选 kwargs 的写法会把「答到哪个房间」的正确性摊到每个调用点,漏一处就是群里的问题被私聊回答了。工单群平铺、任意群话题回复。
> - **群里不念私事**:`extra_system` 追加一段群守则(不是第二套提示词);确认卡与个人记录走私聊 + 群里一句「已私信」;`assistant:active_chat` 不被群发言改写;裸资产号回购捷径群内不触发;工单群内的「转人工」不建第二张工单。
> - **`assistant_conversations.chat_kind`**(`dm`/`handoff`/`group`)。任意群不在任何表里,靠 join 分不出它和私聊 —— 而两者的成本、风险、阅读方式都不同。同值进审计 `detail.group_kind`,两处取自同一组常量。
> - 认领 `im.chat.member.bot.added_v1` / `deleted_v1`:进群自我介绍(该应答的群才发)、被移出记 `assistant.chat.membership`。
> 运维与飞书侧订阅步骤见 [docs/ops/assistant-feishu-channel.md](../ops/assistant-feishu-channel.md)。

> **工单群 = 工单的第二个面 (2026-09-08; 迁移 633 / +1 端点 / +1 MCP → 627 / 0 cron)。**
> - **注册表**:`assistant_ticket_chats`(`ticket_id` 与 `feishu_chat_id` 双唯一)是「工单 ↔ 群」的唯一出处;`assistant_handoffs.feishu_chat_id` 退化成那次转人工的历史记录。**为什么不是 handoff 上的一列**:handoff 是写完不变的**事件**,群是有生命周期的**对象**(加人 / 卡片 / 解散),而且群不再只从转人工来。历史行由迁移回填 —— 不搬会让既有房间掉进「任意群」那条默认关着的闸。
> - **建群只有一个入口**:[`assistant/ticket_chat.py`](../../server/app/assistant/ticket_chat.py) + [`core/coordinators/ticket_chat_coordinator.py`](../../server/app/core/coordinators/ticket_chat_coordinator.py)。三个面(转人工按钮 / 门户工单详情 / 后台工单页 + MCP `open_ticket_chat`)共用;拉不到第二个人就不建群并说明原因;求助人开群且无人认领时取**兜底路由负责人**。
> - 🔴 **开群 = 有人接手。** 两条开群路径都在建群**之前**指派工单。此前「转人工」拉了人却不指派,于是 SLA 首响永远记不上、看板上那是一条没人管的记录。
> - **群里那张卡**([`ticket_card.py`](../../server/app/assistant/ticket_card.py))走平台渲染器 `_build_interactive_card`;`card_message_id` 存在注册表行上,状态变化靠 `PATCH /im/v1/messages/{id}` 原地重画。终态卡不留会被拒的按钮。
> - **状态同步**:`SupportService` 的六个转换点调 `schedule_ticket_chat_refresh(note_key=…)` —— **只排后台任务**,任务自己开 session 重读工单。绝不在未提交的事务里做外部往返(2013 的形状),也绝不把「刚才发生了什么」画上去(调用方可能回滚)。
> - **群里的命令**([`ticket_commands.py`](../../server/app/assistant/ticket_commands.py))走字面匹配:状态 / 认领 / 转给 @X / 拉 @X 进来 / 优先级 / 帮助。被 @ 的人取自验签过的 `open_id`,不取自正文;认不出登录身份时用通讯录 open_id → 邮箱 → 人。
> - 🔴 **RBAC:`assignee` 是这张工单的处理人。** `support.service.can_act_on_ticket`(global_admin **or** assignee)是唯一判据,HTTP / 群命令 / 卡片按钮 / MCP 四个面共用。后台六个 mutation 的门从 `require_admin()` 换成 `get_current_user` + 这个判据;**队列读面不变**(能动一张 ≠ 能看全部),工单详情读面跟着放宽(能动手就该看得见)。求助人不在这一档。

> **群里的讨论落到工单上 (2026-09-08; 0 迁移 / 0 端点 / 0 MCP / 0 cron)。**
> - [`room_history.py`](../../server/app/assistant/room_history.py):**按需**读一次房间(`GET /im/v1/messages`,分页、只取文本类、剔除机器人自己的消息、`open_id` → Helix 人名)。助手**不订阅**群消息;`总结` 是唯一触发读取的路径,每次读取落一行 `assistant.chat.history_read`(条数与截断标记,**不含正文**)。读失败返回 `None` 而不是空记录 —— 「读不到」和「没人说过话」是两件事。
> - [`room_summary.py`](../../server/app/assistant/room_summary.py):四栏(问题/已做/待办/结论),走 `copilot.one_shot_json`。**模型不可用时什么都不写** —— 一段编出来的小结会被下一个人当成事实。
> - 小结**逐字**同时发到群里和写进工单时间线。新消息种类 `MSG_NOTE` + `SupportService.add_note`:记录不是回复(`add_reply` 会把状态推到 `waiting_requester` 并暂停 SLA),而且前端 `system` 是一句居中小灰字,渲染不了一份六行小结。
> - `交接给 @X` = 总结 + 转派 + 拉人;总结失败照样交接但说清少了什么。
> - ⚠️ 不要读 `AssistantTicketChat.created_at` 来限定读取范围:那是 server_default 列,行在本 session 里 insert 时从未取回过,一读就是惰性 IO ⇒ 异步路径 `MissingGreenlet`。工单群的全部历史就是这张工单的讨论,不需要这个下限。

> **群里的闭环:解决 / 确认关单 / 重开 / 评价 (2026-09-08; 0 迁移 / 0 端点 / 0 MCP / 0 cron)。**
> - **处理人主路径是打字**:`解决了 <说明>` 直接结案,说明取**原文**(`_strip_prefix`,不用归一化后的串 —— 大小写/错误码/专有名词都是结案说明的一部分)。空说明拒绝并解释为什么。
> - **按钮路径先提案**:「我弄完了」→ 读群 → 小结 → 在群里发一张结案说明卡等确认;确认时校验 **sha256 摘要**(卡上显示的那段 vs 最后落地的那段),对不上就拒绝。AI 产出的文本不经人眼不进台账 —— 全链路同一条规矩。
> - 🔴 **求助人那两颗按钮的判据是「这是你的工单」,不是「你是处理人」**:处理人替他按「问题解决了」等于自己给自己发合格证。`global_admin` 放行。
> - **三档 CSAT 发在群里**(`via='feishu_group'`,口径与飞书服务台一致),只有求助人能评,评过重画成回执。群外结案时由 `ticket_chat_coordinator` 的刷新任务补发(调 `card_actions.offer_room_rating`,不另拼一张卡 —— 按钮 payload 必须与处理函数同源)。
> - **卡片按钮是一台状态机,上限 3 颗**(`ticket_card._buttons_for`)。四颗在手机上折行。⚠️ `resolved` **不是**终态:那时正需要求助人表态;终态只有 `closed` / `cancelled`。
> - 🔴 **每次工单卡点击先发布 in-flight 卡**(当前那张工单卡)。飞书把回调返回当成这条消息的新内容,超预算时返回 `handoff.in_flight` —— 不发布它,通用的「正在处理」卡会把群里唯一能看到状态的东西替换掉。群内点击的 follow-up 一律抑制(`FOLLOWUP_KEY: False`),否则操作者私聊里会多出一整张工单卡。

> **工单群的收尾 (2026-09-08; 迁移 634 = 一行 cron + 两个设置 / +1 端点 / +1 MCP → 628)。**
> - **cron `assistant.ticket_chat_sweep`**(每天 03:20,[`assistant_room_handlers.py`](../../server/app/scheduled_jobs/assistant_room_handlers.py)):结了的群按 `assistant.ticket_chat_retention_days`(默认 7)解散;还开着但 `assistant.ticket_chat_idle_nudge_days`(默认 14)天没动静的群里问一句。两个 0 各自关掉那条行为。
> - 🔴 **删之前工单上必须有过程记录**(房间的聊天记录随解散消失)。模型不可用 / 读不到房间 ⇒ **留着房间**,计入 `kept_for_record` —— 它和「没到期」在 `disbanded == 0` 上长得一样,而原因完全不同。
> - 🔴 **删失败不标已删**(否则台账说群没了而群还在,再没有路径去清它)。单个失败不算运行失败;**全部失败才抛**。
> - **不自动关单**:自动关掉一张还开着的工单会让「已关闭」不再代表任何东西。问一句 + 给出路 +「问过算一次活动」。
> - 手动解散三个入口:群里「解散群」(终态硬闸,排在授权之前)、门户/后台按钮(终态才出现 + 二次确认)、MCP `disband_ticket_chat`(**在 `OPT_OUT` 里** —— 不可逆动作不上聊天卡片面)。
> - `im.chat.member.bot.deleted_v1` 现在把台账行落成 `closed`(PR1 只记审计)。
> - ⚠️ **cron 行不带 `config`**:#3520 的镜像 —— 播种进 config 的键会赢过平台设置。迁移测试直接断言 `config == {}`。

> **运营台 (2026-09-08; 0 迁移 / +1 端点 / +1 MCP → 629)。** `/admin/assistant-handoff` 从一张裸路由表重做成「转人工与工单群」运营台。读模型在 [`room_console.py`](../../server/app/assistant/room_console.py),一个聚合端点 `GET /api/v1/assistant/admin/room-console`(概览 + 清单**同一个响应** —— 分两次请求会让「共 40 个群」和下面 37 行来自两个时刻)。
> - 四个 KPI:近 N 天转人工次数与**拉起群的比例**(服务端算一次,两个面共用)、还开着的群与其中停滞的、等收尾的、以及 🔴 **`chats_without_record`** —— 唯一关于**可恢复性**的数(群一解散聊天记录就没了),只数**还开着**的房间。
> - **两处口径必须与别处一致**:停滞阈值 `IDLE_DAYS` == 收尾任务的 `DEFAULT_IDLE_NUDGE_DAYS`(否则页面标红的和任务去问的不是同一批);「等收尾」的谓词 == 默认视角的筛选条件(否则点开「3」看到 5 行)。两条都有测试钉住。
> - `_note_exists()` 用 **EXISTS 而不是 join**(一张工单三条记录会把一行变三行);`ticket_open` 的子查询必须用 **`aliased(SupportTicket)`** —— 清单那条语句已经 outer-join 了同一张表,不加别名会被 auto-correlation 吃掉整个 FROM。
> - 只读 MCP 孪生 `assistant_ticket_chat_overview`(global_admin + auditor)。

> **负责人兜底与群内 SLA 升级 (2026-09-08; 迁移 635 = 两列 / 0 端点 / 0 MCP / 0 cron)。**
> - [`owner_reach.py`](../../server/app/assistant/owner_reach.py):拉人之前先问「他今天在吗」。主负责人(有邮箱、今天不在批准中的休假)→ 路由表的 `backup_owner_person_id` → 都不行才降级。🔴 **休假判据与 `hr.open_leave_router._UNAVAILABLE_STATUSES` 逐字同源**(有测试断言两个集合相等);🔴 **查不到休假记录 = 在**(HRIS 同步可能是空的,一个同步故障不该让所有转人工降级);没有备份时**仍然拉主负责人**而不是降级,并在开场白里说明原因。
> - **群内 SLA 催办复用 `support.ticket_sla_reminder` 的水位与冷却**,不另立时间表(「多久算超时」只该有一处定义;这类数只能拉长不能收紧)。催办卡片只发给处理人,而求助人就在同一个房间里等着 —— 一条只有处理人看见的催办,对员工而言等于「没人管」。
> - **催到 `assistant.room_escalate_after_reminders`(默认 3)轮就升级**:把备份负责人(退到 IT 管理员)拉进房间。两条约束:`escalated_at` 保证**只发生一次**(否则一个房间会变成整个 IT 团队),以及**不自动改派**(升级是多一双眼睛;改派会让原处理人以为这件事不再是他的)。备份就是当前处理人时是空操作,而不是一句假的「已经升级」。

> 🔴 **群里那条链路上线当天的两处(2026-09-08,同一个根)。**
> - `GET /bot/v3/info` **没有 `data` 信封**(结果在 `bot` 下),而 `FeishuApprovalClient._request` 统一 `payload.get("data", {})` ⇒ 机器人**永远解析不出自己的 open_id**,而且是「没有异常、`code` 为 0、结果为空」的形状。连锁三层:`strip_bot_mention` 认不出自己 ⇒ 命令表看到 `"@Helix 总结"` ⇒ **群里每一条字面命令都静默落到通用问答**;「@ 的是不是我」永久降级成「有人被 @ 就算」;模型收到的提问里带着 `@Helix` 并照抄。`_request` 因此有了 `envelope_key`,而 `ticket_commands` 里**再兜一层**:句首的 @某人 一律丢掉(句中的不动)。
> - **`read_this_room`**:读群历史原来只挂在字面命令上,自然语言问同一件事落空,模型答「我无法查看群聊历史」—— 而平台能读。工具只在群里那一轮给出,`chat_id` 从信封进闭包不进参数;读不到时如实说读不到并点名 `im:message.group_msg`(空结果会被模型读成「大家什么都没说」)。
> - ⚠️ 测试教训:已有群聊用例全部 monkeypatch `bot_open_id`,**产出那个 id 的线从没被测过**;而「工具长什么样」的用例摘掉挂载那行依然全绿 —— 必须有一条从 `run_turn` 进的。

> **合并后复审的九处 (2026-09-08)。** 七处跨在两个 PR 的接缝上,单 PR 自审看不见:
> - 🔴 `POST /support/tickets/{id}/chat` 曾只靠 `_load_for_caller` 放行,而它的 `is_admin` 含 **auditor / management**(只读角色)—— 开群会 `assign`,于是只读角色能改工单归属。判据已收成与 MCP 孪生逐字相同的一份。
> - 🔴 **重开的工单要能再拿到群**:`open_for_ticket` 复用已解散的那一行并清掉 `closed_at`/`close_reason`/`card_message_id`/`escalated_at`。
> - 🔴 **群内 SLA 催办读水位**(`last_agent_reminded_at`)才发:任务在 job 提交前就排出去,job 回滚时否则群里会出现一句关于从未发生的催办的话。
> - 🔴 收尾 cron 的 `config` 里带阈值键**直接抛**(#3520 的形状,handler 里那条「显式传了就赢」的路已删)。
> - `room_history` 在上限处停下且 `has_more` 时标 `truncated`;`channel.send_card` 的话题回复被拒时退回平铺;运营台复用 `ticket_chat.chat_applink`;群限速提示按**窗口**抑制(开关关着仍按天);`render_note` 反查模板槽位(`str.format` 静默丢弃 = #3515 的形状)。
> - 🔴 **收尾 cron 的到期判断在 SQL 里,不在 Python 里**(`_due_rooms`:孤儿 / 结了且过期 / 开着且长期没动静三支 OR)。「取最老的 50 个开着的房间再逐个判」会被一墙长命工单**永久饿死**,而症状是每天 `considered=50, disbanded=0` —— 读起来完全像「没有到期的」。`considered` 因此改成数「这一轮取出来的房间」。
> - 🔴 **建群 / 解散各留一行审计**(`assistant.room.opened` / `assistant.room.disbanded`)。这是这条链路唯一两件不可逆的事,而 `close_reason` 对「到期清理」和「有人手敲了解散」给出同一个答案。删失败**不**记;开群与随之而来的 `assign` 共用一个 trace_id。
> - 🔴 **群里的身份拒绝按房间+原因抑制**(`_refuse_to`)。群频次闸排在身份分支之后,所以一个没绑定的人原来可以让机器人在群里无限说话,每一条还都是关于他自己的通报。
> - **终态工单不给开新房间**(coordinator 上的 `ticket_settled`,四个入口一起),而「为什么没开成」的 (码, 话, 状态) 收成 coordinator 上唯一一张 `CHAT_REFUSALS` —— HTTP 与 MCP 读同一张,新增一个 reason 不可能只在一个面上被认出来。
> - **兜底升级也要判「这个人是不是就是处理人」**:该判断原来只在备份负责人那一支;假升级的真正代价是升级戳一落,房间再也不会真的升级。
> - **孤儿房间的清理也留一行审计**(`ticket_chat.audit_disband`,`actor_type=system`)—— 它不走 `disband`,原来是唯一一种无人署名的销毁。
> - **运营台的写控件对 auditor 是灰的**:这一页的读是审计档,而每一处写在后端都是 global_admin-only —— 一颗按下去必然 403 的控件读起来像平台坏了。

> **接地闸的「便宜规则」不再受容量上限连坐,生成层会重问 (2026-09-01; 0 迁移 / 0 端点 / 0 cron / 0 MCP 增减 / 0 RBAC)。**
> - [`_derived_number_tokens`](../../server/app/intelligence/contracts.py) 的四条派生规则原来**整体**挡在 `_MAX_PAIRWISE_FACTS = 120` 后面,而跨域简报的包有 **161** 个不同数值事实 ⇒ 规则一条都不生效,叙述连「把刚发布的 `72.14` 写成 `72`」都会被判 uncited。规则 1(取整)、2(补数)是 **O(n)**(实测 0.0004s、286 个值),已改为**无条件生效**;规则 3、4(余数、换分母百分比)是 O(n²),**仍按 120 封顶**。
> - 🔴 **`_MAX_PAIRWISE_FACTS` 不能为了消掉一次拒绝而调高**,理由已从「省 CPU」改写成「守口径」并附实测:161 个事实跑完整四条只要 0.16s(旧理由不成立),但会派生 **51,862** 个可接受值 —— 随机抽样 **1~1000 整数 99.0% 通过、一位小数百分数 100% 通过**,闸自己文档点名必拒的 `312` 也被放行。**包大了该收窄事实面(见 `_FACT_LIST_ITEMS_SHOWN`),不是放宽闸。**
> - [`generate_analysis`](../../server/app/intelligence/generation.py) 新增 `_VALIDATION_ATTEMPTS = 3`:**只对 `IntelligenceContractError` 重问**,`BAILIAN_LLM_INPUT_TOO_LARGE` / `BAILIAN_LLM_OUTPUT_TRUNCATED` 直接落地(那是包的属性,重问原价再买一次同样失败)。Token **跨尝试累加**进审计行。次数按实测定:修完规则后单次通过 **4/6**,`(1/3)^次数` ⇒ 1 次 33% / 2 次 11% / **3 次 3.7%**。
>
> 效果:生产真包只读探针 **1/6 → 4/6**(单次),叠加重问后 job 失败概率 **33% → 3.7%**。剩余两次失败已确认是闸干得对(`76000` 最近事实差 38,000;`75` 是把发布值 `75.5` **截断**,不是四舍五入),**没有再放宽**。同批改写两个把旧行为编码成期望的既有测试(断言强过自身声明的意图)。详见 CHANGELOG 2026-09-01。

> **模型面事实包有了记录数上限,LLM 调用有了输入窗口闸 (2026-09-01; 0 迁移 / 0 端点 / 0 cron / 0 MCP 增减 / 0 RBAC)。** 两个新原语,都在既有的唯一缝上:
> - [`_flatten_for_citation`](../../server/app/intelligence/generation.py) 接受 `item_cap`,`build_model_fact_package` 对 **`facts` 传 `_FACT_LIST_ITEMS_SHOWN`(8)、对 `dimensions` 传 `None`**。provider 发的列表是「每人/每单/每资源一条」,随公司规模线性涨;`dimensions` 是 `catalog.py` 的闭集,是简报正文来源,封顶会在第九个维度上线那天静默丢掉它。**`count` 始终是真长度**,被抽样的列表另发 `shown_count` —— `count` 是可引用事实,报样本数就是发布一个错的量级。封顶**逐层生效**,因为 prompt 代价是乘性的(3 horizon × 8 调动 × 15 deltas)。`aggregate_facts` 本身**不动**(它同时投给前端读模型,见 2026-08-17 那条摊平教训)。
> - [`_guard_input_size`](../../server/app/domains/bailian/llm_client.py) 在 `_call`(native)与 `chat`(compatible-mode agent loop)发请求**之前**量 prompt,超 `_MAX_INPUT_TOKENS`(**258,048**,DashScope 自己在 400 里报的数)抛 `BAILIAN_LLM_INPUT_TOO_LARGE`(413)。估算用 `_ESTIMATED_CHARS_PER_TOKEN = 3.0`,**刻意低于真实的 ~3.9**,好让闸落在厂商之前。定位同 `_EMBED_MAX_BATCH`:厂商上限归客户端,不归每个调用方。
>
> 起因:2026-09-01 一次组织调动(30 天窗口内 1 → 17 个调动事件)把 prompt 从 445 K 推到 **1,049,934** 字符 ≈ 270 K token,`intelligence.analysis_daily` 吃了 `HTTP 400 Range of input length should be [1, 258048]`。修后生产真快照 441,966 字符 / in=116,545 token,只读探针 3/3 打通模型。详见 CHANGELOG 2026-09-01。
>
> ⚠️ **同一条探针暴露出一个独立的、更早就存在的缺陷(未修,需单独 PR)**:40 个 catalog 指标里 **10 个的 key 不以自己的维度名开头**(`people_org` 用 `people.*`、`platform_ops` 用 `platform_operations.*`),而另外 30 个重复维度名、摊平时被剥掉。于是同一批指标有两种地址形状,模型从多数样本学到「维度.指标」后在那 10 个上必然写错(实测 `Unknown fact reference: /facts/people_org.prediction_data_coverage.value`,而该指标真实存在)。根治=改名对齐,必须 bump `SCORE_VERSION` + 进 DECISIONS。

> **Intelligence Center current extensions (2026-08-16; release gate closed).** Lifecycle snapshots include the versioned `leaver_risk` aggregate and the assets provider includes `optimization` facts (`asset-optimization-v1`). The daily platform brief is scheduled after the deterministic snapshot, materializes deduplicated audited recommendations, and exposes zero-model bilingual reads plus ROI totals through HTTP/MCP. Both are deterministic, anonymous, RBAC-filtered read-model data; model generation is not performed during reads. `intelligence.enabled` remains fail-closed by default.

> **优先建议有了退出路径 —— 之前它只增不减,正文还是第一次见到时冻住的 (2026-08-29; 0 迁移 / 0 端点 / 0 cron / 0 MCP 增减 / 0 RBAC)。** 生产截图上的一条卡片同时暴露了三个独立缺陷:`read_recommendations` 只排除 `ignored`,于是**已解决**的建议照旧占着「优先建议（10 条）」的名额和计数;`materialize_analysis_recommendations` 用 `title_en` 匹配到已有行之后,把模型这一轮写的其它字段**全部丢弃**,所以那张卡片在 #3043（2026-08-18 把 `owner_covered_count` 改成认云订阅继承的 owner,生产 6503 台里 6486 台能解析到）之后每周被重新落地一次、正文一个字没变,仍写着「6737 个云网络对象中仅有 247 个有所有者」;而**没有任何机制**能让一条建议退场 —— 没有 TTL,`last_seen_at` 每轮都写、却没有任何查询读过它,也没有 supersede,所以模型换个措辞就是一条**新行**,旧行永远留着。三处一起修:(a) 读取排除 `ignored` + 新的 `superseded`,并按 `_OPEN_FIRST` **先未处理后已处理**排序 —— 窗口封顶 200 行,一串已解决的 P0 本来能把未处理的 P1 挤出响应;(b) 命中已有行时**重述**  `title_zh` / `summary_zh` / `summary_en` / `evidence_refs`;(c) `_supersede_unseen_recommendations` 把**被连续 2 次成功分析跳过、且至少 7 天没被提起**的**未认领**行归档为 `superseded` —— 按**轮数**而不是天数计(`cutoff = min(第 N 新的成功分析 generated_at, now - 7d)`):调度停摆时天数毫无意义,而有人一下午点三次「生成简报」时轮数又太松,两者取交集。生产取证说明为什么必须有这条路:15 行里 15 行 `first_seen_at == last_seen_at` —— dedupe 键以 `title_en` 结尾,而模型在 08-18 / 08-25 / 08-29 三次简报之间把每条标题都改了措辞(「回收闲置资产以优化成本」→「回收闲置资产」),所以 (b) 那个匹配从上线起一次都没命中过,每轮简报只是往表里加行。归档面刻意窄:只扫 `new`/`viewed`（`in_progress` 是有人在做的活,且 `savings_reconciliation` 把真实云节省事件挂在它上面;`resolved` 是已入账的结果,仍计入 ROI）,只在**本次分析发布了建议时**才扫（空跑是模型话少,不是问题消失了）,只在**同一 subject/audience 三元组**内扫。`superseded` 是**机器专属**状态:`_MANUAL_STATUSES` 不含它,所以任何 PATCH/MCP 请求都无法把一行改成归档;但 `_ALLOWED_TRANSITIONS["superseded"]` 存在,人可以把它拉回待办 —— 那张表按**当前**状态索引,缺一个持久化状态就是 500 而不是 422。归档写一条 `actor_type="system"` 的 `intelligence.recommendation.supersede` 审计行,三个调用面各多返回 `recommendations_refreshed` / `recommendations_superseded`。前后端两个壳都把「优先建议」的计数收窄成未处理,已处理折进下面一段可展开的「已处理 N 条」。测试:[`test_recommendation_staleness_2026_08_29.py`](../../server/tests/test_intelligence/test_recommendation_staleness_2026_08_29.py)（9 条,四处变异验证）+ 桌面/移动各一条。

> **Deterministic health scoring is now `health-v4` (2026-09-01; 0 migration / 0 API / 0 MCP / 0 i18n).** A pure **key rename** — no definition, threshold, weight or fact read changed, which is what separates it from v2/v3. `people_org`'s four metrics moved from `people.*` to `people_org.*` and `platform_ops`'s six from `platform_operations.*` to `platform_ops.*`, so all 40 metric keys now repeat their dimension. **Why it was a defect and not cosmetics:** the citation flattener strips a metric key's repeated dimension namespace and keeps a foreign one, so 30 metrics published the name `<dimension>.<leaf>` while 10 published `<other>.<leaf>` at a one-segment-deeper address. A model shown 30 examples of one rule writes that rule for all 40, and for those 10 it is wrong every time — production 2026-09-01 refused a whole grounded brief for `Unknown fact reference: /facts/people_org.prediction_data_coverage.value`, a **real** catalogue metric. Measured on the real production snapshot: **30/40** metrics citable as `<dimension>.<metric>` before, **40/40** after; six live model runs on the post-rename package, 6/6 validated. It is a version bump because the key IS the metric's identity (`intelligence_snapshots`' unique key carries `score_version`, and `read_service` has a `score_version_mismatch` verdict) — history is never rewritten, so `health-v3` rows keep `people.*` and the resolver must keep resolving them (`test_metric_name_citation_2026_08_25.py` keeps its two-shape fixture on purpose). New import-time guard [`validate_metric_key_shape_is_uniform`](../../server/app/intelligence/scoring.py) forbids a catalogue from MIXING the two shapes, applied to **both** catalogues: the cloud-subscription one keys all 18 of its metrics `cloud.<group>.<leaf>`, never repeating its dimension, and never hit this failure precisely because there is no competing majority — so both shapes are legal and only the mixture is not. Deliberately NOT done: renaming those 18 cloud keys (would touch `cloudScopeAnalysis.ts` types and `_WORKBENCH_BY_DIMENSION` with zero failure evidence), and widening the grounding guard (tried 2026-08-17, admitted invented deeper paths, reverted). Decision: DECISIONS 2026-09-01. Tests: [`test_metric_key_shape_uniform_2026_09_01.py`](../../server/tests/test_intelligence/test_metric_key_shape_uniform_2026_09_01.py) — the snapshot fixture is built FROM `METRIC_CATALOG`, so a metric added later is covered without anyone remembering the file.

> **Deterministic health scoring is now `health-v3` (2026-08-18; 0 migration / 0 API / 0 MCP).** Same-day second step after `health-v2`, this time the **assets** dimension (59.00 on production, six metrics, confidence 100%). Applying the reachability test — *is the target band arithmetically attainable under shipped policy?* — three of the six were not, i.e. measurement defects rather than operational debt. (a) `assets.inventory_accuracy` divided confirmations by **every** snapshot item, so an item nobody had answered scored exactly like a record found to be wrong: a cycle opened at its worst band, and because [`AssetInventoryCycle`](../../server/app/domains/asset/models.py) freezes the remainder at `pending` on close, any cycle closed with unanswered items pinned the metric permanently. v3 scores answered items (`confirmed / (confirmed + issue_reported)`) and moves the response rate into **confidence**, which the product already renders next to the score. Production: 352/393 = 89.57% (60 pts) -> 352/367 = 95.91% (100 pts), confidence 93.38%. (b) `assets.idle_rate` dated idleness from `asset_items.updated_at`, a row-mutation stamp — on 2026-07-14 / 07-15 / 07-21 production bumped 134 / 108 / 87 asset rows of which 134 / 104 / 85 had `last_inventory_at` move the same day, i.e. *answering a stocktake* reset the idle clock on 70 idle computers while only 3 real asset operations happened platform-wide that day. v3 dates idleness from the last real custody event (`asset_operations`, falling back to intake) — long-idle computers 70 -> 145 — and scores the **same measure the shipped asset-optimization surface already renders** (`idle_surplus_count`: computers only, net of uncovered near-term hardware demand). The old population counted 297 stock-room monitors, 49 office network devices, meeting-room displays and printers as reclaimable capital, so its 50-item evidence list was all monitors instead of the 132 computers someone could actually reclaim. (c) `assets.warranty_coverage` was **retired**: `warranty_until` has never been written in production (0 of 1,099 rows, the 2026-04 legacy import included) and **no intake or edit control produces it** — it is read-only in the detail page, workbench card and H5, and the only writer is an optional bulk-import column unused in five years. It stays a fact (`warranty_population_count` / `warranty_complete_count`); re-scoring it needs a real producer and a new version (re-reading it as `purchase_date` + depreciation policy would peg it at 100% — inflation, not a fix). Its .15 left the five surviving asset outcomes at an even `.20`. Net on production: assets **59.00 -> 70.00**, confidence 100% -> 98.68%, platform overall +1.1. Deliberately untouched because they are real debt, not a measurement defect: `assets.financial_coverage` 27.64% (`purchase_cost` 324/1053 and improving year over year — 46 of 405 for 2021 intake vs 50 of 55 for 2026 — with `procurement_order_id` at 0 rows, so Helix holds no second cost source) and `idle_rate`, still 20 pts after the fix because 145 computers genuinely have not moved in 90 days and 132 of them are surplus to near-term demand. Decision: DECISIONS 2026-08-18. Tests: [`test_fact_providers.py`](../../server/tests/test_intelligence/test_fact_providers.py) (`test_inventory_accuracy_scores_answered_items_not_unanswered_ones`, `test_idle_rate_dates_idleness_from_custody_history_not_row_edits`; three mutations verified, and the retired metric stays asserted absent while fixtures still carry `warranty_until`).

> **Deterministic health scoring is now `health-v2` (2026-08-18; 0 migration / 0 API / 0 MCP).** `SCORE_VERSION` in [`scoring.py`](../../server/app/intelligence/scoring.py) moved to `health-v2` because two lifecycle metric definitions were wrong and a definition change never rewrites history — `intelligence_snapshots` keeps its `health-v1` rows untouched and un-recomputed. (a) `lifecycle.leaver_failure_rate` counted a leaver as failed on *any* historical `error_count > 0` and on process status `blocked`. `error_count` is documented on its own column as "total failures **since blocker creation**" and never resets on resolve, and `blocked` is the ordinary in-flight status `_recompute_status` sets for **any** pending row — so a retried-then-`revoked_auto` blocker was counted forever and every running offboarding was scored as a failure. Production: 12 of 78 window leavers reported failed, zero actually unresolved, and the metric could only degrade as retry history accumulated in the rolling window (≈14% of leavers hit a transient retry), pinning it to the worst band no matter how operations ran. v2 requires a blocker that is still **unresolved** AND either actually failed execution (`error_count ≥ 1` or a linked provisioning task `failed` — the two evidence-of-failure arms of [`compute_triage_bucket`](../../server/app/domains/offboarding/models.py), deliberately excluding its "needs a human but has not failed" arms) or has sat past the T+0+24h force-resolve grace. Recomputed on production: 15.38% → 0.00%. (b) `lifecycle.transfer_recovery` was **retired**: its only signal source, `provisioning_tasks.source='role_change'`, has never produced a row in production (written solely by the manual apply-role-change path; the automated `role_change_handlers` sweep chases cloud-grant reviews and creates no provisioning task), so it was permanently `confidence=0` — simultaneously excluded from the score math (shrinking the denominator to 0.85 and inflating every sibling metric by 1.18×) and costing 15 confidence points. Its weight went to `day1_ready` (.30), `leaver_failure_rate` (.30) and `owner_continuity` (.20); `onboarding_sla` deliberately stayed at .20 because it currently reads the same open-process population as `day1_ready`. Net on production data: lifecycle **39.41 → 65.00**, confidence **85% → 100%**, platform overall 60.16 → 64.00. A metric that can never obtain data is more misleading than no metric — re-adding mover recovery requires a real execution-plane signal and a new version. Decision: DECISIONS 2026-08-18. Tests: [`test_fact_providers.py`](../../server/tests/test_intelligence/test_fact_providers.py) (`test_leaver_failure_rate_counts_unresolved_failures_not_retry_history`, mutation-verified; the retired metric stays asserted absent while its old signal source still has fixture rows).

> **Deterministic health scoring is now `health-v3` (2026-08-18, second pass; 0 migration / 0 API / 0 MCP).** One definition change plus three fact-read fixes, landed together so the version steps once. **Definition (drove the bump):** `lifecycle.onboarding_sla` read the CURRENT `sla_breach_level`, a flag [`evaluate_sla`](../../server/app/domains/onboarding/service.py) returns `none` for on **every** terminal process and resets to `none` the moment the last blocker clears — so it measured "share not red right now", not the attainment its label promises; production reported 67.57% while only 16 of 25 completed hires actually made the shipped 72h SLA (the rest landed 1–22 days late). v3 scores `hard_ready_at <= hire_date + 72h` over the hires whose **window has closed** (`hard_ready_at` set, or today past `hire_date + 3d`) — a hire still inside the grace window is neither an attainment nor a miss, which is the same "常态在途 ≠ 失败" rule v2 applied to the leaver metric. Production: 67.57% → 45.71% (score unchanged at 30; the metric stopped over-reporting). **Fact reads (no definition touched):** (a) `software_api.key_expiry_coverage` scoped its denominator to credentials Helix actually issued and holds — `status='approved'` rows have no credential yet (the admin writes `key_value`/`valid_from`/`valid_until` at issuance) and `detail.source in ('adopted','adopted_manual')` console keys deliberately keep `key_value` NULL because [`bailian_adopt`](../../server/app/domains/apikey/bailian_adopt.py) never held the plaintext, so Helix cannot stamp a validity on them; 44 of production's 61 "live" rows were one of the two, putting the 95% band out of arithmetic reach. The numerator also changed from "expires more than 30 days out" to "carries a bounded validity": applications are capped at one year, so the old rule dropped every correctly-governed key for its final month — exactly when the expiry-alert cron is doing its job. 24.59% → 80.00%, real gap 4 keys. (b) `software_api.reconciliation_coverage` now adds the **备案保留** external holders the shipped panel already nets out — `asset.router._netted_categories` (`external` / `leaver_residual` × `external_account_registry` KNOWN classifications `shared_service`/`external_guest`/`contractor`); 105 of production's 326 unmatched vendor seats are registered reservations, so scoring them as gaps made the health metric contradict the panel that owns those rows. Evidence is filtered by the same predicate so the "next step" never points at rows the metric counted as handled. 89.38% → 92.80%. (c) new fact `over_recorded_quota_plan_count` names the plans whose ledger occupancy already exceeds `max_seats` (Zoom Large Meeting 1/148, Zoom Webinars Plus 2/190, Confluence Standard (agent) 10/101 — all vendor-`seat_sync` imports): `capacity_headroom` at 72.73% is a **real** finding, not a read defect, but the remedy is correcting a stale quota, not buying seats. Net on production data: software_api **67.00 → 75.50**, lifecycle 65.00 (unchanged). `lifecycle.day1_ready` (16.22%) was deliberately left alone — 12 active employees' onboarding is stuck on `hardware_allocation` with no computer in the asset ledger, the 95% band is arithmetically reachable, so it is real debt under the standing rule (unreachable band ⇒ read defect; reachable but hard ⇒ leave the口径). Decision: DECISIONS 2026-08-18（第二轮）. Tests: [`test_fact_providers.py`](../../server/tests/test_intelligence/test_fact_providers.py) (`test_onboarding_sla_scores_shipped_72h_attainment_not_current_flag`, `test_key_expiry_coverage_scores_only_helix_issued_credentials`, `test_reconciliation_coverage_nets_registered_external_holders` — all three mutation-verified).

> **Model citation surface — what crosses the LLM boundary (2026-08-18; 0 migration / 0 API / 0 MCP).** [`build_model_fact_package`](../../server/app/intelligence/generation.py) shapes what the model may cite, and both knobs exist because of production failures where a *correct* analysis was thrown away. (a) **Evidence sample**: the platform `reference_map` holds **1976** anonymous refs across 29 kinds; handing all of them over produced narrative items citing 42 and 124 refs against a per-item cap of 20. `_sampled_evidence_refs` shows `_EVIDENCE_REFS_PER_KIND` (6) per kind — every kind stays representable, and `allowed_evidence_refs` (validation) still spans the WHOLE reference_map, so narrowing what is shown can never turn a legitimate citation into a rejection. (The sibling pointer failure — `cloud_scope` citing `/facts/costs/data_quality/sources/2/available_count`, built by joining two paths — is solved separately by `_flatten_for_citation`, which hands the model one flat level whose keys carry the full path, so there is nothing to mis-navigate.) In [`contracts.py`](../../server/app/intelligence/contracts.py): an over-long `evidence_refs` is de-duplicated and TRIMMED to `_EVIDENCE_REFS_MAX` rather than rejected (drill-down anchors are not what grounds numbers — `fact_refs` + `_validate_text_numbers` are, and both stay strict), and every `evidence_refs` entry must now be in the allowed set. That last check closes a real hole: `validate_anonymous_refs` only judges strings SHAPED like `^[a-z][a-z0-9]{1,15}_\d{2,6}$`, so a hostname or address placed in `evidence_refs` was never checked at all and would have been stored and rendered as an evidence anchor. Tests: [`test_citation_surface_2026_08_18.py`](../../server/tests/test_intelligence/test_citation_surface_2026_08_18.py).

> **An unknown action verb downgrades, it does not discard (2026-08-25; 0 migration / 0 API / 0 cron / 0 MCP).** `cloud_scope_health` had never published in 10 attempts, and its 2026-08-24 rejection was one word: `recommendations.1.action_kind='confirm'`, which threw away 2548 output tokens of otherwise grounded narrative. `action_kind` is a CATEGORY of Helix action, not model prose — the words are in the title and detail — so `_coerce_unknown_action_kinds` maps a verb outside the vocabulary to `review`, which is absent from `_APPROVAL_ACTIONS` and gets the default read-evidence/open-workbench/verify-snapshot steps. Strictly a narrowing, in the same spirit as the low-confidence clamp: a coerced recommendation can never become a write it was not entitled to, and only a STRING outside the vocabulary is coerced (a missing or non-string `action_kind` is still a schema breach). The vocabulary is read off the output contract via `_allowed_action_kinds`, so a subject that narrows it in a subclass narrows the coercion too. This replaces the one-word-at-a-time widening that added `assign` — each such widening also widens the approval gate, and a third verb was always going to arrive. Adding `confirm` as a first-class verb (its own workbench + approval decision + the four consumption points) stays available and is now a product choice, not an outage. Tests: [`test_action_kind_coercion_2026_08_25.py`](../../server/tests/test_intelligence/test_action_kind_coercion_2026_08_25.py).

> **A metric is citable by the name we published for it (2026-08-25; 0 migration / 0 API / 0 cron / 0 MCP).** The weekly brief failed with `Unknown fact reference: /facts/cloud_network.resource_waste_rate.value` — and the package really does publish that metric, at `dimensions.cloud_network.metrics.resource_waste_rate.value`, right next to `...resource_waste_rate.key == "cloud_network.resource_waste_rate"`. `_flatten_for_citation` drops the dimension namespace that a catalog key repeats and inserts a `metrics` segment the key does not have, so for **all 40** metrics in the real platform snapshot the published NAME was never the published ADDRESS: citing any metric by name was a guaranteed rejection. `_record_addresses` in [`contracts.py`](../../server/app/intelligence/contracts.py) closes it — a record that publishes its own `key` is addressable by that key, tried LAST, after every literal address has failed. This is not a relaxation of the grounding guard: the translated address must still exist in the same member, so a published name plus an invented leaf (`...resource_waste_rate.trend`) is refused exactly as before, and a name two records claim is dropped rather than guessed. It also revises an earlier verdict — the 2026-08-17 refusal of `/facts/software_api.seat_utilization.value` was recorded as a genuine hallucination, but `software_api.seat_utilization` is a real catalog metric and was hitting this same bug. Tests: [`test_metric_name_citation_2026_08_25.py`](../../server/tests/test_intelligence/test_metric_name_citation_2026_08_25.py).

> **The copyable fact list advertises magnitudes, not anchors (2026-08-25).** `_citable_pointers` skips lists precisely because a vocabulary is not a magnitude — but flattening turns each list ITEM into a scalar, so **693 of the platform package's 3505 advertised addresses** were anonymous drill-down anchors (`...evidence_refs.7` → `server_79`), two thirds of everything published under `dimensions`. Anchors have their own channel (the per-item `evidence_refs` field, checked against the whole `reference_map`). `_is_evidence_ref_address` drops them from the advertised list — segment-exact, and keeping `...evidence_refs.count`, which IS a magnitude. They stay resolvable if cited, so nothing legal became illegal. Measured on the real 2026-08-25 platform snapshot: 3505 → 2812 addresses, 125,745 → 114,806 input tokens. Tests: [`test_citable_address_book_2026_08_25.py`](../../server/tests/test_intelligence/test_citable_address_book_2026_08_25.py).

> **A failure alert carries the reason, not only the code (2026-08-25).** `generate_analysis` records the real cause on the `intelligence_analyses` row and then raises a deliberately generic AppException, and `intelligence.analysis_daily` built its `RuntimeError` from the CODE alone — so every P1 card this job ever pushed read `INTELLIGENCE_OUTPUT_INVALID (snapshot …)` and diagnosing one meant a hand-written prod-TiDB query. The handler now quotes the audited row's `failure_message` (`_analysis_failure_reason`, best-effort: a failure with no row still alerts with its code). Note for the notifier: the reason is part of the dedup signature, so two different rejected references now read as two different failures — which is correct, and bounded by the per-job quiet period.

> **…and the failure path must survive its own faults (2026-09-06; 0 migration / 0 API / 0 MCP).** The 2026-08-25 entry above made the alert quote the audited row. The remaining hole was that the code writing that row could itself fail and take the diagnosis with it — the same class as the 2026-08-17 `MissingGreenlet`, one layer down. Two faults injected against the real path (not mocked exceptions) each produced a P1 card naming a **SQLAlchemy documentation-anchor slug**: a pooled connection dropped mid-model-call gave `…失败:7s2a` (`PendingRollbackError`), and a vendor `_meta.model_used` wider than `IntelligenceAnalysis.model_used` (`String(128)`) gave `…失败:9h9h` (`DataError`) — the latter discarding a `Narrative contains uncited numbers: ['9999']` that all three validation attempts had agreed on, and losing the audited row too. 🔴 **`getattr(exc, "code", None)` is not a safe duck-type on this platform: every SQLAlchemy DBAPI error answers `.code` with a docs anchor.** Four changes: (a) `generate_analysis` mirrors `row_id` / model / token counts into LOCALS right after the pending row commits, because the except branches passed `row.model_used` as an ARGUMENT and Python evaluates arguments first — on a deactivated session that read raised *before* `_mark_failed` was entered; (b) `_mark_failed` is best-effort like its sibling `_analysis_failure_reason` (log, roll back so the session is handed back usable, never replace the caller's cause); (c) `model_used` is truncated at both write sites, like the `code[:64]` / `message[:512]` beside it — it is the only one of the three that comes from the VENDOR; (d) the catch-all branch writes `type(exc).__name__` plus the scrubbed first line into the audited row, since that branch has no vendor code and no contract rule to fall back on. Only `AppException.code` is trusted for the alert. Tests: [`test_failure_path_keeps_the_cause_2026_09_06.py`](../../server/tests/test_intelligence/test_failure_path_keeps_the_cause_2026_09_06.py) — five cases, each mutation-verified; note the SQLAlchemy slug set is derived from class attributes and therefore CANNOT see per-instance codes (reverting (d) leaks `8s2b`), so every case also asserts positively on what the alert must say.

> **Intelligence Center production entry and cloud analysis UX (2026-08-16).** The production release gate is enabled after acceptance. Every admin-tier Intelligence Center viewer (`global_admin`, `management`, `hr`, `finance`, `cloud_admin`, asset admin tiers, and `auditor`) lands at `/admin/intelligence`; employees retain `/me`. Shared cloud-scope analysis is a dedicated top-level detail Tab for administrators, owners, and effective members. It renders the deterministic five-dimension baseline before any model content, provides confidence-first guidance and dimension explanations, and makes the next loop explicit: inspect baseline → explicitly generate shared AI analysis → follow authorization-trimmed evidence → recheck baseline. GETs remain zero-model; only an explicit generate mutation may spend tokens.

> **Landing target ⇒ menu entry is now an enforced invariant (2026-08-18; 0 migration / 0 API / 0 MCP).** Landing a perspective somewhere it has no sidebar entry silently strands it: the ⌘K palette is built from the SAME per-view menu lists (`paletteNavEntries` in [`MainLayout`](../../web/src/layouts/MainLayout.tsx)), so a missing item removes the page from search too and leaves only the browser Back button. That was live for `hr` / `finance` / `cloud_admin` / `asset_admin` / `auditor` from the moment the landing map shipped. Three structural changes close it: the route is one exported constant (`INTELLIGENCE_HOME` in [`lib/roleLanding.ts`](../../web/src/lib/roleLanding.ts)) shared by the landing map and the single `intelligenceNavItem` every view list now carries; the per-view menu selection is a `satisfies Record<ViewRole, …>` map (`menuByViewRole`) instead of a ternary chain, so a new `ViewRole` cannot be added without deciding its sidebar; and [`layouts/menuReachability.test.ts`](../../web/src/layouts/menuReachability.test.ts) pairs every `HOME_BY_VIEW_ROLE` target against that map and fails the build when a perspective lands somewhere it cannot navigate back to. The same test rejects a `RoleRoute roles={[…]}` entry that is not a `ViewRole` — `RoleRoute` reads the already-narrowed `effectiveUser.roles`, so such a name is unreachable config (this is why `asset_global_admin` was removed from the Intelligence guard; holders enter through the `asset_admin` they co-derive, and `apply_view_role_narrow` keeps their global asset scope).

> **Cloud-scope score explanation contract (2026-08-17).** [`cloud_scope_scoring.public_cloud_scope_metric_catalog`](../../server/app/intelligence/cloud_scope_scoring.py) exposes only the static, versioned 18-metric catalogue (localized label, kind, weight and score rule); it never contains subscription facts or local reference maps. `project_cloud_scope_analysis_state` adds current deterministic-metric evidence only after the same RBAC-aware resolver used for AI narrative evidence rechecks each anonymous reference against the requested subscription and caller. The desktop and Feishu H5 cards consume this one contract to show the fact, rule, score, weight, confidence, data time, next step and only visible evidence. Unavailable data stays inconclusive rather than becoming a fabricated zero.

> **Intelligence Center cloud-scope baseline schedule (2026-08-17; migration 503 / 0 API / 0 MCP).** `intelligence.cloud_scope_baseline_daily` refreshes every active subscription's immutable shared business-basis snapshot at 07:45 UTC, after the daily platform snapshot. It never calls a model and isolates one scope's collection failure from all other scopes. This means the AI tab can explain the actual inventory, efficiency-monitoring, cost-history, and sync-data gaps on a first read; explicit generation remains the only path that can spend tokens.

> **Intelligence Center cloud-scope initial warm-up (2026-08-17; migration 504 / 0 API / 0 MCP).** `intelligence.cloud_scope_baseline_bootstrap` is an interval task seeded once per release and explicitly queued by the worker's first-run read-model seed immediately after boot. It writes the same zero-model immutable baselines for pre-existing subscriptions, and disables itself only after a complete run. Exceptional scopes receive bounded automatic retries; after the retry ceiling the daily job remains enabled and the final bootstrap failure is visible to operators. This closes the otherwise day-long first-release gap without weakening the read-only GET or explicit-generation cost boundary.
>
> **Both baseline jobs are bounded + resumable (2026-08-18; 0 migration).** `_refresh_cloud_scope_baselines` takes `budget_secs` + `after_scope_id`, stops on a **subscription boundary** (a snapshot is persisted as one unit, so that is the only safe stopping point), and reports `next_scope_id` / `complete`; each job stores the cursor in its own `scheduled_jobs.config`. Bootstrap: 120s per tick, self-disables only when the sweep genuinely reaches the end. Daily: 1500s per run, fleet covered by rotation. **Why**: measured against prod, a full sweep was ~51s/scope ⇒ ~3.9h for 271 scopes, so neither job had ever completed since being seeded — every run was eventually killed by the 6h stale-run sweeper, and the bootstrap's 60s cadence outliving the watchdog grace is what crash-looped prod for ~16h. **The dominant cost was redundant work**: `detect_anomalies` is integration-scoped (its `resource_keys` filters in Python *after* the widest GROUP BY in the cost domain), so every scope re-scanned what its siblings had already scanned — 96× for aws-primary. `ScopeAnomalyIndex` memoises it per (integration, day) within one sweep: **2.9× measured**, and provably equivalent because the key filter is post-hoc, `limit` is `None`, and each resource's median/IQR uses only its own series. ⚠️ **Equivalence cannot be A/B-tested against prod** — the ingest crons rewrite `cmdb_cost_facts` continuously, and an old-vs-old control pass proved two identical runs already disagree; pin it deterministically in tests instead. **Remaining per-scope cost (~12s, measured, not fixed)**: `resolve_scope_servers`'s **time-unbounded** `SELECT DISTINCT resource_id FROM cmdb_cost_facts` (6.7s/scope) plus the per-scope cost-fact reads (5.9s/scope) — that query is on the billing-attribution path shared with the billing API, so narrowing it carries its own correctness surface and is deliberately left as separate work. **Rule of thumb this leaves behind**: any fleet-wide sweep whose per-item cost is owned by another domain needs a wall-clock ceiling and a cursor, not just an optimisation — the ceiling is what survives the next fleet-size increase.

> **Intelligence Center operating guidance (2026-08-16).** The desktop landing creates a deterministic closed loop before visual analytics: it ranks the three lowest visible dimensions as actionable priorities, explains each domain’s operating purpose, and presents the user path (identify → understand → handle in the existing workspace → verify). The domain drawer supplies versioned methodology, data timestamp, confidence, evidence count, and an action appropriate to the deterministic metric kind, then deep-links to the existing domain workspace. It deliberately does not expose anonymous evidence identifiers or create a generic AI write path; actions remain behind each domain’s original RBAC, audit, idempotency, and Feishu approval boundaries.

> **Intelligence Center recommendation handoff v2 (2026-08-17; 0 migration / 0 API / 0 MCP).** Before an administrator leaves the brief, the client renders a deterministic, localized handling plan from the audited recommendation contract: grounded domains, single-domain versus cross-domain scope, ordered symbolic steps, approval boundary, and the exact fresh-snapshot verification rule. A model still cannot specify a URL, approver, or mutation payload. The server upgrades legacy v1 contracts only from their local fact pointers, discarding any legacy redirect; a cross-domain recommendation fails closed to Intelligence Center rather than guessing one destination. The subsequent domain action remains entirely in the established RBAC, approval, audit, idempotency, and irreversible-operation gates. This is a read/UI clarification until the explicit confirmation starts the existing handoff; it performs no model call and creates no new write primitive.

> **Intelligence Center recommendation recheck state (2026-08-17; 0 migration / 0 API / 0 MCP).** Recommendation reads batch-resolve the latest immutable snapshot for the same `subject_type`/`subject_id`/`audience_key` and compare its facts hash with the handoff baseline. The returned state is intentionally narrow: baseline unavailable, awaiting snapshot, awaiting fact change, or facts changed/review required. A changed hash is evidence only that the observed package changed; it neither resolves the recommendation nor records confirmed or realized savings. The projection is zero-model and zero-write, filtered through the same recommendation subject/RBAC plan before it reaches the client.

> **Intelligence Center deterministic critical-health digest (2026-08-17; 0 migration / 0 API / 0 MCP).** A newly persisted shared platform snapshot emits one aggregated Outbox digest per active IT/global administrator only when a known domain has score `<60` and confidence `≥70`. The predicate reads versioned deterministic snapshot rows alone: model prose, recommendation priority, and model-supplied severity cannot page anyone. The immutable snapshot is locked before the existing per-recipient Outbox rows are read, so scheduler recovery/concurrency cannot duplicate a digest; an unchanged daily hash creates neither a new snapshot nor a duplicate notification. The Feishu/email card links to Intelligence Center and explicitly directs the recipient to review the evidence, handling plan, and recheck state rather than treating the alert as an automatic conclusion.

> **Intelligence Center weekly platform brief (2026-08-19; migration 520 / 0 API / 0 MCP).** `intelligence.analysis_daily` retains its stable key and complete run history, but runs at `35 7 * * 1` UTC (Monday) rather than daily. Deterministic `intelligence.snapshot_daily` remains daily. The platform model prompt explicitly prohibits arithmetic and limits the weekly narrative to a small, reviewable set of grounded items, so oversized or weakly supported output is less likely to fail the strict grounding contract; invalid output is still rejected rather than relaxed into the product.

> **Intelligence Center H5 subscription-cost remediation (2026-08-17; 0 migration / 0 API / 0 MCP).** When cloud-scope analysis marks the business-cost source partial or missing, the mobile card links only to `/m/cloud/scopes/{scope_id}/billing`, never to a fleet billing screen. The page reuses the existing membership/owner/admin-authorized `GET /cmdb/scopes/{scope_id}/billing-detail` contract and its cost-measure boundary, exposing the affected subscription’s data availability, latest fact date, current total, budget, projection, service mix, and top resources. Missing facts remain explicitly unavailable rather than becoming a misleading zero-cost result.

> **Intelligence Center H5 resource/monitoring remediation (2026-08-17; 0 migration / 0 API / 0 MCP).** Machine-attribution and efficiency/monitoring gaps in a cloud-scope analysis now link to `/m/cloud/scopes/{scope_id}/efficiency`. That route is the existing mobile efficiency workbench operating in scope mode: it uses `GET /cmdb/efficiency/scopes/{scope_id}/summary` and `/instances`, preserving their server-side attribution and visibility resolution instead of querying a fleet list and filtering it in the browser. Scope owners retain the complete subscription view; other callers are narrowed to their established self/team visibility. The page distinguishes no actionable rows from a missing-source diagnosis and gives the user a concrete next check.

> **Intelligence Center H5 synchronization remediation (2026-08-17; 0 migration / 0 API / 0 MCP).** A stale or partial cloud-scope synchronization source links to `/m/cloud/scopes/{scope_id}/sync`, which reads the existing member/owner/admin-authorized scope record and makes the affected provider and last synchronization explicit. Only a caller satisfying the existing cloud-management predicate receives the existing `cloudApi.triggerSync(integration_id)` action; its server authorization remains authoritative. The action copy makes clear that a connection synchronization refreshes subscription/resource data for that connection rather than promising an unscoped one-click fix. Other authorized members receive the responsible owner and a precise handoff, then return to rerun the shared analysis once the facts refresh.

> **Intelligence Center data-coverage clarity (2026-08-17; 0 migration / 0 API / 0 MCP).** Every cloud-scope data-quality source now shows its concrete coverage and fact time on both desktop and Feishu H5: a count source reports the actual available/expected values, while a source without a meaningful count says so explicitly; absent timestamps are stated as absent, never formatted as a current value. This is presentation of the already-versioned deterministic snapshot only—no model call, browser-side inference, or broadened object access—and remains paired with the same authorized subscription-level remediation path.

> **Intelligence Center verified savings reconciliation (2026-08-17; migration 510 / 0 API / 0 MCP).** The CMDB efficiency compute handler reconciles existing realized-savings events into a durable Intelligence recommendation link only after an administrator has started a `reclaim` or `configure` handoff for a cloud/cost recommendation and the recommendation's anonymous evidence locally resolves to the exact event server. It never matches model text, titles, or an approximate resource name; each saving event has a unique link, preventing double credit. Writes use MySQL/TiDB atomic ignore-on-duplicate semantics, so concurrent efficiency jobs safely converge instead of rolling back on the unique key. ROI reads use the existing CMDB savings service to calculate linked event amounts live, retaining its billing-lag protection and restart/re-upsize self-correction. Automatic and manual realized savings are separately exposed then summed; the management view identifies the verified-event contribution.

> **Cloud-scope AI data-gap handoff (2026-08-17; 0 API / 0 MCP).** The shared analysis card consumes its deterministic aggregate `data_quality.sources` contract and now hands an authorized viewer directly from an incomplete source to the current subscription's Resources, Billing, or Summary work area. The card does not invent a new workflow or widen access: parent detail pages only switch their existing local tab, so member and administrator routes preserve their own RBAC projection.

> **Cloud-scope AI failure explanation parity (2026-08-17; 0 API / 0 MCP).** Desktop and Feishu H5 render the same classified, non-sensitive failure explanation for a rejected or interrupted model generation. A rejected grounding check is explicitly an AI safety outcome—not a claim that subscription facts are missing—and the deterministic health baseline remains available.

> **Intelligence Center full explanation workspace (2026-08-16).** Every domain card and operating-guide priority now resolves to `/admin/intelligence/domains/:domain` rather than relying on a constrained drawer. The read-only page renders a human explanation for each deterministic metric: current fact, metric type, immutable score rule, score, weight, confidence, data time, evidence count, and the existing operational workspace that can correct it. An unavailable metric explicitly says it is inconclusive—not zero—and explains that it is excluded from score math while lowering confidence. The route uses the existing domain read contract and remains zero-model, RBAC-filtered, and trilingual.

> **Intelligence recommendation handoff parity (2026-08-17).** `intelligence_execute_recommendation` is a true behavioral MCP twin of `POST /api/v1/intelligence/recommendations/{id}/execute`: a notification recommendation enqueues through the existing Outbox; every other action upgrades the local, grounded handoff contract, records the recommendation as `in_progress`, and returns the existing workbench plus fixed approval/verification steps. It never invokes a model-directed domain mutation, supplies a model-controlled URL, or bypasses domain RBAC, Feishu approval, idempotency, audit or irreversible-action gates. This avoids the former false MCP conflict while preserving the same safe handoff as the desktop and H5 experience.

> **Intelligence H5 recommendation closure (2026-08-17).** The Feishu H5 overview no longer reduces recommendations to an unreadable title strip. A native sheet now exposes the grounded summary, current snapshot recheck state, approval boundary and fixed local handling steps; recommendation managers can mark a new item viewed, explicitly start the same existing-workbench handoff, and mark an in-progress item resolved after recheck. The mobile view uses the same zero-model read contract and existing HTTP mutations as desktop; it does not construct a mutation payload, expose a model-supplied link, or enlarge any role's authority.

> **Intelligence H5 workbench routing (2026-08-17).** Starting a recommendation handoff in Feishu first maps the server-owned desktop workbench route through `mapDesktopPathToMobile`. Where a native mobile equivalent exists, the user stays in the H5 shell and continues within the matching domain; a genuine desktop-only page remains an explicit desktop handoff. The mapper retains its same-origin/path-safety validation, and neither the model nor the client supplies a mutation target.

> **The frontend gates actually run in CI (2026-08-18).** `go-ci`'s frontend job audited dependencies and built the image, nothing else — and the image build compiles with `tsconfig.app.json`, which excludes `*.test.tsx`. So a type error in a test file, or a suite that exits non-zero, reached `main` unopposed; both did on 2026-08-18. The job now runs `pnpm lint`, `pnpm typecheck` and `pnpm test`, matching what CLAUDE.md already claimed. `pnpm test` is judged by its EXIT CODE: an unhandled rejection prints an all-green summary and still exits 1.

> **The privacy boundary is checked on the shape its rules were written for (2026-08-18).** `assert_llm_safe` matches denied key NAMES exactly, and flattening rewrites every key to `<parent>.<leaf>` — after which none of those names can match, leaving only value regexes over the bulk of the payload. `build_model_fact_package` now asserts BEFORE flattening as well as after. Separately, the guard now runs its value regexes over dict KEYS too: list items are addressed by their own `key` field, which promotes arbitrary fact content into the key position, where a value that would be rejected one line later used to travel freely. **Corrected 2026-08-18**: that key scan is right for a NESTED key and wrong for a flattened one — the post-flatten key is an address this code just joined, and `metrics.cloud` reads as `<label>.<TLD>` because `cloud` is in the TLD list, so all 19 `cloud.*` metric keys made every cloud-subscription package `UnsafeFactPackage`-rejected (fail-closed outage, not a leak). The post-flatten call now passes `keys_are_flat_paths=True` and scans per dot-segment; the nested pass — which runs unconditionally and sees every segment either as a structural key or as a list item's `key` VALUE — remains the boundary. Flattening adds no new strings (it joins, and truncates a namespace prefix), which is why the second pass can afford the weaker reading.

> **One parser decides what a citation grounds (2026-08-18).** `recommendations.fact_dimensions()` is the single place that turns a `fact_refs` entry into the dimensions it grounds, across all live spellings (nested pointer, prefix + flattened key, bare flattened key). Four call sites previously re-implemented it inline; three of them are VISIBILITY filters (recommendation list, ROI totals, AI-narrative scoping), so an unrecognised spelling failed silently and completely — after the fact package was flattened, every role scoped to fewer than all eight dimensions saw an empty list and a zero ROI, while `global_admin`/`management` short-circuit the filter and therefore could not observe the fault.

> **Recommendation handoff targets are route-verified and fact-level (2026-08-18; 0 migration / 0 API / 0 MCP).** [`_WORKBENCH_BY_DIMENSION`](../../server/app/intelligence/recommendations.py) previously named five paths that are not routes on either shell (`/admin/people`, `/admin/software`, `/admin/cloud`, `/admin/cost`, `/admin/security`), so "开始处理" landed on the 404 page. Targets are now (a) real declared routes and (b) chosen from the grounded fact pointer's leaf rather than only its dimension, so an HRIS-freshness deduction opens the sync console and a key-rotation one opens the API-key admin. The contract is `contract_version: 3`; stored v2 rows are re-derived on read so existing recommendations heal instead of preserving a dead target. Two guards make regression impossible: `test_recommendation_handoff.py` parses `web/src/App.tsx` and every provider's `aggregate_facts` (a target that is not a route, or a refinement key no provider publishes, fails), and `test_deep_link_contract.py` requires every target to be classified in `contracts/mobile-deep-links.json`. The model still supplies only an action *category* — never a URL.

> **Every data gap names its source, coverage, as-of time and remedy (2026-08-18; 0 migration / 0 new API / 0 new MCP tool).** `read_overview` now returns `data_gaps[]`, one entry per short dimension, each carrying `reason_code` (`no_snapshot` / `provider_unavailable` / `low_confidence`), the dimension's OWN as-of time (the stalest participating metric, not the snapshot-wide cutoff — the point is to show *which* source is stale), `observed_metric_count` of `expected_metric_count`, the confidence, and a `workbench_path` taken from the same route-verified table the recommendation handoff uses, so "where do I fix this?" has one answer on both paths. It is derived entirely from stored snapshot metadata: no model produces or explains a gap, and `intelligence_get_overview` inherits the field through the shared `read_overview` (no new tool, hint unchanged). The org surface also states `score_version_mismatch` and distinguishes "your role cannot see cost amounts" from "the cost data layer is not connected" — all three were previously silence behind one "insufficient data" sentence. **Landing safety:** `/admin/intelligence` is the login landing for seven admin-tier perspectives, so a closed release gate (404) or an empty scope (403) now lands on a usable page instead of the 404/403 screen; the fallback must never be `/` or `/dashboard`, which route back through `RootLanding` and loop. **H5 parity (2026-08-18):** `MIntelligenceCenterPage` renders the same per-dimension gap panel from the same `intelligence.gaps.*` keys (its `workbench_path` goes through `mapDesktopPathToMobile`, leaving the shell only when a dimension has no H5 twin), and a 403 there redirects to `/m/home` instead of parking on a dead-end `ErrorBlock`.

> **Recommendation closed loop on desktop (2026-08-18; 0 migration / 0 new API / 0 MCP).** `POST /recommendations/{id}/execute` now returns `status` for `action_kind == "notify"` as it already did for every other kind — clients merge the response into their row, so omitting it wrote `undefined` over a valid status and left the recommendation unresolvable. The panel maps generation failures from the envelope's error **code** rather than the HTTP status (three distinct 409s — in-progress, `INTELLIGENCE_NO_SNAPSHOT`, `INTELLIGENCE_NO_DATA` — all read as "already generating"), surfaces the server's own 429 text so a cooldown states its remaining window, distinguishes a 403 read from "your first brief is coming", pages rather than truncating at five, hands off through SPA navigation (and does not navigate at all when the cross-domain target is the page the reader is already on), and refreshes with the page. Two loop actions that the API always accepted but no client ever sent are now reachable: **ignore with a mandatory audited reason**, and **manual realized-benefit entry** (`realized_savings` + `currency`), deliberately optional so resolving does not manufacture a number. ROI reports attested and automatically reconciled amounts separately instead of one merged figure.

> **AI-surface deep links resolve on both shells (2026-08-18).** `/admin/intelligence` had no mapper rule at all, so the recommendation card, the critical-health card, and the cross-domain handoff each hard-navigated a Feishu H5 reader out of the shell to the desktop copy of the page they were on; `?recommendation=<id>` was ignored by both shells, so a push about one row opened an unordered list. Both shells now read the parameter, widen the read so a deep-linked row outside the default slice is still found, and say so when the row is no longer open. Four adjacent mis-mappings found by the same audit are fixed with it: privileged AI-evidence links (`/admin/cloud/servers/:id`) had no rule and silently did nothing on tap; `/admin/notifications/outbox` resolved to the reader's *personal* bell inbox instead of the delivery queue; twelve static `/admin/assets/<page>` routes were read as asset ids and rewritten to a mobile detail page that 404s; and `/assistant/confirm` — a public, signed-token landing page — was missing from `FeishuMobileGuard.TOKEN_ACTION_PATHS`, so the assistant card's 查看并确认 went through a silent-SSO bounce first. A new test pins the guard list against the contract's token-page set.

> **归属常量归位 + 自助可见性补齐 AWS/Azure（2026-08-18；0 迁移 / 0 API / 0 MCP）**。
> `SCOPE_RAW_PATHS` / `SCOPE_ACCOUNT_RAW_KEYS` 现在住在
> [`cmdb/models.py`](../../server/app/domains/cmdb/models.py)：`service.py` 已 import
> `visibility.py`，反向 import 会成环，所以常量放在两边都 import 的 models 里 ——
> **一份定义**同时喂给 `resolve_server_scope_ids`（解析器）、`_scope_inventory_clause`
> （scope 侧 SQL）和 `visibility_clause`（自助可见性）。
> [`visibility.py`](../../server/app/domains/cmdb/visibility.py) **自带一份**归属实现
> （不经由解析器），其账号戳链路同样只接了 `_project_id` ⇒ 2026-07-22 那条用户批准的
> 契约「订阅 Owner 能看到自己订阅里的机器」**对 AWS/Azure 从未生效**（成本链路也接不上），
> 5147 台机器的 Owner 看不到自己的机器。现已扩到四个戳；**性能契约不变** —— 仍只是
> servers 表上的 JSON 等值（4 个 extract），**不**把成本表扫描放进每请求计划
> （2026-07-23 的 8176 事故约束）。这是朝已批准契约方向的**权限面扩大**：受益者是订阅
> Owner 本人及其上级链，他人订阅仍 404。

> **证书状态是读时推导的（2026-08-18；0 迁移 / 0 API 新增 / 0 MCP）**。
> `cmdb_certificates.status` 由 `derive_cert_status` 在**写入时**算出后不再重算，所以
> 探测一停状态就永久冻结（生产实测：3 行显示「即将到期」而证书 52 天前已过期，其
> `last_probed_at` 停在 6 月）。
> [`cert_service.effective_cert_status`](../../server/app/domains/cmdb/cert_service.py)
> 在读时按 `not_after` 重新推导（同 `_effective_triage` 的形状：存储列仍是写入方真值），
> 响应加 `status_stale` 把**坏掉的探测暴露出来**。`unreachable` **不**参与重算 ——
> 那是探测结果而非生命周期，合并会把探测故障藏起来。`status` 筛选随之从 SQL 移到内存
> （与显示同源；该端点本来就因 Owner live 解析在内存里筛选），健康度
> `domain_certificate_health` 同步改用有效状态（分数不变，只求指标与证书页口径一致）。

> **服务器→订阅归属：驱动的账号戳是第一链路（2026-08-18；0 迁移 / 0 API / 0 MCP）**。
> [`resolve_server_scope_ids`](../../server/app/domains/cmdb/service.py) 此前第 1 步只读
> `raw_data._project_id`（仅 GCP），其余云全靠 `cmdb_cost_facts`
> （`resource_id == instance_id`）回落 —— 而 **AWS 的成本行是账号级聚合**
> （`resource_id='aws-account:<id>:<service>'`、`resource_type='aws-aggregated'`）、
> **Azure 的是 ARM 路径**，都不等于实例 ID，所以这两家永远解析不出订阅。第 1 步现在遍历
> `_SCOPE_ACCOUNT_RAW_KEYS`（由 `_SCOPE_RAW_PATHS` **派生**，scope 侧 SQL 谓词与 server
> 侧字典取值不能再漂）：AWS `_aws_account_id` / GCP `_project_id` / Azure
> `_subscription_id` / 阿里云 `__sub_account_id`，值即所属 scope 的 `external_id`
> —— 是云厂商自己的账号归属，不是名字或标签推测。Azure 只归到**订阅** scope，不归到
> `azure_resource_group` 子 scope（戳是订阅 GUID，且资源组名仅在订阅内唯一）。
> 生产 2026-08-18：5136 台 AWS + 11 台 Azure 机器带着能对上活跃 scope 的账号戳却解析为空，
> 于是服务器列表的继承 Owner、效率页、Owner 周简报、节省归因**四个面**对它们全显示「-」。
> **UCloud 驱动不写任何账号键**，那 4 台仍解析为空（保持显示「-」，不伪造）。
> `visibility.py`（非管理员可见范围）**自带一份**归属实现、不经由本原语，因此本次修复
> 不改变任何人的可见范围；那份副本存在同样的缺陷，属独立改动。

> **云与网络健康度事实按已上线策略取数（2026-08-18；0 迁移 / 0 API / 0 MCP）**。
> [`CloudNetworkFactProvider`](../../server/app/intelligence/providers/cloud_network.py)
> 的三个指标此前把**平台自己的策略**读成失败，其中两个让目标档位在算术上不可达。
> ① **`access_governance`** 的 governed 现在复用既有谓词而不是裸测 `expires_at > now`：
> `load_perpetual_person_ids`（`cloud_admin` / `global_admin` 的 `expires_at IS NULL` 是策略，
> 2026-07-29 事故后所有写入方与扫描方共用的唯一真值）、订阅 Owner 自持授权（两个扫描都
> 豁免）、以及 **prod 订阅整体移出计分口径**（R&D 安全团队全权负责，两个 pass 都跳过
> `PROD_ENVIRONMENTS`；改为 `prod_grant_count` / `prod_overdue_grant_count` 两条事实，与
> admin-overview 的只读逾期计数一致）。生产复核 62.65% → 99.38%，缺口 740 → 9。
> ② **`sync_freshness`**：`last_synced_at` 在不同出身的行上不是一个意思。带
> `parent_scope_id` 的**子订阅**（如 Helix 为 AI API Key 申请在已跟踪订阅内开的
> `azure_resource_group`）永远不会被订阅级发现同步枚举，其时间戳恒等于创建时刻 ——
> 现在**继承父订阅**的新鲜度；而**从未被确认过的根订阅**保留严格读法照旧扣分。生产上 6 行
> 资源组子订阅全部 `last_seen_sync_job_id IS NULL`，其中 1 行 active 曾把整个维度钉在最差档，
> 而 233 个发现同步的订阅都在 1 天内。
> ③ **`owner_coverage`** 现在读**有效 Owner**（自身存储列 → 继承自订阅，复用
> `resolve_inherited_server_owners`；不回写任何一行，半年度 Owner 确认 cron 依赖存储列
> 因此不受影响）。此前只读存储列 ⇒ 生产 6503 台活跃机器里仅 13 台「有 Owner」（0.2%），
> 而 6486 台能解析到在职订阅 Owner；余下绝大多数是随 nodepool 生灭的 k8s / 自动伸缩节点，
> 从不逐台指派。复核 3.67% → 99.94%，真缺口 4 台。
> ④ **`resource_waste_rate`** 的分子改用效率工作台同一个 `_effective_triage`，Owner 的
> triage 决定才能反映到分数（此前 Owner 把待处理清零、指标仍报原始积压）；并新增
> `priced_actionable_efficiency_count` / `actionable_monthly_savings_usd` 把「有成本归属」与
> 「无成本归属」的分裂显式说出来（生产：5103 条已分类里仅 540 条可定价）。
> **未 bump `SCORE_VERSION`** —— 指标 key / 标签 / kind / 权重 / 阈值全未动，改的是 provider
> 观测口径（原口径与已上线策略矛盾，属缺陷）；历史快照不重写，趋势上会出现一次台阶。
> 前端 4 处渲染点改为 `metricNextStep.<metric.key>` 优先、回落 `<metric.kind>`，让「下一步」
> 不再把资源组子订阅的问题说成「检查连接器」。

> **「未观测」是第三种状态，不是 False —— 安全与合规维度的取数口径（2026-08-18；迁移 517）**。
> `auditor_mfa_status.is_enrolled` / `is_enforced` 是 NOT NULL 布尔，所以**答不出来的 provider
> 只能写 `False`**，与「实测未启用」无法区分。生产 528 条 Atlassian 行全是
> `policy_status='policy_absent'`（Atlassian 公开 API 没有逐用户 2FA 属性，只能读组织认证策略，
> 而那策略读不到），六个面把它读成实测不合规：健康度 `mfa_enforcement_coverage`（0.0% / 30 分
> 且宣称 100% 可信度）、findings 工作台（412 条无法处理）、`auditor_risk_scores`
> （570 人里 528 人背 `mfa_missing_provider`）、`mfa_coverage` ISMS 控制项、insights 首屏、
> ISMS 评估证据表。迁移 517 加 `enrollment_observed` / `enforcement_observed`（可空布尔，
> **不回填**），**只由 `MfaSyncService` 写**（唯一知道上游到底答了什么的代码）。
> **任何新读 `is_enrolled` / `is_enforced` 的代码必须先过这两个闸**，这是该语义的唯一真值，
> 与 offboarding 的 `compute_triage_bucket()` 同一个位置、同一个用法。NULL 读作「未观测」，
> 方向安全（退出分母而非变成假指控），次日 sync 按真值重述。
>
> 同一维度的**镜像缺陷**：瞎着的探测器读起来像「一切正常」。`public_exposure_scan_daily`
> 每轮 S3 腿都 `AccessDenied`，却交出 100/100 + 100% 可信度。`public_exposure_rate` 与
> `credential_risk_rate` 的**可信度**现在取自 `scheduled_jobs.last_result_summary` 的真实覆盖面
> （`sources_scanned` / `scanned_sources` / `pulled_sources` 减去 `partial_sources` 与
> `errors[].source`；**既扫到又报错的源算「瞎的」**，与 `PublicExposureScanService` 自己的
> auto-resolve 闸门同一条规则）。分值不动 —— 只是不再宣称看全了。
> **未 bump `SCORE_VERSION`**：指标 key / 标签 / kind / 权重 / 阈值全未动，改的是 provider
> 观测口径（属缺陷修复）；历史快照不重写。生产复算：维度 56 → 70，可信度 100% → 83.29%。
> 剩下的 30 分是真账（特权复核 2026-Q3 逾期 24 项未定 + 502 条逾期密钥轮转），
> 判据是「目标档在当前策略下算术可达 ⇒ 别动口径」。
>
> **适配器读的 config key 必须是 driver 真存的那个（静态闸门，2026-08-18）**。
> `GoogleAuditorAdapter` 三个方法都读 `admin_email` / `delegated_subject` / `subject`，
> 而 `google_workspace` driver 声明的是 `impersonate_subject_email` —— 守卫子句每次命中，
> 抛出的 `IntegrationMissingError` 文案却是「No active integration found」，于是
> **MFA / 登录事件 / admin role assignments 三条源从上线起全空**（`auditor_signin_events`
> 生产全表 0 行，而 `signin_anomaly_monitoring` 控制项一直报 pass）。新增
> `IntegrationConfigIncompleteError`（子类，`except IntegrationMissingError` 契约不变，只改文案
> 并点名缺哪个 key）+ [`test_adapter_kinds.py`](../../server/tests/test_auditor/test_adapter_kinds.py)
> 的 `test_adapter_config_keys_exist_in_the_driver_schema`：AST 扫每个 auditor 适配器的
> `config.get(...)` or-链，每条链至少一个 key 必须在对应 driver 的 `config_schema` 里。
> 该闸门上线当场又抓到两个同类死读：`GitHubAuditorAdapter` 读 `organization`/`org`（driver 是
> `org_slug`）、`AwsAuditorAdapter` 读 `regions`/`region`（driver 是 `default_region`
> —— 意味着安全组与 ACM 扫描一直只扫写死的 `us-east-1`）。与 #2122 的 `KIND` 闸门同源：
> 适配器的所有测试都在方法层 mock，常量/键名从来没被拿去对过注册表。

> **云订阅 AI 分析卡的四条诚实状态与限流口径（2026-08-18；0 迁移 / 0 API / 0 MCP）**。
> `CloudScopeAnalysisCard` 是**有标题页签的整个 body**（两个订阅详情页无条件写进 `items`），
> 所以它不再有 `return null` 这个选项：首屏 = 骨架屏、release gate 关闭/无权（403/404）=
> `EmptyState` + `cloudScopeAnalysis.unavailable`、读失败 = 带重试的 Alert。`releaseProven`
> 状态位随之删除。**移动端 `MCloudScopeAnalysisCard` 刻意保留 `null`** —— 它内联挂在 H5
> 订阅详情里、没有自己的标题，空状态只会变成噪音。两端的生成失败一律按信封的稳定 error
> **code** 分流（与 `IntelligenceBriefPanel` 同一形状、共用 `intelligence.generationRateLimited`）：
> `INTELLIGENCE_COOLDOWN` / `INTELLIGENCE_DAILY_QUOTA` 额外把**服务端原文**带出（那里才有
> 配置出来的分钟数与每日上限），其余 code 一律不透出服务端原文。展示位是常驻 Alert /
> NoticeBar，不是 toast。**不做刷新倒计时**：冷却锚点是服务端"该 subject 最新一条
> pending/succeeded 尝试的 `created_at`"，既不等于 `generated_at`，也不在
> `cloud_scope_projection` 里 —— 客户端只显示 `formatRelative` 相对时长，绝不推算窗口。
> 想改成显示窗口就必须由投影新增字段，并同步 MCP 孪生的同名同权限字段。

> **云订阅 AI H5 轮询恢复/上限（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> 非 silent 的 latest 重试若仍为 generating 会递增 poll cycle，重新挂载自终止循环；
> 单轮最多 200 次/10 分钟，超限后只暂停 GET 并展示继续检查，不会重放生成 mutation。

> **云订阅 AI H5 轮询终态（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> generating 轮询由每次 GET 响应决定是否重新挂三秒定时器，成功/失败/卸载即停止；
> 不依赖不变的 status effect dependency。release gate 已证明后的读取错误提供原生重试。

> **云订阅 AI 原生飞书 H5 卡（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> `MCloudScopeAnalysisCard` 使用 antd-mobile Progress/Collapse/Popup 与语义变量，嵌入
> 既有管理员订阅详情，不加载桌面 chrome。latest 首屏只读；history 只在打开 Sheet
> 后读取；generate 仅显式点击，force 通过 React 19 feedback bridge 原生确认。五维
> 分数、双语叙事、证据移动深链、低可信度/旧事实/完整性/失败状态和特权 Token 历史
> 投影完整保留。latest/history/generate 独立序列，release gate 未证明前 fail-hidden。

> **云订阅 AI 请求隔离加固（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> latest、history、generate 使用互不干扰的请求序列；路由复用、三秒生成轮询和旧请求
> 完成不会跨订阅回写、误弹提示或让历史 loading 悬挂。只有一次成功读取证明 release
> gate 已启用后，后续 5xx 才显示重试；首次 5xx 与 403/404 都 fail-hidden。

> **云订阅 AI 桌面历史与请求隔离（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> 历史只在用户打开抽屉后调用既有 GET，首屏模型/历史调用均为零；固定最多展示 50 条
> 摘要，不复制完整叙事。卡片以 `scope_id + request sequence` 隔离 latest、history 与
> generate 的异步回写，React 路由复用不会让上一订阅结论出现在新订阅。历史中的模型与 Token
> 元数据是否存在完全服从服务端特权投影，客户端不推断或补取。

> **云订阅 AI 桌面卡（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> `CloudScopeAnalysisCard` 同时嵌入 admin 与 my-cloud 既有订阅详情 summary，不创建新
> 导航。首次 GET 在 release gate 成功前渲染为空，disabled/无权 404/403 均不暴露
> 产品或订阅存在性；读路径不触发 history/POST。显式点击才生成，409 单飞竞争转为
> GET 轮询，强制生成通过 modal 二次确认且 TypeScript 判别联合要求 force/confirm
> 同时为 true。卡片完整呈现确定性五维分、可信度、双语结构化叙事、本地 RBAC 证据、
> 上次生成/数据截止/发起人/缓存状态，并 fail-visible 处理旧事实、失败尝试和完整性异常。
> CSS Module 使用 Helix/Ant 语义 Token，支持暗色、窄屏与 reduced-motion。

> **云订阅 AI Web 合同（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> `web/src/api/cloudScopeAnalysis.ts` 完整描述共享五维健康、结构化双语叙事、按权限
> 映射证据、生成状态/Token 特权字段和历史摘要。`latest`/`history` 是纯 GET，唯一
> `generate` 方法才执行成本 mutation，并显式传 `force + confirm_force`；同步百炼
> 调用使用 120 秒独立超时，避免全局 30 秒数据库请求预算在服务仍执行时中断客户端。
> 契约测试固定读取零 POST。当前无组件引用该客户端，release gate 仍保持隐藏。

> **云订阅共享 AI 分析数据面（2026-08-16；3 API / +3 MCP→466 / 入口关闭）**。
> `GET /api/v1/cmdb/scopes/{id}/ai-analysis`、`.../history` 与显式
> `POST .../generate` 通过 `cloud_scope_facade` 与三个 MCP 孪生工具共用一条边界：
> release gate → 精确订阅权限 → 读取预算/生成内核 → 权限安全投影。GET 与 history
> 只读持久化状态，模型调用恒为零；POST/MCP generate 才可能调用统一百炼，并复用
> 7 天事实 Hash、30 分钟冷却、每日额度与 DB 单飞。MCP generate 标记为 mutation，
> 描述中明确 Token 成本。Owner、有效成员与域管理员共享业务口径结果；无权访问以
> 404 防枚举。强制重算只给 global/cloud admin 且必须确认。公开证据中的机器、服务
> 和资源除 scope/integration 校验外，还必须由该订阅 `CmdbCostFact` 唯一归属证明，
> 同 integration 的其他订阅对象不能借匿名引用泄漏。

> **云订阅 AI 公开投影（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> `cloud_scope_projection` 将共享五维健康、最后成功 AI 输出、最新尝试和历史摘要编译
> 为唯一公开合同。分析引用只使用该分析生成时的不可变 snapshot/reference map；每个
> grant/server/cost 对象再按当前授权订阅和调用者身份过滤。普通成员只能解析自己的
> grant，其他成员的角色/ID 不返回；直接 Owner 与域角色沿用既有可见性。跨 scope、
> audience/kind/Hash 不一致时 AI 内容 fail closed，当前确定性健康仍可用。failure
> message、integration ID、原始 reference map、邮箱不会进入公共结构；模型/Token
> 元数据只对域角色公开。历史只返回摘要，不复制全部叙事以控制 365 条读取上限。

> **云订阅共享按需分析服务（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> `cloud_scope_analysis` 是订阅 AI 读取/生成的唯一服务边界。`can_read_cloud` 域角色
> 可读全域；其他员工仅凭当前 Owner 或 active 且未到期的直接授权进入，Azure RG
> 可继承仍存续父订阅的同类权限。pending/到期/无权请求均以 404 防枚举；强制生成
> 仅 `can_manage_cloud` 且要求二次确认。状态与历史读取只查持久化账本，最新尝试失败
> 时仍返回上一成功版；显式生成先编译确定性快照，再进入统一的事实 Hash 缓存、
> 冷却、额度、DB lease 单飞和双语验证链。关闭订阅可读历史但不能产生新模型成本。

> **云订阅共享不可变快照（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> [`build_cloud_scope_snapshot`](../../server/app/intelligence/cloud_scope_snapshot.py)
> 将真实事实包以 `subject_type=cloud_scope`、`audience_key=business_shared`、
> `score_version=cloud-scope-health-v1` 写入现有长期快照账本。专用预编译持久化入口
> 复用平台快照的唯一身份、savepoint 并发单写、clean-session 和提交语义，并在写入
> 前再次扫描模型可见 dimensions/facts。Hash 还绑定 server-only reference map，
> 防止聚合数字未变但底层对象替换时复用带旧证据映射的行。当前无路由/定时任务
> 调用该服务，入口开关仍关闭，模型调用为零。
>
> **云订阅 AI 分析真实事实包（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> [`collect_cloud_scope_facts`](../../server/app/intelligence/cloud_scope_facts.py)
> 将单订阅的生命周期/Owner、有效与待审批授权、角色治理、六个月业务折前成本、
> 预算预测、异常、云库存归属机器及现有效率账本编译为五维评分和匿名聚合包。资源归属
> 优先读取云同步写入的精确 provider 标识（AWS account、Azure subscription / resource
> group、GCP project、阿里云子账号），再以账单 `resource_id → instance_id` 回退；因此
> 新建、停机、免费或账单延迟的机器仍会进入效率判断。聚合事实保存直接库存和账单回退
> 数量，供受权用户理解“无机器 / 未关联 / 无监控”的差异，绝不发送真实标识到模型。成本
> 归属复用账单详情公开的 `scope_fact_filters`：租户级走 `scope_id`，Azure RG 走
> `(integration_id, resource_group)`；无法形成安全归属键时匹配空集，绝不全表读取。
> 模型包内服务与资源均为引用，真实服务名/资源 ID/人员只留本地 reference map。
> 共享口径只比较 `budget_measure=list` 预算；旧 net 预算只标记 basis mismatch，
> 不输出 burn rate，避免折前/实付比值泄漏。无法解析任何云库存时资源维度才降可信度，
> 不把未知库存伪装成健康空订阅；异常与效率均复用现有确定性结果、零模型调用。
> 机器 Owner 采用平台既有“机器显式 Owner，否则继承有效订阅 Owner”语义；订阅
> Owner 有效时，机器不因自身 `owner_person_id` 为空被重复报为无主。成本趋势固定为
> 包含当前月的六个自然月；异常率分母只含检测器实际 30 天窗口内至少七天数据的资源。
>
> **云订阅 AI 分析评分内核（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> `cloud_scope_scoring` 使用独立不可变 `cloud-scope-health-v1`，按成本预算 25%、
> 资源效率 25%、权限治理 20%、Owner 生命周期 15%、数据同步 15% 汇总 18 项
> 确定性指标。每项继续复用 coverage/failure/freshness/direct 的版本化阈值；缺失
> 数据降低 confidence 且不变成健康零分，未知/重复 key fail closed。该层不调用模型，
> 后续共享事实快照与 AI 解读只能消费其输出。

> **AI 大脑飞书 H5 总览（2026-08-16；0 API / 0 MCP / 历史发布状态：入口关闭）**。
> `/m/admin/intelligence` 复用同一 overview/history/domain 访问投影，在移动端以健康
> 环、横向领域卡、轻量 SVG 趋势和底部领域工作区呈现；首屏不预取八个 domain，
> 所有读取强校验 `model_calls=0`。关闭开关的 404 进入移动 not-found，403 独立呈现，
> 无菜单曝光。指标目录现携带 score-versioned 中英日规则说明，桌面和 H5 均以服务端
> 合同为准；Redis 旧无 TTL 超限计数保证在 60 秒边界内恢复。

> **AI 大脑 H5 建议闭环对齐桌面（2026-08-18；0 迁移 / 0 API / 0 MCP）**。
> `/m/admin/intelligence` 的全部写操作（生成简报、强制刷新、标记已查看、标记已解决并填报
> 实际收益、带原因忽略、通知管理员、开始处理）统一走 `useMobileMutation`，取得其在途重入闸
> 与 `Idempotency-Key`；`intelligence` 的写端点目前**未挂 `@idempotent`**，故该键在服务端
> 惰性，`retryOnNetworkError` 必须保持关闭。生成失败按信封 `errors[0].code` 分流成六种独立
> 结论（生成中 / 无确定性快照 / 无带时间戳事实源 / 冷却 / 当日额度 / 无权限），其中冷却与额度
> 直接透传服务端原文（剩余窗口只在那句话里），与桌面 `IntelligenceBriefPanel` 同一张映射表。
> 建议列表改为读全量可见集（仍强校验 `model_calls=0`）、标题带总数、超过 3 条可展开，
> 任何一条都不会永久不可见；ROI 按人工填报与已核验云节省事件分开呈现。交接抽屉补齐跨域提示、
> 涉及领域与验证方式；跨域建议的 `workbench_path` 指回 AI 大脑本身时不再 `navigate()` 当前页，
> 而是与桌面一致提示已标记为处理中。移动侧不新增任何写原语，动作仍落在各领域既有 RBAC、
> 审批、审计与不可逆闸内。三个 intelligence 移动 CSS 模块迁移到 `--m-*` 令牌标尺；语义色
> 保留 `--adm-*` 以与同屏 antd-mobile 组件一致。

> **云订阅 AI 普通成员 H5 详情（2026-08-16；0 API / 0 MCP / 入口关闭）**。
> `/m/cloud/scopes/:scopeId` 复用既有 `getScope` 的 Owner/有效成员/管理员非枚举权限闸，
> 只展示共享订阅元数据和原生 `MCloudScopeAnalysisCard`；不输出账号联系邮箱、预算、
> 成员清单或写操作，也不新增员工菜单。普通 desktop/bare scope 深链进入此页；携带
> 合法 `renew` 授权编号的到期提醒仍进入 `/m/cloud/grants`，保留原续期闭环。

> **AI 大脑资源需求预测内核（2026-08-16；0 API / 0 MCP / 历史发布状态：入口关闭）**。
> `intelligence.demand_forecast` 固定为 `demand-forecast-v1`，输出累计 30/60/90 天
> 入职需求。样本按 exact / department_role / role 三层降级且最少 5 个有效在职对象；
> 少于门槛不推导，role 级只输出 low-confidence review。资源需达到 60% 历史持有率，
> 数量按观测比例向上取整；location 供给按地点隔离，global 供给跨队列统一消费，并扣除
> 预留、加入确认在途；已匿名绑定到本批候选人的预留承诺优先满足本人且不再消费通用
> 库存。容量缺口与按实际到岗日逐笔分配的 Day‑1 风险分开计算，因此
> “月底到货补平容量”不会掩盖月初新人未就绪。输入重复、负库存或非法 key fail closed；
> 取消、异常、离职、已到岗及 90 天外对象被排除。该层无模型调用，后续 Provider 只负责
> 生成匿名输入并把版本化结果写入 Intelligence 快照。真实适配器
> `intelligence.demand_forecast_facts` 现复用 HRIS 主组织、资产办公室权威解析、硬件库存与
> 候选人预留、软件席位、VPN 和云授权账本；地点、部门、岗位、资源及候选人全部转为稳定的
> 包内匿名引用，本地 `reference_map` 不进入模型事实。候选人已经明确分配/预留的资源属于
> 业务承诺，可在历史持有率低于 60% 时保留该候选人的需求，但仍不能绕过最小 5 人样本闸。
> 无限席位和纯配置型访问显式标为 unlimited，不伪造容量缺口或 Day‑1 库存风险。
> 生命周期 Provider 已把该包写入既有 `lifecycle.demand_forecast` 聚合事实，随每日平台/
> 生命周期快照共用不可变历史、事实 Hash 和 release gate，不新增预测表或定时任务。
> Provider-local 引用先声明本地 `reference_map`，快照编译器再按所有领域证据统一重排并
> 递归改写聚合事实，最终校验所有匿名引用都存在于快照映射；同名序号不会错绑到另一领域
> 对象。VPN、云授权及席位变更时间已纳入预测数据截止时间。
> 桌面 `/admin/intelligence` 直接从 overview 的生命周期聚合事实读取该版本化结果，展示
> 30/60/90 天累计新人、总需求、容量缺口、Day‑1 风险、低样本与不完整数据，并按风险和
> 缺口生成只读优先事项。公共 Web 类型覆盖 cohort/resource/supply/confidence 全结构，
> 但界面只展示资源类别，不输出匿名 key；无额外请求、API、MCP 或模型调用。空数据、
> 明暗主题和 540px 窄屏纵向布局已经过组件及真实浏览器回归。
> 飞书 H5 `/m/admin/intelligence` 同样从首屏 overview 读取该事实，不额外请求 domain/
> history 之外的端点。原生触控横向卡保留三档累计口径，优先缺口最多三条并隐藏匿名 key；
> 空结果会区分真正无需求与因数据不完整被排除。375px、明暗主题、开放资源类别回退及零
> 控制台异常已有真实浏览器回归。
> 桌面与 H5 共用 `demandForecastPresentation` 纯选择器，统一三档排序、需求/缺口汇总、
> 优先项排序和开放词汇安全回退；展示条数仍分别为 5/3。预测字段完全缺失表示旧快照尚未
> 生成，只有字段存在且 horizons 为空才表示确定性零需求，避免上线窗口的误导结论。

> **AI 大脑转岗资源双向预测内核（2026-08-16；0 API / 0 MCP / 历史发布状态：入口关闭）**。
> `intelligence.transfer_forecast` 固定为 `transfer-forecast-v1`，输入匿名转岗对象、当前持有
> 和确定性目标策略，累计输出 30/60/90 天 add/recover/retain/review。目标与当前交集不
> 制造假动作；物理硬件和显式 retain 策略只标记保留，绝不建议自动回收；low confidence
> 保留方向但 action 强制 review，目标不完整或 insufficient 时不产生资源 delta。取消、
> 非在职、异常、窗口外、重复人员/持有和非法资源 fail closed。该层不读库、不调用模型，
> `intelligence.transfer_forecast_facts` 批量读取最近/未来 90 天角色变更、有效席位与权限、
> 在用硬件、待复核云授权及当前 Provisioning 策略，不创建按人员查询。软件、访问、硬件和
> 转岗对象在进入聚合事实前都变为包内匿名引用，真实 ID 只进入本地 `reference_map`。
> 已过生效日但仍有资源差异的事项标记 overdue 并继续累计，闭合事项自动退出，超过 90 天
> 的旧事项不进入当前预测；云授权只形成 review，物理硬件继续 retain。
> 生命周期 Provider 将它保存为 `lifecycle.transfer_forecast`，与 demand package 先通过
> `merge_anonymous_fact_packages` 合并到稳定且无碰撞的 Provider-local 引用空间，再交由
> 快照编译器重排为快照全局引用。目标策略匹配复用 `core.provision_match` 的 canonical
> lenient value matcher，因此预测与实际 Provisioning 不各自维护匹配语义。
> Web 的 `TransferForecastFactPackage` 完整映射该结构；跨端共用
> `transferForecastPresentation` 区分 absent/empty/ready，并只按汇总数量排序，不解析或
> 展示匿名对象 key。未知资源、方向和动作统一降级为 other/review。
> 桌面 Intelligence overview 的 `TransferForecastPanel` 直接消费该字段，展示三档累计
> 对象数、add/recover/review、retain、overdue、incomplete 和最多五类优先资源；React key
> 可使用匿名编号，但可见 DOM 不输出人员或资源 ref。读取不新增 API 或模型调用。
> 飞书 H5 `MIntelligenceTransferForecast` 直接消费 overview 同一字段，三档卡使用触控横向
> 滚动，最多显示三类优先资源，并完整保留 retain/review/overdue/incomplete、基准日和排除
> 说明；不新增请求、移动专用接口或模型调用。

> **H5 生命周期/资产面板对等（2026-08-18；0 migration / 0 API / 0 MCP）**。
> 概览页此前只在手机端渲染 demand / transfer 两块，`aggregate_facts.lifecycle.leaver_risk`
> 与 `aggregate_facts.assets.optimization` **已在同一份 overview 里取回却不渲染** —— 同一个人
> 用手机看会少两个维度，且页面不声明少了什么。`MIntelligenceLeaverRisk` /
> `MIntelligenceAssetOptimization` 补齐，口径与桌面同源：离职风险复用共享
> `leaverRiskPresentation`（排序 / absent-vs-empty / 排除计数不可能漂移），资产可回收率用
> 桌面分母（闲置盈余 ÷ 闲置**候选**）。匿名 ref 不上屏；零新增 i18n key（复用
> `intelligence.leaver.*` / `intelligence.assetsOptimization.*`，三语已齐）。

> **离职风险确定性内核（2026-08-16；0 migration / 0 API / 0 MCP / 入口关闭）**。
> `app.intelligence.leaver_risk` 只接受匿名聚合事实，使用 `leaver-risk-v1` 固定、可解释
> 评分计算爆炸半径、阻塞、逾期、特权访问、继任人和数据交接缺口；未知资源类型、负数和
> 不一致计数 fail-closed。模型不能改变分数，也不能授权删除或绕过既有 Leaver Blocked 闸。

> **AI 大脑生产发布状态（2026-08-16）**。`intelligence.enabled` 已在生产启用；
> 全局管理员、管理层及相应领域角色从 `/admin/intelligence` 获得其权限裁剪后的经营视图，
> 并默认以 AI 大脑为登录落点。普通员工仍没有 AI 大脑菜单，只能在自己有有效权限的云订阅
> 详情顶部使用共享「AI 分析」。页面读取、领域下钻、简报和建议读取均不调用模型；只有明确
> 发起的平台/订阅分析才可能产生模型成本。后续标注“入口关闭”的段落记录的是当时分 PR 的
> 发布闸状态，应按此生产状态理解。

> **AI 大脑领域解释合同（2026-08-16；0 API / 0 MCP / 历史发布状态：入口关闭）**。
> 既有 domain HTTP/MCP 响应新增 `metric_catalog`，直接投影后端不可变指标目录的
> key/kind/unit/weight/中英日 label；不复制到前端规则表。桌面只在用户点击领域卡时
> 读取，呈现原始值、得分、可信度、数据时间与匿名 evidence ref；local reference
> map 仍留在服务端。读取限流 Lua 对 TTL<0 的旧 key 自动补 60 秒 TTL；无有效范围
> 的 403 在 Web 映射到统一 Forbidden 页面。

> **AI 大脑桌面总览（2026-08-16；0 API / 0 MCP / 历史发布状态：入口关闭）**。
> `/admin/intelligence` 消费现有 overview + history，只展示确定性综合分、角色可见
> 维度、可信度与真实稀疏趋势；刷新不生成分析。八类业务角色由 `RoleRoute` 预拦，
> 后端 access plan 继续作为权限真源。disabled 404 映射到通用 not-found，菜单待整套
> 验收后发布。读取限流已改为单条 Lua 原子 INCR+EXPIRE，不依赖 Redis 7 的
> EXPIRE NX；授权计划先于额度消费，403 不计数。

> **AI 大脑 Web 读取契约与限流（2026-08-16；0 API / 0 MCP / 历史发布状态：入口关闭）**。
> `web/src/api/intelligence.ts` 是 overview/domain/history 的强类型零模型客户端；
> 不提供生成方法。HTTP 与 MCP 在 release gate 后、RBAC 解析前共同执行
> `apply_intelligence_read_limit`，默认 120 次/用户/分钟，可由平台设置收窄或放宽
> 到 30–600；独立 key 不占用 sensitive endpoint 预算。429 携带 `Retry-After`，
> Redis 故障 fail-open 并告警，因为响应只含已经过权限投影的聚合快照。

> **AI 大脑只读数据面（2026-08-16；6 API / +6 MCP→469 / 历史发布状态：入口关闭）**。
> `intelligence.router` 暴露 overview/domain/history，顺序固定为 release gate → 当前
> 认证用户的 narrowed-role access plan → `read_service`。总览按 subject 批量复用
> 最新快照，组合后重新计算可见健康；版本漂移与缺失来源 fail-visible 为 partial。
> 历史按 dimension 单独成 series。响应不含 audience 或 local reference map，且显式
> `model_calls=0`；paid cost 在独立受众落地前始终 `available=false`。

> **AI 大脑访问投影内核（2026-08-16；0 API / 0 MCP / 历史发布状态：入口关闭）**。
> `intelligence.access.resolve_access_plan` 是角色矩阵、维度来源、区域受众、实付成本、
> 强制生成与建议管理能力的唯一策略；它消费请求已应用 `X-View-Role` 的规范角色。
> `project_snapshot` 按可见维度重算健康与可信度，公共结果不含 `audience_key` 或
> `reference_map`；服务端只保留被可见指标实际引用的本地映射，供后续对象级 RBAC
> 再过滤。区域读取和定时生产共同使用 active office 集合，权限变更不会回读旧受众。

> **AI 大脑每日确定性快照（2026-08-16；迁移 479 / 0 API / 0 MCP /
> 入口关闭）**。`provider_registry.PLATFORM_DIMENSIONS` 显式锁定八维顺序并在
> 运行时校验每个 provider 的稳定维度键；`intelligence.snapshot_daily` 每日
> 07:20 UTC 用同一 UTC 锚构建 `platform/platform/business_shared`。快照服务按
> score version + facts Hash 并发去重，因此无事实变化不写历史；handler 返回
> provider failures 和 `model_calls=0`。它不读取 `intelligence.enabled`：该开关
> 只控制产品入口/生成，隐藏期允许生产积累只读快照用于开放前验收。
> 区域资产快照按 `AssetOfficeAdmin` 的唯一有效办公室集合归并为
> `domain/assets/asset_region:<digest>`；同范围共享、不同范围隔离，当前角色或办公室
> 关系变化后解析到新受众。`global_admin`/`asset_global_admin` 只消费全局快照，不建
> 区域副本；受众摘要不编码 person id。

> **AI 大脑平台运行事实（2026-08-16；迁移 477 / 0 API / 0 MCP /
> 入口关闭）**。`PlatformOperationsFactProvider` 以 enabled job 和最近 7 天
> `scheduled_job_runs` 终态为任务成功率与覆盖可信度；自动 Provisioning 只统计
> auto_api/SSO 且已到执行时间的任务，failed 或超过 24 小时仍 pending/running
> 均为失败；通知按既有 delivered/partial 成功、failed/dead/uncertain 失败语义；
> enabled integration 要求 30 天内 test-connection=ok；Identity DQ 使用最新成功
> 扫描与 live findings。SQL 先聚合，证据查询最多 50 条，不把运维错误详情写入
> 事实包。
>
> 前端错误指标读取最近 7 个完整 UTC 日聚合并按 active person 归一；同日重跑
> 使用固定的昨日结束截止时间。迁移 477 的
> PlatformSetting.created_at 是观测起点，首周 confidence 按 1/7→7/7 升温，禁止
> 将上线前无数据当成零错误。聚合表按产品的长期趋势要求保留，增长上限为每天
> mobile/desktop 两行；评分查询窗口固定 7 天。写入在受控日志落地后、HTTP 响应
> 返回前用独立 session 执行并有 500ms 上限，数据库异常 fail-open。连接器 metric
> confidence 等于已有测试时间的 enabled integration 覆盖率；数据质量问题率封顶
> 100%。迁移 479 以 UTC_TIMESTAMP 重置观测起点，消除 session timezone 歧义。

> **前端错误长期健康遥测（2026-08-16；迁移 476 / 既有写入口）**。
> `POST /api/v1/observability/client-errors` 保留现有受限、认证、结构化日志，并在
> 日志写入后通过有界独立事务向
> `client_error_daily_aggregates` 原子写入 UTC day + surface + count。聚合层不保存
> person、route、component、message、stack、user-agent，也不保存这些值的可逆或
> 可枚举哈希；详细诊断仍只在受控日志系统。数据库聚合失败不会阻断崩溃上报，
> 该计数供 AI 大脑平台运行事实和长期趋势只读消费。

> **AI 大脑安全与合规事实（2026-08-16；0 迁移 / 0 API / 0 MCP /
> 入口关闭）**。`SecurityComplianceFactProvider` 复用 `auditor_mfa_status`、最新
> 非取消 `privileged_role` access certification 及其 items、public exposure、
> secret rotation、PAT audit 和四个每日 ScheduledJob。MFA 仅 enrolled+enforced
> 计为覆盖；复核以快照 item 的非 pending 决定完成率，只有 completed 且
> `snapshot_item_count=0` 的空活动视为可信已完成，构建中的空活动无结论；
> 公开暴露和凭证风险空集只有在采集任务实际运行后才具有可信度。证据新鲜度以
> 四个任务中最旧/终态失败者的日周期计分，running 不视为失败，缺少任务按
> 覆盖度降低 confidence。模型
> 包只含计数、比例、周期和本地匿名引用，不含邮箱、IP、资源名、Token scope、
> 风险详情或原始载荷。
>
> 成本异常内部读以至少 7 个数据日定义可判定资源，并在建立内存时间序列时即
> 过滤其他资源；异常率
> 只除以可判定资源，`evaluable / observed` 单独成为 metric confidence，避免
> 大量新生/稀疏资源把异常率虚假稀释。

> **AI 大脑成本与效率事实（2026-08-15；0 迁移 / 0 API / 0 MCP /
> 入口关闭）**。`CostEfficiencyFactProvider` 的共享 audience 只调用
> `fact_engineering_amount()` / `engineering_row_clause()`：usage 行读取业务
> list（未接双口径的云按既有过渡规则回落），非 usage 行恒为 0。预算覆盖仅认
> `budget_measure='list'`；实付预算和 invoice 金额不进入事实包。异常检测支持
> 一次限定多个启用 integration，并按相同 usage 白名单计算；分摊覆盖按业务成本
> 加权，增长比较两个完整 30 天窗。预计节省只读
> `cmdb_server_efficiency.estimated_monthly_savings_usd`，与实际节省账本保持分离。
> 云网络浪费证据映射到 `server_id`，非运行状态与效率引擎共用
> `NON_RUNNING_SERVER_STATUSES`。

> **AI 大脑云与网络事实（2026-08-15；0 迁移 / 0 API / 0 MCP /
> 入口关闭）**。`CloudNetworkFactProvider` 只读取启用 cloud integration 下的
> active scope、非终态 server、active domain，以及本地/启用连接器证书；同步
> 新鲜度按最旧订阅的日周期计算。资源效率直接消费
> `cmdb_server_efficiency.verdict/recommendation/computed_at`，不再推导利用率或
> 节省。访问覆盖要求 active grant 同时具备未来到期时间和有效在职主体；Owner
> 覆盖要求 scope/server/domain 指向有效在职主体；域名与证书健康要求超过 30 天
> 的明确到期时间。输出仅为计数、比例和最多 50 个本地匿名引用。

> **AI 大脑软件/API 事实（2026-08-15；迁移 475 / 0 API / 0 MCP /
> 入口关闭）**。`SoftwareApiFactProvider` 从 `software_seat.idle_reclaim`
> 最近运行摘要、active allocations/plans、family-dedup seat sync stats、有效
> software owner/IT contact 和 API-key lifecycle 计算六项健康指标。未知活跃
> 数据降低 metric confidence，不等于 idle。开放 external holder 仅作为本地
> 匿名扣分证据。迁移 475 让全空快照 `data_as_of=NULL`，生成入口在拿租约和
> 扣额度前以 `INTELLIGENCE_NO_DATA` 拒绝。资产区域权限与 Intelligence 统一
> 调用 `resolve_asset_office_ids_bulk`，消除两套办公室解析逻辑。
> 复审加固后，有效 `idle_kept_until` 是已观测保留使用而非已知闲置；同一
> `product_key` 的重复 plan 统计选择厂商总数最大的完整代表行，绝不跨行拼接
> matched 分桶。全局资产需求必须仍有 Identity 主体；城市归一化碰撞固定由
> 最小 office UUID 胜出，使权限与评分结果稳定可复现。

> **AI 大脑资产事实（2026-08-15；0 迁移 / 0 API / 0 MCP / 历史发布状态：入口关闭）**。
> `AssetFactProvider` 从 onboarding hardware blocker/reservation、可用电脑库存、
> 最近 active/closed inventory cycle 和资产主账计算需求覆盖、盘点准确、90 天
> 长期闲置、离职持有、保修覆盖、财务完整度。区域实例必须显式传入 managed
> office IDs，并按 `holder_office_id → assignee HRIS city → legacy location` 的
> 既有解析优先级过滤；空授权集合不回退全局。输出仅含计数/比例/区域标记和
> 本地匿名证据。无样本 provider 的 `data_as_of=None` 不参与共同截止时间。

> **AI 大脑人员组织/入转离事实（2026-08-15；0 迁移 / 0 API / 0 MCP /
> 入口关闭）**。`PeopleOrgFactProvider` 读取有效人员、主组织和现有
> `hris.incremental_sync` 运行账；经理覆盖严格沿用 Identity Data Quality 的
> 组织顶点/系统账号排除语义。`LifecycleFactProvider` 复用 Day-1 readiness，
> 并从现有 onboarding/offboarding/provisioning/blocker 账计算 SLA、离职失败、
> 调岗 revoke 和所有权连续性。两者只输出计数、比例、状态和最多 50 个本地
> 匿名证据对象；无样本 confidence=0。快照构建必须使用干净独占 session，
> 只有显式来源不可用可降级，代码错误 fail-closed。

> **AI 大脑快照生产线（2026-08-15；迁移 474 / 0 API / 0 MCP / 历史发布状态：入口关闭）**。
> `FactProvider` 是跨域只读事实协议；provider 不决定评分政策，只提交
> `MetricObservation`，`health-v1` 指标目录锁定 kind/weight/三语标签。编排层
> 统一匿名证据、隐私扫描、八维评分和事实 Hash，单源故障只降低该维度可信度，
> 合同错误/敏感事实 fail-closed。唯一身份
> `(subject,type/audience,score_version,facts_hash)` + savepoint 保证并发同事实只
> 写一条；当前/历史查询始终按精确 audience 隔离。共同数据截止取最旧可用
> source time；每指标保留自己的时间。快照 `created_at` 为 DATETIME(6)，支持
> 同秒多版本稳定排序。此层没有领域写、用户入口或后台调用。

> **AI 大脑统一生成合同（2026-08-15；迁移 473 / 0 API / 0 MCP / 历史发布状态：入口关闭）**。
> `intelligence.generation` 是唯一模型调用包装：从快照构造不含 subject/local
> map 的匿名聚合包，先过隐私闸，再调用现有百炼配置。`IntelligenceOutput`
> 为封闭中英 Schema；每段以 JSON Pointer 引用标量事实，所有叙述数字与匿名
> 对象均做存在性校验，可信度低于 70 时禁止 high 结论且必须说明数据缺口。
> 云订阅同 Hash 七天缓存、30 分钟冷却；平台/领域同 Hash 永久复用，变化后按
> 上海自然日最多一次。数据库租约保证单飞，用户额度独立提交，缓存不扣额度；
> pending/succeeded/failed 与模型/Token 全部长期记录，模型失败不影响评分。

> **AI 大脑确定性内核（2026-08-15；迁移 472；0 API / 0 MCP / 历史发布状态：入口关闭）**。
> 新的 `app/intelligence` 是跨域只读编排层，不成为任何业务域的所有者。
> `IntelligenceSnapshot` 只保存版本化评分、聚合事实、可信度和本地证据映射；
> `IntelligenceAnalysis` 保存严格校验后的中英输出与模型/Token/事实 Hash 审计；
> `IntelligenceRecommendation` 保存去重建议及预计、确认、实际收益的独立账；
> `IntelligenceGenerationLease` 提供数据库级单飞。模型只接收匿名聚合事实，
> 敏感键和值在调用前 fail-closed，未知匿名引用在响应后拒绝。八维固定权重和
> 默认阈值由 `health-v1` 固化，历史快照不回算；缺失维度降低可信度而不伪造
> 零分。`intelligence.enabled=false` 是整套产品的发布总闸。

> **Current Codex CLI + DeepSeek Review contract supersedes the custom tool
> agent** (2026-08-14; 0 migration / 0 RBAC / 0 cron / 0 MCP). Helix uses one
> lenient standard Codex CLI agent invocation with deepseek-v4-flash as its
> custom model provider, directly following the NexusCRM single-review shape.
> Candidate PR material is inert data beside a trusted-base checkout; the agent
> runs read-only and receives neither auth.json, an App key nor a merge token.
> The trusted publisher validates structured output into an exact-head
> check/comment, and the separate App controller may only exact-SHA squash
> merge. The former bespoke DeepSeek function-calling loop, dual pass, quotas,
> recovery loops, preflights, shadows and failure-injection flows are
> superseded. A new post-merge disposable-PR canary is still required before
> calling this version live. See DEEPSEEK_REVIEW.md.

> **Superseded direct DeepSeek adapter contract**
> (2026-08-14; 0 migration / 0 RBAC / 0 cron / 0 MCP). Helix uses one lenient,
> standard `deepseek-v4-flash` review pass copied from the NexusCRM review path.
> The former adversarial pass, research quotas, recovery loops, deterministic
> preflights, shadows and failure-injection workflows are removed and must not
> be used as activation evidence. The trusted base-only workflow publishes one
> exact-head check/comment; a separately credentialed App controller re-reads
> it and may exact-SHA squash merge. The feature flag remains false until one
> disposable internal PR proves that live path. See
> [`DEEPSEEK_REVIEW.md`](DEEPSEEK_REVIEW.md). Older DeepSeek entries below are
> historical and superseded where they conflict with this contract.

> **Tencent CAM 首次登录指引与凭证通道分离** (2026-08-13; 0 migration / 0 cron / 0 RBAC / 0 MCP). A newly-created Tencent CAM user receives one Feishu-only account-onboarding card from `CloudLifecycleService._dispatch_grant`: it gives the live Tencent login page, explicitly tells the user to choose sub-account login, and supplies the root account ID plus CAM username. The temporary password is never copied into Feishu or Helix inbox; it remains a direct work-email-only, masked and non-retryable delivery. The normal per-role “access active” card remains separate, so multi-role approvals do not repeat account-creation instructions. `https://cloud.tencent.com/login` is the canonical entry; the retired `/login/sub-account` path must not be emitted. **Delivery truth comes from `EmailDriver.send()`, never from the bookkeeping that follows it** (2026-08-17): the card's credential line used to be derived after `record_external_email`, so a failed outbox INSERT made an already-delivered mail read as lost and sent the hire to IT for a reset that killed the working password. That record is now a SAVEPOINT-isolated best-effort step downstream of the flag. When no mail leaves at all (empty template, missing email integration, `send` raising), `_dispatch_grant` writes a terminal `ok=False` outbox row carrying the credentials — this row is the ONLY persistence of a Tencent-generated password, so without it IT has nothing to hand-deliver and must reset.

> **Shared Staging is dispatch-only; the nightly deploy was structurally
> impossible** (2026-09-06; 0 migration / **−1 GitHub Actions schedule** / 0 RBAC
> / 0 MCP). `.github/workflows/staging-deploy.yml` lost its `schedule:` trigger.
> A run fails closed unless the isolated Staging Branch is already at the
> release's Alembic head, and `main` lands **3–13 migrations every working day**,
> so the cron was red on every day a migration merged: 24 runs, **4 successes
> (16.7%)**, all four inside the migration-free weekend of 2026-08-21→08-23. It
> then failed **13 consecutive nights** (08-24 → 09-05) while Staging sat at
> revision 527 against a head that walked to 618. Migrating Staging to head buys
> exactly one night. **Staging is now only as fresh as the last dispatch** — that
> is the honest state, and it was already the real state.
> **Half the historical failures were never about Staging at all**: 10 of 20 were
> the `go-ci / Main` ancestor gate correctly refusing a red `main`, and one
> (08-31) was the Staging TiDB Serverless Branch answering `(1105) usage quota
> exhausted`. Reading them as one cause required hand-decoding the log, because
> Cloud Assistant returns `Output` **Base64-encoded and the workflow printed it
> raw** — every Staging failure in this workflow's life reached the Actions log
> as an opaque blob. It is decoded now, the host reserves **exit `43`** for
> "behind head, not authorized to migrate" (alongside `42` for migration-only),
> and each of the three states prints its own titled error plus a step summary
> naming the remediation.
> 🔴 **The runbook's recovery instruction was wrong on one of its two branches.**
> `reattest-dax-retained-on-ecs.sh` copies the source receipt's `database` block
> verbatim, so it **cannot attest a revision the sanitizer never saw**: after a
> migration it compares the source receipt's `alembic_revision` to the live
> schema and exits `8`. "Re-sanitize **or** re-attest" was never true after a
> migration — only a full sanitizer run produces a valid receipt at a new
> revision. The script now says so on that exact refusal.

> **Shared Staging deployment is source-ready but disabled pending bootstrap**
> (2026-08-13; 0 migration / 0 cron / no production RBAC). The isolated
> `.github/workflows/staging-deploy.yml` releases only a `main`-ancestor SHA
> with its exact successful `go-ci / Main` receipt. GitHub OIDC assumes a
> narrow Alibaba deploy role, pushes immutable server/web digests to a dedicated
> ACR namespace, then uses Cloud Assistant against one ECS instance. The host
> uses its own pull-only RAM role, preserves its isolated Redis and
> Caddy-attached Docker network, runs only backend/frontend application services,
> and writes a source+digest receipt after local health checks; a public readiness
> probe is the terminal workflow check. The scheduled path never migrates and
> never starts worker. A source-bound Staging data-sanitization receipt is also
> mandatory: the current TiDB Branch inherited Production contents and cannot be
> treated as an authenticated test environment until the separately designed
> reset/pseudonymisation/effect-denial readback is complete. No cloud resource,
> GitHub Environment, OIDC trust, ACR namespace, runtime role or host config has
> been activated by this source change;
> [the Staging runbook](../deployment/staging.md) is the activation gate and keeps
> it separate from the AWS production path.

> **The roster and the writes do not have to come through the same door**
> (2026-08-15; 0 migration / 0 cron / 0 RBAC / 0 MCP). Closes the "expect
> nightly failure until a new driver exists" consequence logged in the
> 2026-08-14 entry below. AWS IdC's SCIM endpoint cannot enumerate past 100
> users, so `vendor_snapshot_refresh` correctly refused to store a truncated
> roster and failed every night. The Identity Store API
> (`identitystore:ListUsers` / `ListGroups`) is the **same directory behind a
> different door** and has real `NextToken` paging, so the fix is a read-side
> transport swap, not a new kind or a new runner:
> [`runners/aws_identity_store.py`](../../server/app/domains/identity_sync/runners/aws_identity_store.py)
> pulls the roster with boto3 and normalizes every record back into **SCIM
> shape**, and `_collect_scim_records` branches to it on
> `runner_options.roster_source == "aws_identity_store"`. Writes
> (deactivate/delete), `_build_runner`, `RUNNER_REGISTRY`, drift and the kind
> UI are all untouched. **Why SCIM shape is non-negotiable**: the snapshot's
> `external_key` is fed straight back to `ScimRunner.deactivate(prior_external_id=…)`
> when a leaver is revoked, and `_compare_fields` renders the drift board off
> flat SCIM keys — Identity Store's `UserName`/`Emails[].Value` spellings would
> silently turn every drift field `not_comparable`. Identity Store `UserId` **is**
> the SCIM `/Users/{id}` id (measured against prod 2026-08-15: 100/100 of the
> stored SCIM keys present in the Identity Store roster, zero drift) and that
> equivalence is pinned by a test rather than left as folklore. Credentials come
> from a separate `aws_cloud` integration named by
> `runner_options.aws_integration_slug` (the target's single `integration_id` is
> already spoken for by the SCIM token); store id + region default to that
> integration's `sso_identity_store_id` / `sso_region`. Same fail-closed posture
> as the SCIM path throughout — repeated `NextToken`, page-budget exhaustion and
> a denied `ListUsers` all raise, because a roster that silently loses people
> makes every missing leaver read as "already revoked"; a denied `ListGroups`
> degrades to a warning, matching the SCIM collector. **Reach is bounded by AWS
> IAM, not by Helix**: the credential must hold `identitystore:ListUsers` on that
> specific Identity Center instance, so each IdC tenant needs a credential from
> *its own* account. Regressions:
> [`test_aws_identity_store_roster_2026_08_15.py`](../../server/tests/test_identity_sync/test_aws_identity_store_roster_2026_08_15.py),
> [`test_aws_identity_store_wiring_2026_08_15.py`](../../server/tests/test_identity_sync/test_aws_identity_store_wiring_2026_08_15.py).

> 🔴 **换写入面 = 换权限族,而权限只在真正写的那一刻才被检验(2026-08-31;迁移 **580** = 播种 1 个 cron / **+1 cron** `identity_sync.feishu_write_scope_preflight`(每天 07:30 Asia/Shanghai)/ 0 端点 / 0 MCP / 0 RBAC)。** #3084(08-19)把建号从 `POST /contact/v3/users`(scope `contact:user:write`,早有)换成 `POST /directory/v1/employees`(scope `directory:employee:write`,**从没开通**)。**没有任何东西会在需要写之前验一次权限**,而接下来 12 天恰好没有新人需要建号 —— 建号面死了 12 天、每天的同步全绿(更新走另一个 scope,一直好使),最后被一个入职当天没有飞书账号的真人踩出来(P10025)。三件事因此固定下来:
>
> **① `99991672` 在写入面是 `blocked` 不是 `failed`,但不 halt sweep。** 重试治不好缺失的权限;`failed` 在本仓语义里是「暂时性」,它每小时染红一次 sweep 共 9 次并把读者引向错误方向。`blocked` = 「人必须去改配置」:run 保持绿、无 churn、`maybe_notify_blocked_backlog` 一次性 page IT,而入职协调器对 `failed`/`blocked` **一视同仁**发卡片(事故当天确实发了,3 人 feishu+email 全 delivered —— 告警链路本身没坏)。**不 halt** 的理由是**飞书按 API 面授予 scope**:那 12 天建号面死了而更新面对全体 ~500 人正常,halt 会把活着的一半也停掉。所以 `99991672` 留在 `_FEISHU_SYSTEMIC_NUMERIC_CODES` 给**查询面**用,写入面先用 `is_feishu_permission_failure` 判。顺带修掉一处**写错的注释**:该码被标成 "tenant-wide frequency limit"(那是 99991671),这个误标正是「权限故障被读成限流」的源头。
>
> **② 探针放在 cron,不放在建号路径上。** 一度放进 `_create_user` 又撤掉了:放在写入路径只省下「一次注定失败的 POST」,却给每次 sweep 加一次调用、给每个建号测试加一次线序(实测打红 16 条既有用例);而真正缺的能力是**「没有人入职时也要能发现」**,那只能是与入职无关的定时任务。探针 = 对**故意非法的 `employee_id`** 发 PATCH(改不动数据),判据来自实测:该端点**先查权限再校验 id** —— `99991672` = 没权限,`99992360` = 已过闸。缺权限时 `raise`,复用既有定时任务失败通知。
>
> **③ `geo_name` 只在建号那一刻可写。** Directory 的 PATCH 直接忽略它(`2221250 No fields to be updated`,测试账号实测),所以改驻留地映射**不会移动任何已建账号**。存量只能走 **`POST /open-apis/security_and_compliance/v1/user_migrations`**(scope `security_and_compliance:user_migration`;查询 `GET .../user_migrations/{user_id}` 返回 `status` 0进行中/1已完成/2已取消 + `progress`)—— **注意它在 `security_and_compliance` 域下,不在 `directory` 域下**,排查时只翻 Directory 的接口树会得出「飞书没有这个 API」的错误结论。生产实测(409 个有飞书号的在职员工逐个核 geo):**56 人错位**(54 个非中国居住地落 `cn`、2 个中国居住地落 `jp`),8/19 触发的 7 个迁移任务**卡了 12 天 progress=0**,另 47 人从没触发过 —— 待飞书侧排查(疑似坐席上限/需排期)。港澳台归 `cn` 见 DECISIONS 2026-08-31。

> **A sweep that touched the vendor zero times successfully is a DOWN target, and a full page is never proof of a complete roster** (2026-08-14; 0 migration / 0 cron / 0 RBAC / 0 MCP). Both halves came out of one incident: the `aws-ai` SCIM token (AWS IAM Identity Center) was revoked, so every vendor call 401'd. **(1) Run classification.** `_sweep`'s status decision ([`service.py`](../../server/app/domains/identity_sync/service.py)) counted `skipped` as evidence the target was alive — but the dominant skip is the payload-hash short-circuit, which issues **no HTTP request at all** (`blocked` re-affirmations and permanent domain-rejects are hash-persisted the same way). So a total credential outage read `created=0 updated=0 deactivated=0 deleted=0, failed=127, skipped=282` → `partial`, and `run_due_targets` only promotes **`failed`** runs into `ran_failed`, so `identity_sync.dispatch` reported an ok tick and **nobody was paged** — the exact "a revoked vendor credential could silently kill the nightly leaver-lockout backstop for weeks" scenario that gate exists to prevent. Proof of life is a vendor call that GOT THROUGH — `created + updated + deactivated + deleted + verified_skipped`; failures with none of those = `failed` → dispatcher raises → failure notifier (whose signature dedup bounds a standing failure to one page). Note the per-target `alert_recipient_ids` path is NOT a substitute — every prod target has it empty and there is deliberately no admin fallback (DECISIONS 2026-06-19), so the dispatcher's raise is the only thing that pages. **`verified_skipped` and the `partial` promotion are the 2026-09-07 correction — see the next entry; the original write-only reading, and the `failed`-only dispatcher gate quoted above, were both too narrow.** **(2) Roster paging.** `ScimRunner._list_resources` trusted `totalResults`; **AWS IdC supports no pagination whatsoever** — verified against both prod tenants 2026-08-14: it caps at 100 resources, echoes `startIndex: 1` whatever you ask, reports `totalResults == itemsPerPage == 100`, re-serves the same first 100 users for every `startIndex`, and offers no `nextToken`/`Link`/non-`eq` filter. That made the completeness check fire on page 1, so `aws-ai` / `aws-sso` snapshots had silently held exactly **100 of 464** users since day one (leaver-assurance Signal B covering 22% of the roster while presenting itself complete). #1786's `fresh == 0` guard could never fire — the total-break came first, and it only raised when `len(out) < total`, which under this server is never. Now a **short page** is the only unambiguous end-of-collection (a full page always probes one more), and a non-empty page carrying nothing new raises. **Consequence to expect in prod: `vendor_snapshot_refresh` fails nightly for both AWS targets until they get an enumerable roster source (AWS Identity Store API `ListUsers` w/ NextToken — a new driver, not a SCIM fix).** A failed collect happens before the wipe-and-replace, so the stale snapshot is preserved, not destroyed. Regressions: [`test_credential_outage_run_status_2026_08_14.py`](../../server/tests/test_identity_sync/test_credential_outage_run_status_2026_08_14.py), [`test_scim_roster_total_equals_page_2026_08_14.py`](../../server/tests/test_identity_sync/test_scim_roster_total_equals_page_2026_08_14.py). **Follow-on (2026-08-14)**: `leaver_assurance`'s Signal-A finding now quotes the state row's `last_error` — the raise string is the alert body, and without the reason a target-wide `401` outage and a single rejected person page identically. Rotation/recovery runbook: [`docs/ops/identity-sync-scim-token-rotation.md`](../ops/identity-sync-scim-token-rotation.md).

> **The shared Redis client owns its own resilience** (2026-08-13; 0 migration / 0 cron / 0 RBAC / 0 MCP). [`core/redis_client.py`](../../server/app/core/redis_client.py) built the pool with a bare `from_url(REDIS_URL, decode_responses=True)`. That path goes `Redis.from_url` → `ConnectionPool.from_url` → `Connection(**kwargs)` and therefore **skips the defaults `Redis.__init__` would have applied**: the connection ends up with `Retry(NoBackoff(), 0)` — zero retries — no socket deadlines, no TCP keepalive and no pre-checkout liveness probe. Scheduled jobs are the worst case for that combination: a worker's pooled connection sits idle for the whole gap between ticks, long enough for the network path to reap it, and the next tick checks out a dead socket. `_build_client` now sets `socket_connect_timeout` / `socket_timeout` / `socket_keepalive` / `health_check_interval` / a jittered-exponential `retry` over `ConnectionError|TimeoutError` / `max_connections`, all from `REDIS_*` settings; querystring options on `REDIS_URL` still win (redis-py precedence) so prod can retune via SSM without a deploy. `/ready` probes through this shared client instead of building a throwaway one per cache miss. **The retry is what recovers** — measured against a real Redis with the connection killed server-side, retry alone survives it and `health_check_interval` alone does not (the PING just fails in the command's place); the health check earns its keep by moving the failure onto a throwaway PING before the caller's command is written to a half-dead socket, which is what makes the retry safe for any command rather than only provably-idempotent ones. **Two traps worth carrying forward:** (1) `from_url` is not a shorthand for the constructor — whatever a client's defaults are, verify they survive the factory you actually call; (2) `redis.retry.Retry` and `redis.asyncio.retry.Retry` have identical constructors, so handing the sync one to an async client is accepted silently and then retries *nothing* — its `call_with_retry` returns the un-awaited coroutine of the first attempt, so its own try/except never sees the failure. Every structural assertion (retries, backoff, supported errors) still reads correctly; only a behavioural test against a live Redis catches it. Regressions: [`test_redis_client_resilience_2026_08_13.py`](../../server/tests/test_core/test_redis_client_resilience_2026_08_13.py).
> **A cost-allocation row is not a cloud account** (2026-08-12; migration **466** / 0 cron / 0 RBAC / 0 MCP). New scope type `legacy_billing_unit`, deliberately **absent from `TENANT_LEVEL_SCOPE_TYPES`** — that single fact removes it from the requestable picker, every per-scope driver fan-out (grants / roles / billing_daily / cost_facts) and the grant gate, without a special case in each caller. Origin: on 2026-08-05 04:11:25 UTC a one-off script wrote three `(legacy billing)` integrations (KSCloud / UCloud / AWS China, all `enabled=0`, `capabilities=[]`) plus **37 scope rows in that same second**, typed `standalone_iam`, with no audit row, no commit and no doc. 云订阅 has been listing them as live subscriptions ever since — but 金山云 is project-based, not multi-account, and the names are PingCAP cost centres (`C0801-RDG-TiDB_Cloud`, `默认项目`, `Innovation-Project`). 30 carry an owner, so they were also candidates for owner-confirmation cards; `list_scopes_due_semiannual` now filters on tenant-level types. Migration 466 retypes existing rows behind **three guards** because it touches the access-control plane: integration name matches `%(legacy billing)%`, that integration is `enabled = 0`, and the scope has **zero** access grants — anything failing a guard is left alone and named in the migration log. Rows are kept, not deleted: the historical bills are still meant to be imported against them. Regressions: [`test_legacy_billing_scopes.py`](../../server/tests/test_cmdb/test_legacy_billing_scopes.py). **`TENANT_LEVEL_SCOPE_TYPES` is the filter, and every surface must actually apply it** (2026-09-07): the admin list `GET /cmdb/scopes` hid these rows from day one, but the EMPLOYEE-facing directory `_requestable_directory_rows()` behind `GET /cmdb/my-subscriptions?include_requestable=true` filtered only on `!= standalone_iam` — so 我的云订阅 showed every employee 28 `legacy_billing_unit` rows + 2 `azure_resource_group` rows that the admin console does not show, and (because they are `environment='dev'`) offered a 快速申请 button that `_refuse_ungrantable_scope_type` rejects AFTER the tap. Surfaced as "same Owner filter → admin 7, employee 12". The directory now filters `scope_type IN TENANT_LEVEL_SCOPE_TYPES - {standalone_iam}` (`standalone_iam` stays excluded for a page-level reason — Tencent / UCloud / AWS-SG live on the IAM-users page — not an ungrantability one). **Rule: when an admin list hides a scope_type, check the employee mirror of that list in the same breath** — the two queries are written in different functions and drift silently, and the employee side is the one with the button. **The rule is now enforced by construction, not by remembering it**: `cmdb/models.py` exports `owner_governed_scope_criteria()` (deleted_at NULL + status != closed + decommissioned_at NULL + `TENANT_LEVEL_SCOPE_TYPES`) and `employee_directory_scope_criteria()` (active + `TENANT_LEVEL_SCOPE_TYPES - {standalone_iam}`), and **every** surface that answers "which scopes is this person responsible for / may this person request" calls one of them: the offboarding blocker scanner AND its preview ([offboarding/service.py](../../server/app/domains/offboarding/service.py)), mover / role-change holdings ([mover_holdings.py](../../server/app/hr_lifecycle/mover_holdings.py)), the manager team-cloud list AND its count badge ([me_universe/team_service.py](../../server/app/domains/me_universe/team_service.py)), the leaver-residue reconciler ([reconciliation/service.py](../../server/app/domains/reconciliation/service.py)), the ownership-integrity orphan lens (which composes its own GCP-billing / Aliyun-RD-master exclusions on top), and the employee requestable directory. Before the 2026-09-07 audit those seven were seven hand-rolled WHERE clauses in five modules giving three different answers about the same person — the offboarding scanner had written `cloud_scope_owner` blockers for ungrantable scope rows. **Correction (2026-09-07, post-deploy check against prod):** an earlier revision of this line called those two blockers *unresolvable* and said a blocker *holds up* the leaver's offboarding — neither is true, and it was inferred from the scanner's behaviour without reading the blocker rows. All 16 `cloud_scope_owner` blockers in prod are resolved (11 `transferred`, 5 `revoked_manual`), there are **zero** unresolved blockers of any type, every affected process is `completed`, and both named rows were auto-resolved by the T+0 `force_transfer` path. One of them was even correct when created — the scope was still `standalone_iam` on 2026-08-05 and migration 466 retyped it a week later. The genuine cost is meaningless ownership churn (force-transfer handing a successor the "owner" of a cost-allocation row), plus the 22 closed-but-owned scopes that would repeat it for future leavers. Contract test [`test_scope_visibility_contract.py`](../../server/tests/test_cmdb/test_scope_visibility_contract.py) drives all seven from one fixture so they go red together; the admin↔employee assertion is deliberately **containment** (`employee ⊆ admin`), not equality, because the ops catalogue is legitimately wider (`azure_resource_group`, `standalone_iam`, `include_legacy_billing`) — what is pinned is the reported direction, an employee being shown a row their own admins cannot see. **If you add a seventh surface, call the helper; do not write `deleted_at.is_(None)` next to `owner_person_id` again.**
> **Region coverage is asked of the provider, never hardcoded** (2026-08-12; 0 migration / 0 cron / 0 RBAC / 0 MCP). `TencentProvider._regions` now calls `DescribeRegions`, with an explicitly configured list still honoured as a *narrowing* and the old static list kept as a fallback. A baked-in region list fails in the one direction that matters: an instance in a region nobody thought to add is invisible, and invisible is indistinguishable from "no servers there". Prod Tencent was pinned to 3 of the 19 regions the account can use. Enumerating all 19 by hand on 2026-08-12 found genuinely zero CVM instances — the point being that this zero is now *confirmed and stays confirmed*, rather than assumed. Two degradation rules: a `DescribeRegions` failure falls back to the static list (a transient API error must not become "sync nothing", since an empty region list silently reports an empty fleet), and discovery is cached per provider instance so it costs one call per sync, not one per capability. Prod config `regions` was cleared so discovery takes over. Regressions: [`test_tencent_region_discovery.py`](../../server/tests/test_integrations/test_tencent_region_discovery.py).


> **A compliance list that can never reach zero stops being read** (2026-08-12; migration **465** / 0 cron / 0 RBAC / +1 MCP). New `cmdb_iam_users.account_kind` (`human` / `service` / NULL) + note + actor + timestamp, set through `POST /cmdb/iam-users/{id}/classify` (and MCP `classify_cloud_iam_user`). Every compliance surface treats an IAM user with no `owner_person_id` as an orphan, and two different problems land in that bucket: a colleague Helix failed to match (act on it) and a robot that will never match (stop asking) — prod Tencent has both. Recording the difference is what lets `has_owner=false` reach zero; the leaver-residue sweep skips `service` rows for the same reason (a service account outlives its creator by design). **Three deliberate constraints:** (1) classification is a *human statement* — `ConsoleLogin=0`, the authoritative robot signal, is surfaced in the UI as a hint and never written on the admin's behalf, because a guess rendered as a decision is how this data goes wrong; (2) `human` is NOT "assign an owner" — it means "a person we have not matched yet", so the row stays in the queue as work rather than being filed away; (3) marking `service` changes nothing on the cloud — the account keeps syncing and its policies are still listed, it only leaves the two governance queues. `console_login` is lifted out of `raw_data` into the IAM-user response so the UI reads a typed field rather than a provider blob. Regressions: [`test_iam_account_classification.py`](../../server/tests/test_cmdb/test_iam_account_classification.py).


> **A security field a provider cannot read must say `unknown`, never `disabled`** (2026-08-12; migration **464** / 0 cron / 0 RBAC / 0 MCP). New tri-state `cmdb_iam_users.mfa_state` (`enabled` / `disabled` / `unknown`) alongside the existing NOT NULL `mfa_enabled` bool. The bool gave a provider that could not read MFA exactly one way to say so — `False` — which the compliance view renders identically to "this user has no MFA". The Tencent provider hard-coded it for all 25 CAM users on a comment claiming per-user MFA is only reachable through unstable helpers; `DescribeSafeAuthFlagColl(SubUin=…)` returns it directly (prod 2026-08-12: 冯帅/李琳 login+action, 左武辉 login only, `tiup-server` none). Same class of defect in the same method: `last_login_at` was read from `ListUsers.LastLoginTime`, a field that response does not carry, so every Tencent row was NULL and the inactivity sweep could not see the tenant — the value is `GetUser.RecentlyLoginTime`. **Rules that generalize:** (1) migration backfills `unknown`, NOT a mapping from the bool being distrusted; (2) a per-user probe failure degrades to `unknown` and never to a security assertion; (3) MFA coverage is computed over KNOWN rows only, with the unknown count shown beside it — a remediation metric must not be manufactured from silence; (4) "any second factor" counts (hardware key, passkey, phone), not just the soft token this tenant happens to use. `ConsoleLogin=0` is now persisted in `raw_data` as the authoritative service-account signal. Providers that genuinely probe (AWS `list_mfa_devices`) report real values; AliCloud Intl, which does not probe, now reports `unknown` instead of `disabled`. Regressions: [`test_tencent_iam_mfa_lastlogin.py`](../../server/tests/test_integrations/test_tencent_iam_mfa_lastlogin.py).
> **A catalog that stops being refreshed must say so where it is consumed** (2026-08-12; 0 migration / 0 cron / 0 RBAC / 0 MCP). New [`cloud_catalog_health`](../../server/app/domains/cmdb/sweep_settings.py) returns the role catalog's own `last_synced_at` + staleness alongside the state of that integration's `cmdb.roles_sync` cron, and rides on `GET /cmdb/role-catalog/by-integration`; the catalog page banners on a **disabled** cron (red) or a catalog >3 days stale (amber — at least two missed nightly runs). **Why it needed a surface at all:** `cmdb.roles.tencent-primary` failed once on 2026-05-19, was left disabled, and the Tencent catalog sat frozen for ~3 months; a manual run then upserted 1031 roles and retired 15, i.e. nothing was broken and nothing had said so. The failure is quiet in the worst direction — the employee's 「申请权限」 picker reads this catalog, so a role that exists on the cloud is simply missing from the dropdown and neither side learns why. Three states are kept distinct on purpose because they are different operational problems: `cron_enabled=false` (a job exists and is off — the case that went unnoticed), `null` (no roles cron for this integration at all), and `true` but stale (it runs and produces nothing). An empty catalog reports `last_synced_at=null` rather than "synced today". Regressions: [`test_role_catalog_health.py`](../../server/tests/test_cmdb/test_role_catalog_health.py).


> **`revoke_account(account_id=…)` resolves the provider handle LITERALLY first** (2026-08-12; 0 migration / 0 cron / 0 RBAC / 0 MCP). Shared [`account_id_candidates`](../../server/app/integrations/drivers/_cloud_shared.py), used by the Tencent, Aliyun and UCloud drivers. `CloudAccountRevokeContext.account_id` is documented as the provider's user handle with an email accepted as a *convenience*; all three implemented that convenience as "contains `@` → take the local part", unconditionally. When the handle **is** an address — the norm on the prod Tencent tenant, 17 of 22 live CAM users — the driver looked up a user that does not exist, read `UserNotExist` as "already gone", and returned `ok=True`. The offboarding kill-switch was therefore a no-op for 77% of that tenant **while reporting success every time**; caught 2026-08-12 deleting 唐彭's residue, whose sub-account kept `AdministratorAccess` through a "successful" revoke. The rule now: try the literal handle, fall back to the derived local part, report already-absent only when neither exists. A probe error (auth failure, throttle) returns `ok=False` rather than being read as absence — a broken credential must not produce a clean offboarding record. AWS is unaffected (it always used `account_id` verbatim). **Test-fixture lesson worth carrying**: the existing `test_revoke_account_delete_chain` answered `ListPoliciesForUser` with 200 for ANY user name, so it passed whether the driver resolved correctly or not, and its assertion pinned the buggy behaviour — a stub that always says yes cannot catch a resolution bug. Regressions: [`test_revoke_account_name_resolution.py`](../../server/tests/test_integrations/test_revoke_account_name_resolution.py).


> **A leaver's cloud access is verified against the PROVIDER MIRROR, never against Helix's own grant rows** (2026-08-12; migration **463** / +1 cron / 0 RBAC / +2 MCP). New module [`domains/cmdb/leaver_iam_residue.py`](../../server/app/domains/cmdb/leaver_iam_residue.py) + table `cmdb_leaver_iam_residue` + cron `cmdb.leaver_iam_residue_sweep` (02:10 Asia/Shanghai, after `cmdb.leaver_grant_sweep` at 01:30). **Why this cannot be expressed in terms of grants:** offboarding sets the grant row to `revoked` and asks the driver to remove the cloud-side assignment; when that driver call reports success and does nothing, *every* existing sweep is blind to it, because they all filter on `active` / `pending` and a `revoked` row is never revisited. Prod proof: 褚杰 `chujie` left 2026-06-01, all four Tencent CAM grants were revoked in Helix that night, the pre-2026-06-02 driver's per-policy `DetachUserPolicy` was a no-op against **group-inherited** policies, and 2.5 months later the sub-user still carried all four — found by a human reading the console. `cmdb_iam_users` is the right evidence base precisely because it is a dumb 4-hourly mirror of the provider with no opinion about what Helix thinks. Invariants worth carrying to any similar check: (1) **detection and action are separate** — the sweep performs zero cloud writes, so it is not gated on the prod write lock, while the UI's cleanup action reuses the existing `POST /cmdb/iam-users/{id}/cleanup` chain (one delete path, one audit action, one lock, `global_admin` only); (2) **a stale mirror is reported, never acted on** — rows whose IAM sync has not succeeded in >13 h are surfaced (a dead sync is itself a finding) but flagged `mirror_stale` and their delete button is disabled; (3) **group membership is residue on its own** — it is *why* the original revoke failed silently; (4) the queue is a **table, not a live query**, because `first_detected_at` is the age clock the UI reads, the alert must fire only for newly-opened rows (nightly re-nagging is what gets a governance channel muted), and `dismissed` needs an actor + reason that survive; (5) matching uses the owner FK **and** work_email, because the owner-resolver only binds *active* people — a row whose owner left before it was ever bound keeps `owner_person_id` NULL forever, which is exactly the population that must not be missed. Blast-radius cap `MAX_RESIDUE_ROWS_PER_RUN=200` raises rather than alerting on an untriageable list. UI lives as the 离职残留 tab of the IAM users page (flat menu + in-page tabs); `/admin/cloud/leaver-residue` redirects there for the alert deep link. Regressions: [`test_leaver_iam_residue.py`](../../server/tests/test_cmdb/test_leaver_iam_residue.py).


> **An IAM-mode cloud principal without an email is a resolution problem, not a snapshot problem — and a members list is only as true as the cron behind it** (2026-08-12; 0 migration / 0 cron / 0 RBAC / 0 MCP). Three invariants for cloud grants discovery. **(1)** A driver's `list_scope_grants` must raise only when a user is **missing or omitted**, never when a present user is merely unattributable. Tencent CAM's `Email` attribute is optional and the prod tenant barely uses it, so [`tencent_cloud.py`](../../server/app/integrations/drivers/tencent_cloud.py) followed the `DiscoveredGrant` contract's documented order — `Email` → `@`-shaped user name → **bare user name** — instead of the RuntimeError that made the service account `tiup-server` fail `cmdb.grants.tencent-primary` on **all 18 runs from 2026-08-03 to 08-12**, freezing the whole tenant's members list at its 08-01 state. The partial-snapshot guard protects the drift sweep from reading "user absent" as "access revoked"; a bare handle is present, so emitting it is what keeps the guard honest. **(2)** [`_apply_discovered_grants`](../../server/app/domains/cmdb/lifecycle_service.py) now resolves `@`-less principals against the `work_email` **local-part** — the same rule and the same "exactly one **active** person" guard `_resolve_iam_owner_person_id` (tier 2) and `_derive_grants_from_iam_users` already use, so all three paths agree. Leavers are deliberately excluded: an unresolved principal gets **named** in the sync summary where an admin can act, whereas binding it files live cloud access behind a revoked row no sweep revisits. **(3)** `GET /cmdb/scopes/{id}/last-sync-summary` reads the integration's `cmdb.grants_sync` **scheduled job**, not just `cloud.scope.sync_now` audit rows, and always returns the job's health. Manual sync-now is rare — the prod Tencent scope had **zero** such audit rows ever — so the unresolved-principals banner (the page's whole answer to "the cloud console shows 25 users and Helix shows 7") had never rendered on it, and ten days of daily failures rendered as an ordinary table. The members tab now shows a red staleness banner when the job is `failed` **or** the newest summary is >72 h old (which also catches the lock-starvation shape where a skipped run is recorded `ok`). Regressions: [`test_grants_tencent_bare_handle_resolution.py`](../../server/tests/test_cmdb/test_grants_tencent_bare_handle_resolution.py), [`test_scope_last_sync_summary_cron_source.py`](../../server/tests/test_cmdb/test_scope_last_sync_summary_cron_source.py), `test_tencent_driver_cam.py::test_list_scope_grants_emits_bare_handle_for_user_without_email`.

> **`leaver_pending` means "scheduled to leave", NOT "gone" — one shared predicate now enforces it** (2026-08-11; migration **461** / 0 cron / 0 RBAC / 0 MCP). New module [`app/identity/leaver_notice.py`](../../server/app/identity/leaver_notice.py) holds the single definition: `leaver_last_working_day_passed(person)` (first moment of `termination_date + 1` in the person's own TZ, mirroring offboarding's `_leaver_revoke_not_before`) and `is_within_leaver_notice_period(person)`. identity_sync's `_leaver_last_working_day_passed` (#1735, the 2026-05-28 Thalie Zhuang incident) now delegates to it, and `build_person_by_email_index(..., leaver_notice_grace=True)` — the default — applies it so a caller that excludes `leaver_pending` still keeps a leaver who is inside their notice period. **Why it had to be shared:** all six seat reconcilers (slack / zoom / nexuscrm / google / jira / feishu) excluded `leaver_pending` outright, so the night IT confirmed a future termination they drift-revoked every `software_seat_allocations` row of a still-working employee; when the person re-matched days later the reconcilers **inserted brand-new allocation rows** (they never reactivate a revoked one), and those new ids defeated offboarding's per-`target_id` revoke dedup → duplicate rows in `/admin/software/cancellations`. A `leaver_pending` row with a NULL `termination_date` also stays in scope (can't prove the day passed → never act early). Filtering is Python-side by necessity: the predicate needs `resolve_timezone(person)`, which SQL can't evaluate. Two matching invariants landed with it: the HR sync's `onboard` branch refuses to revert an in-flight scheduled leaver to `active` (`SuccessFactorsLifecycleService._is_inflight_scheduled_leaver`, the onboard-branch twin of the #2215 offboard guard — SF holds `status='t'` through the notice period, so every tick re-staged a `reactivate`), and offboarding's stale-blocker sweep cancels the revoke task it orphans. `find_in_flight_seat_revoke_task` gained a (user, **plan**) fallback that reuses an in-flight task for a replacement seat row and **re-points its `target_id`** at the live row — plan-scoped, never software-scoped, because one Zoom user legitimately holds base + Large Meeting + Webinars seats under one `software_id`. Regressions: [`test_email_lookup_leaver_notice_grace_2026_08_11.py`](../../server/tests/test_identity/test_email_lookup_leaver_notice_grace_2026_08_11.py), [`test_scheduled_leaver_not_reverted_2026_08_11.py`](../../server/tests/test_hr/test_scheduled_leaver_not_reverted_2026_08_11.py), [`test_stale_blocker_task_and_seat_replacement_dedup_2026_08_11.py`](../../server/tests/test_offboarding/test_stale_blocker_task_and_seat_replacement_dedup_2026_08_11.py).
> **Superseded historical DeepSeek review lane (2026-08-18).** The former
> review and review-driven merge workflows were removed. The remaining review
> paragraphs below are historical design evidence only; their referenced
> workflow paths must not be restored as an activation mechanism.

> **DeepSeek agent resilience recalibration is active, not an enabled gate** (2026-08-14; 0 migration / 0 RBAC / 0 cron / 0 MCP). The current baseline six-case agent receipt [31757636406](https://github.com/pingcap-inc/Helix/actions/runs/31757636406) is failed calibration evidence: three fixtures reached `INVALID` because a valid bounded line-range correction was rejected, a single provider transport error had no bounded retry, or a hash-bound exact PR patch did not satisfy candidate inspection for a patch-only body. The current protocol fixes only those mechanics: it accepts at most three source-free reader schema/401-line-range corrections, retries the identical in-memory request at most twice for transport/timeout/408/429/5xx, and treats `read_pull_request_diff` as the candidate evidence it already is. It does not execute candidate code, retry invalid/unsafe requests, accept other 4xx/invalid JSON/model mismatch, or give the runner any controller/merge privilege. The new prompt/policy requires fresh real-model shadow and failure-injection acceptance before `HELIX_DEEPSEEK_REVIEW_ENABLED` can change.

> **DeepSeek failure injection now has a manual, credential-free acceptance lane** (2026-08-14; 0 migration / 0 RBAC / 0 cron / 0 MCP). [`deepseek-review-failure-injection.yml`](../../.github/workflows/deepseek-review-failure-injection.yml) runs only by manual dispatch on trusted `main`, on a GitHub-hosted runner with `contents: read` and no provider/App/merge/check/comment credential. It executes the deterministic injection contracts for stale/closed head, draft, fork, BLOCK/failed run, base drift/behind guarded refresh, publisher/policy mismatch, duplicate delivery, merge `409`, malformed/unsafe agent tool and candidate-manifest drift. It does not replace a real `deepseek-v4-flash` shadow receipt. The current merge design intentionally has no pre-merge CI condition: essential-only `go-ci` is post-squash evidence, so its failure remains a release concern rather than a synthetic admission injection.

> **DeepSeek review reader portability is a bounded contract, not a runner package assumption** (2026-08-14; 0 migration / 0 RBAC / 0 cron / 0 MCP). The first failure-injection receipt [31761185359](https://github.com/pingcap-inc/Helix/actions/runs/31761185359) failed before any provider request because the GitHub-hosted runner lacked `rg`, making 12 contracts `INVALID`. `search_review_text` now scans fixed strings in-process after the same path validation: at most 400 non-secret, non-symlink files, no binary or above-1-MB body, and at most 80 matching lines. It adds no tool, shell, execution, network, credential or merge capability. A fresh failure-injection receipt and a real-model shadow remain prerequisites; the gate stays disabled.

> **DeepSeek thinking transcript recovery preserves protocol, not source or authority** (2026-08-14; 0 migration / 0 RBAC / 0 cron / 0 MCP). The failed six-case receipt [31760003389](https://github.com/pingcap-inc/Helix/actions/runs/31760003389) showed #2666/#2614 receive provider rejection after one prose/no-tool continuation and #2582 reject a five-call batch after 21 grounded calls. The agent now replays exact `reasoning_content` only with a genuine tool-call assistant transcript; prose continuations contain role/content alone. A five-to-eight-call non-final response is acknowledged only with fixed no-source errors and may retry as one-to-four calls up to three times; it never executes the oversized calls. Nine-or-more calls, malformed IDs, a terminal call mixed into overflow, provider `4xx`, unsafe paths and all exhausted budgets remain non-successes. The runner remains without shell, candidate execution, network tool, App token or merge authority; fresh real-model calibration is still required before activation.

> **DeepSeek agent calibration does not make a correct review wait indefinitely** (2026-08-14; 0 migration / 0 RBAC / 0 cron / 0 MCP). Focused receipt [31762682575](https://github.com/pingcap-inc/Helix/actions/runs/31762682575) proved the transcript repair by BLOCKing #2614/#2666, but #2582 requested an invalid scope and received no source; it remained `INVALID`. The host now permits at most three source-free corrections only for `read_review_file`, `search_review_text`, and `list_review_paths`, naming exactly `trusted_base` and `candidate_snapshot`. It retains a dual independent agent pass but uses a six-call/four-evidence minimum and ten-call ceiling per pass rather than 16/24, preserving tool-grounded inspection while targeting a 5–10-minute PR review. Any other scope, path, malformed tool, unsafe request, failed model output or exhausted recovery fails closed; new real-model receipts are still required before activation.

> **DeepSeek calibration corpus is constrained to the live input boundary** (2026-08-13; 0 migration / 0 RBAC / 0 cron / 0 MCP). The original 15-fixture starter set is superseded by the 14-fixture live-replayable manifest: five APPROVE and nine BLOCK historical inputs. #2595 is intentionally not calibration evidence because GitHub omits its 3,128-line i18n patch and the complete diff cannot fit the live reviewer’s bounded input. Full receipt [31696046120](https://github.com/pingcap-inc/Helix/actions/runs/31696046120) matched all five APPROVEs but only four BLOCKs (#2666/#2614/#2611/#2526); it exposed two output-protocol INVALIDs (#2574 ambiguous JSON, #2571 more than the five visible findings) and three PASS false negatives (#2561 refresh-JTI, #2500 CLI workflow trigger, #2517 reusable OIDC write scope). Focused V8 receipt [31697910753](https://github.com/pingcap-inc/Helix/actions/runs/31697910753) recovered #2561/#2500/#2517 but left #2574/#2571 ambiguous-invalid. Full V9 receipt [31699738145](https://github.com/pingcap-inc/Helix/actions/runs/31699738145) was 12/14: #2581 false-positive and #2571 transient binding-invalid. V10 moves the immutable tuple to the trusted publisher/controller and adds only #2581's guarded-CI APPROVE boundary. Its full receipt [31701095615](https://github.com/pingcap-inc/Helix/actions/runs/31701095615) was 13/14: the sole remaining #2561 miss exposed an added-line-only citation blind spot for a removed security guard. V11 changes the exact anchor contract, not the model quota: a finding must name the validated `RIGHT` added line or `LEFT` deleted guard. A fresh #2561 replay and complete receipt remain required. See [`DEEPSEEK_REVIEW.md`](DEEPSEEK_REVIEW.md) and [`deepseek-review-shadow-fixtures.json`](deepseek-review-shadow-fixtures.json).

> **A multi-minute external pull must not run inside a DB transaction** (2026-08-11; migration **460** / 0 new cron / 0 RBAC / 0 MCP). [`run_metrics_sync`](../../server/app/domains/cmdb/metrics_sync.py) is now three phases with the pooled connection released between them: read the fleet → `commit()` → pull from cloud monitoring holding **nothing** → re-acquire and upsert in chunks of `_UPSERT_CHUNK_ROWS=500`, each committed. Before this, phase 1's read transaction stayed open for the whole cloud leg (AliCloud 海外: 24–27 min every night, 583 instances × 7 CloudMonitor metrics × master + every Resource Directory member), so it pinned one of the 10 pool slots, held a read snapshot far past `tidb_gc_life_time`, and on 2026-08-10 lost the socket to a TiDB Serverless recycle — errno **2013** on the first statement after the pull, discarding a completed 24-minute pull. `pool_pre_ping` is a *checkout*-time probe and cannot see a mid-life drop. The write phase also stopped preloading the window into the identity map: it is an `INSERT ... ON DUPLICATE KEY UPDATE` on `uq_server_metrics_daily` (`_ROLLUP_COLUMNS` + `source` + `updated_at`; `created_at` deliberately not in the SET list), wrapped in `run_with_lock_retry` so a drop *during the write* re-runs the write, never the pull. Migration 460 + `_METRICS_TIMEOUT_SECS` give the job an explicit 3600 s `timeout_secs`: it was running 1 633 s of the 1 800 s default and climbing with the fleet. **The pattern generalizes** — any handler that calls a vendor API between two DB statements is one node recycle away from the same failure.
>
> **The scheduler's missed-beat backfill must not re-fire a cron that is still running** (2026-08-11; 0 migration). `execute_job` writes `scheduled_jobs.last_run_at` in its *terminal* write-back, so during a long run the column still holds the previous run's timestamp. The hourly [`_register_scheduled_jobs`](../../server/app/worker.py) reload read that as "today's beat never happened" and scheduled a backfill fire 90 s out; `max_instances=1` suppressed it only while the run was still going, so any run finishing inside that window was immediately re-executed. Prod: 16 duplicate scheduler runs between 2026-06-11 and 2026-08-10, each starting 3–89 s after the previous run of the same daily cron ended — including the duplicate 24-minute AliCloud CloudMonitor pull that then died on errno 2013. The reload now skips the backfill for any job id in the process-local `_inflight_job_ids` set; the redeploy case the backfill exists for (worker killed mid-window, nothing in flight after reboot) still fires.
>

> **Cost allocation recompute TiDB memory bound** (2026-08-10; migration **458** / 0 cron / 0 RBAC / 0 MCP). [`recompute_allocation`](../../server/app/domains/cmdb/cost_service.py) re-applies allocation rules and scope-owner fallback to the 90-day `cmdb_cost_facts` window. Its former global-id keyset scan could cross large blocks of pre-cutoff history and be killed by TiDB error 8176; a 500k id band reduced but did not eliminate that risk because date filtering still happened inside each physical id range. The recompute now walks one accounting `date` at a time and pages only that day's `id` cursor. Migration 458 adds `ix_facts_date_id(date, id)`, pinning each page (`date = :day AND id > :cursor ORDER BY id LIMIT 10000`) to a narrow ordered index range. It preserves future-dated fact handling, bulk target-tuple updates and per-batch commits; no backfill is needed because the next run is idempotent.

> **Single source of truth for what is already built.** Read this before asking the user a structural question; ~80% of "does Helix have X?" questions are answered here.
>
> **The sweep drop-guard baseline is a MEDIAN of recent successful runs, because fleets legitimately oscillate** (2026-08-09; 0 migration / 0 cron / 0 RBAC / 0 MCP). `_previous_successful_cap_upserted` → [`_baseline_cap_upserted`](../../server/app/domains/cmdb/sync.py): same `_BASELINE_SCAN_WINDOW=20` scan, but it now collects the most recent `_BASELINE_SAMPLE_TARGET=5` successful per-capability `upserted` counts and returns their `median_low`. **Why the single-previous-run baseline was wrong:** the GCP servers cron (`failure_count` 47) kept refusing to sweep with `provider persisted 834 unique rows vs 1140 baseline (previous_sync) (>20% drop)`. Nothing was failing — the skipped-leg set was a stable 20 Compute-API-disabled projects across the whole window, and the driver never partial-failed. That tenant's GKE fleet **autoscales on China business hours**: 13–15 samples per cron tick give 09:55 UTC (17:55 Beijing) median 986 / peak 1140 against 21:55 UTC (05:55 Beijing) median 803 / trough 740. **Amplitude 35% of peak against a 20% drop tolerance**, so any trough run whose baseline landed on a peak run read as a partial driver failure. A median describes "what a healthy run looks like for this pair" instead of "what the last one happened to catch", and is robust in **both** directions — one inflated duplicate-page run no longer poisons the next tick (previously only rescued after the fact by the `previous_sync_capped_by_existing` cap), and one trough run no longer lowers the bar. `median_low` rather than `median` so an even sample resolves to a count the cloud actually returned, not an average of two runs — this integer authorizes soft-deletes and has to be pointable-at in a post-incident review. The guard is NOT blunted: losing a third of a fleet is far outside the band a median smooths over. `baseline_source` deliberately still reports `previous_sync` — it is a stable identifier for "the cloud-returned baseline" vs `existing`, already persisted in historical `forced_meta` audit rows. **Test-fixture trap worth knowing:** an end-to-end `_safe_sweep` test must seed the Helix-resident fleet and this run's observed subset under **different** `last_seen_sync_job_id`s; stamping every row with the current job makes `existing == observed`, the baseline gets capped to `existing`, and the assertion compares a number to itself without ever exercising the baseline path. Regressions: [`test_sweep_baseline_median_2026_08_09.py`](../../server/tests/test_cmdb/test_sweep_baseline_median_2026_08_09.py).
>
> **A CMDB capability sync commits in batches, and `cmdb_servers.raw_data` never stores a bootstrap blob** (2026-08-08; 0 migration / 0 cron / 0 RBAC / 0 MCP). Two invariants in [`domains/cmdb/sync.py`](../../server/app/domains/cmdb/sync.py) after the GCP servers cron died on TiDB **8176** (`exceeding the allowed memory limit for the tidb-server instance`). **(1) Bounded write transaction.** Each `_sync_*` helper used to upsert its entire capability and commit once after the sweep; TiDB holds a transaction's write set in tidb-server memory until commit and charges it to the session, so the accumulated bytes are billed to whatever statement runs next. `statements_summary_history` for the failing run shows the membuffer growing across the insert loop (663 inserts → 64.5 MB, next 93 → 129 MB) and then being charged to statements that are intrinsically free — a `COUNT(*)` at 129 MB, the sweep `UPDATE` at 157 MB, which is where 8176 landed. Re-running those same guard queries in a fresh session with an empty transaction reports **0.0 MB**, which is what distinguishes membuffer pressure from a bad query plan — **use that check before adding an index to a "heavy" sync query**. `_commit_upsert_batch(db, upserted)` now commits every `_UPSERT_COMMIT_BATCH` (=200) rows in the servers / domains / certificates / dns_records / load_balancers / cdn / object_storage loops; call it at the END of a loop iteration so a batch boundary never splits one resource's writes. IAM is deliberately excluded — its shadow-IAM revival alert is enqueued after the loop and must commit with the `revoked_at` clear. Mark-and-sweep runs strictly after the loop, so a mid-loop failure now preserves partial progress instead of discarding it, and still soft-deletes nothing. Same shape as the scope-sync batch commit (`lifecycle_service.sync_scopes_for_integration`, `BATCH_SIZE=50`, 2026-04-24) — that one was fixing lock-hold time, this one memory, same remedy. **(2) `raw_data` is a diagnostic snapshot, not a blob store.** GCP instances averaged **95 KB** each (16× AWS, 34× Aliyun) because GKE nodes carry `configure-sh` (64 KB) + `kube-env` (22 KB) + `user-data` (5.9 KB) in `metadata.items` — 74 MB of write set per sync that nothing reads, plus node bootstrap credential material sitting in a plain JSON column. `_trim_oversized_strings` elides any string over `_RAW_STRING_MAX_CHARS` (=4096) to `<helix:elided N chars>`, preserving keys and structure. Applied to **servers only, on purpose**: certificates legitimately store multi-KB PEM chains. The only consumers of a server's `raw_data` are `CmdbService` (`_project_id` → cloud-scope link) and `efficiency.py` (`id` → cost matching), both short strings. Regressions: [`test_server_sync_txn_memory_2026_08_08.py`](../../server/tests/test_cmdb/test_server_sync_txn_memory_2026_08_08.py).
>
> **`distributed_lock` supports bounded waiting, and a starved cloud sync now FAILS instead of reporting ok** (2026-08-06; 0 migration / 0 cron new / 0 RBAC / 0 MCP). [`distributed_lock`](../../server/app/core/distributed_lock.py) gains **`acquire_timeout`** (default `0.0` = the historical fail-immediately behaviour, so all existing callers are unchanged): it polls every 5s until the key frees or the budget elapses. Motivation — `cmdb.scopes_sync` / `roles_sync` / `grants_sync` all serialize on ONE `cloud_sync:{integration_id}` key but run as **three independent crons scheduled minutes apart**, so whenever the slowest member overran the gap the next one lost the lock and no-opped. Prod: `cmdb.roles.aws-primary` (04:09, avg 731s / max 882s) still held the lock when `cmdb.scopes.aws-primary` fired at 04:18 → `scheduled_job_runs` shows **29 of 31 runs (2026-07-07..08-05) were lock-skips, only 2 real runs**. It stayed invisible because the handler *returned* `{"status":"skipped"}` and the runner records a returned dict as `status='ok'` (`feedback_handler_status_must_raise`) — the failure notifier never fired, and AWS sub-accounts created in July surfaced in Helix only on 2026-08-03. The three handlers now wait `_CLOUD_SYNC_LOCK_WAIT_SEC=900` and then **raise** via `raise_cloud_sync_lock_starved` ([`handlers.py`](../../server/app/scheduled_jobs/handlers.py)) so the run lands `failed` and pages. Belt-and-braces: prod cron for `cmdb.scopes.aws-primary` moved `18 4 * * *` → `40 4 * * *` (clear of roles' 04:24 worst-case release) so the wait is a safety net, not a daily 15-minute scheduler-slot squat. **Any future job added to the `cloud_sync:{id}` family must be scheduled against the measured duration of its siblings, not a nominal few-minute gap.**
>
> **Newly discovered cloud scopes are environment-classified from their account name** (2026-08-06; 0 migration / 0 cron / 0 RBAC / 0 MCP). Cloud providers expose no environment attribute, so every synced scope used to land `environment='dev'` and stay there until a human noticed — a production account therefore displayed as 开发 *and* skipped both the prod write lock and the expiry-sweep exemption (prod `417183877957` / `dbaas-prod-essential-v2` sat mislabelled from first sync until an employee reported it). [`classify_scope_environment`](../../server/app/domains/cmdb/models.py) now derives `prod` from a standalone `prod`/`production` token in `display_name`, applied **only on the INSERT branch** of the scope upsert in [`sync_scopes_for_integration`](../../server/app/domains/cmdb/lifecycle_service.py) — deliberately absent from `ON DUPLICATE KEY UPDATE`, so a human reclassification is never reverted by the next sync (same policy as `budget_usd_monthly` / `status`). The reverse inference is NOT made: `pingcap-dbaas` is production but its name says nothing, so name-says-nothing → default `dev`, never a downgrade of an existing row. ⚠️ **`nonprod` contains `prod`** — a naive `"prod" in name` marks the whole non-production estate as production (8 of the 17 sys9/infra accounts onboarded the same day). The token regex carries three guards, `(?<![a-z])(?<!non-)(?<!non_)prod(uction)?(?![a-z])`; the `non-` lookbehind was found by the parametrised test, not by eye. Tests: [`test_scope_env_classify_and_sync_lock_2026_08_06.py`](../../server/tests/test_cmdb/test_scope_env_classify_and_sync_lock_2026_08_06.py).
>
> **API-Key "how to use this key" is a first-class employee surface** (2026-08-04; 0 migration / 0 cron / 0 RBAC / +1 MCP `api_key_usage_hints`, hint **447**). New non-secret endpoint `GET /api/v1/apikeys/assignments/{id}/usage-hints` ([`router.py`](../../server/app/domains/apikey/router.py) → [`ApiKeyService.usage_hints`](../../server/app/domains/apikey/service.py)) returns `{vendor_kind, adopted, has_key, contact_it, deployments[], deployments_status}` — owner / shared-member / asset-admin gated, same surface as `/reveal` but without the browser-session step-up (carries no key material). `vendor_kind` resolves from the structured `detail.<vendor>` blob (bailian/bedrock/azure_openai) then the provider name. **Azure OpenAI** hints call the NEW read-only [`AzureCloudDriver.list_foundry_deployments`](../../server/app/integrations/drivers/azure_cloud.py) (ARM `GET .../accounts/{name}/deployments`) so the panel shows the LIVE model deployments IT provisioned as the `model=` argument; live-call failure degrades to `detail.azure_openai.last_deployments` (`deployments_status=stale`), none → `contact_it=true`. The frozen reveal bundles in [`azure_openai_autogrant.py`](../../server/app/domains/apikey/azure_openai_autogrant.py) + [`rotation.py`](../../server/app/domains/apikey/rotation.py) no longer tell the employee to self-deploy a model (they have no portal access) — they route to IT. Frontend guidance is a shared module [`components/apikey/{revealBundle.ts,revealGuidance.tsx}`](../../web/src/components/apikey/revealGuidance.tsx) (`parseKeyBundle`/`detectProviderKind`/`RevealHowTo`/`CodeBlock` + `AdoptedKeyNotice`), consumed by BOTH the employee [`ApiKeyRevealModal`](../../web/src/components/apikey/ApiKeyRevealModal.tsx) (now structured Descriptions + "如何使用" collapse; fetch keyed on `useEffect(assignment.id)` NOT Modal `afterOpenChange`, so an embedded-webview rc-motion stall can't wedge it on loading) and the admin reveal in `UserItUniverseTab`. **存量纳管 (adopted)** keys — whose plaintext Helix never held — get an explicit notice (list 更多 menu + detail 「凭证」 card + mobile tag/pointer) instead of a silent hidden reveal button. Tests: [`test_usage_hints_2026_08_04.py`](../../server/tests/test_apikey/test_usage_hints_2026_08_04.py).
>
> **Bedrock per-API-Key spend is broken down by UsedBy — the per-key × UsedBy cross** (2026-08-04; migration **452** / 0 cron new / 0 RBAC / 0 MCP). `cmdb_bedrock_profile_facts` gains `sub_profile` (joins `fact_key`). The per-API-Key axis (`profile_tag_key = iamPrincipal/helix-request-number`, migration 435) previously stored one row per (date × key × service); its `UsedBy` workload table on the detail page was the person-level axis (owner-filtered), so two keys held by one person rendered an identical table (xuhuaiyu reported it). CE crosses the `UsedBy` resource tag with the `helix-request-number` IAM-principal tag (prod-verified 2026-08-04: per-key crosses sum to the owner total to the cent), so the sync now dispatches the per-key axis to the AWS driver's new [`list_bedrock_per_key_profile_facts`](../../server/app/integrations/drivers/aws_cloud.py) — one account-wide `GroupBy=(key × owner)` discovery call, then per spending key `Filter=account+key, GroupBy=(UsedBy × SERVICE)` — landing rows as `profile=<AK number>`, `sub_profile=<UsedBy>`, `service=<model>`. Read side: [`_per_key_by_profile`](../../server/app/domains/apikey/spend.py) groups THIS key's rows by `sub_profile`, so `by_profile_scope` flips from `holder` to **`self`** (rows sum to the per-key headline; each key finally shows its OWN workloads). Invariants held: never sum across `profile_tag_key`; the per-key axis still excluded from the person-level workload axis; switch gate is still "rows in this window", not "setting configured"; NULL `sub_profile` (pre-452 rows / untagged calls) folds to `(unallocated)` and still reconciles. Same PR added `_BEDROCK_PROFILE_PER_SCOPE_TIMEOUT_SEC=120s` (`asyncio.wait_for` wrapping only the vendor fetches) — the profile-facts per-scope leg previously had no timeout while holding the `cloud_sync:cost_facts` lock (ttl=900). Fan-out grew 5→7 CE calls/day/scope (only keys with spend, not all IAM users). Tests: [`test_bedrock_per_key_usedby_cross_2026_08_04.py`](../../server/tests/test_cmdb/test_bedrock_per_key_usedby_cross_2026_08_04.py) + updated [`test_spend_detail_axis_surfacing_2026_07_30.py`](../../server/tests/test_apikey/test_spend_detail_axis_surfacing_2026_07_30.py) / [`test_spend_per_key_tag.py`](../../server/tests/test_apikey/test_spend_per_key_tag.py). Runbook: `docs/ops/cloud-lifecycle-ops.md §9a-bedrock`.
>
> **Cloud subscriptions carry a synced-but-overridable account email** (2026-08-03; migration **450** / 0 cron / 0 RBAC / 0 MCP). Cloud providers report a per-account contact email (AWS Organizations `list_accounts` → `Email`); the AWS driver already captured it into `raw_data` but it was never a first-class field, so it never displayed. `CmdbCloudScope` gains `cloud_account_email` (cloud-authoritative, refreshed on every `sync_scopes_for_integration` upsert exactly like `display_name`, read-only "云上值") and `account_email_override` (owner/admin replacement; NULL = use cloud), plus `account_email_modified_by_person_id` / `account_email_modified_at` to record who last set/cleared the override. Two columns rather than the `CmdbServer`/`CmdbDomain` single-column-`+`-`data_source` pattern **on purpose**: the requirement is to show cloud value AND human value together, which a single column loses on override. `ScopeResponse` exposes the pair + resolved `account_email` (override ?? cloud) + modifier label; `PATCH /cmdb/scopes/{id}` accepts `account_email_override` (empty string clears; non-empty is format-validated 422) under the existing owner-or-admin gate (owners can edit; pure Helix metadata, but held to the prod-write-lock like `notes`). Desktop owner/admin detail drawers + shared `ScopeIdentityBar` + mobile admin detail surface it. **External API** (`/api/v1/open/cloud/subscriptions`, 2026-08-04): GET exposes `account_email` (effective) + `cloud_account_email` + `account_email_override`; PATCH accepts `account_email_override` (service-principal audit, no modifier person id). **Prod-lock exempt** (2026-08-04 product decision): the account-email override is contact metadata that never touches cloud IAM, so an owner can edit it on a locked prod scope (dedicated "save email only" path, sends only that field). The same PR widened the **owner-transfer** prod-lock carve-out from admin-only to owners too (re-seating ownership is pure Helix metadata; the outgoing grant revoke stays prod-gated). Everything else stays blocked under the lock. Tests: [`test_scope_account_email_2026_08_03.py`](../../server/tests/test_cmdb/test_scope_account_email_2026_08_03.py) + [`test_cloud_subscriptions_api.py`](../../server/tests/test_api_clients/test_cloud_subscriptions_api.py).
>
> **Durable approval intents minimize terminal data and retry fairly** (2026-08-03; #2504/#2507 / migration 449 / existing cron / 0 RBAC / 0 MCP). All 11 domain finalizers atomically replace recovery-only payloads with a `terminal-v1` resource receipt at completion/compensation; stable hashes and provider/audit correlations remain in dedicated columns. Migration 449 compacts existing terminal rows and adds indexed `next_attempt_at`. Reconciliation failures and lock contention use bounded exponential backoff, due work is ordered by `coalesce(next_attempt_at, created_at)`, and a bad fixed-batch prefix therefore cannot starve later valid intents. Column/index DDL steps are independently guarded for MySQL/TiDB interruption recovery.
>
> **The Feishu approval durable-intent rollout is complete** (2026-08-03; #2392 final / migration 448 only for the pre-existing SSO 1 MB contract / existing cron extended / 0 RBAC / 0 MCP). Asset approvals and every production non-asset create path now persist a stable provider UUID before outbound I/O. Announcement, VPN, AI-tool, SSO, identity-consumer, domain-registration, data-access, API-key, CMDB scope and CMDB DNS flows all have scoped finalizers and cron recovery; the generic chat-approval driver requires a caller-supplied durable UUID. The shared primitive supports payload-level `recovery_key` partitions and exact durable-row `intent_key` generations without dropping SSO compensation states. The unified drift handler invokes and aggregates every domain once, and the AST UUID-less production inventory is the empty set. Full invariants and recovery states are in [`feishu-approval-submission-intents.md`](feishu-approval-submission-intents.md).
>
> **Identity-consumer and domain-registration Feishu creates use durable intents** (2026-08-03; #2392 batch 3 / 0 migration / existing cron extended / 0 RBAC / 0 MCP). Both domains commit a stable Feishu UUID and immutable provider/finalization payload before outbound I/O, then atomically materialise the domain row, parallel `ApprovalRequest`, audit and completed intent. Identity consumers use a slug recovery subkey so one applicant's unknown submission cannot absorb a different system request; domain registration retains its one-pending-per-applicant rule. Submit and cron recovery share the same distributed lock. A locking lifecycle read after provider creation converts newly ineligible requester/owner cases into correlated cancelled rows plus durable Feishu withdrawal compensation.

> **VPN and AI-tool Feishu creates use durable intents** (2026-08-03; #2392 batch 2 / 0 migration / existing cron extended / 0 RBAC / 0 MCP). Both domains now commit their stable Feishu UUID and immutable provider/finalization payload before outbound I/O, then atomically materialise the domain row, parallel `ApprovalRequest`, success audit and completed intent. Response loss, provider-success process death, user retry and the existing drift cron all converge on one provider instance; locking-current-read pending checks close the MySQL/TiDB repeatable-read snapshot race after waiting for the per-requester Redis lock. The final rollout contract pins the UUID-less production inventory to zero.
>
> **Integration diagnostics have one credential-redaction boundary** (2026-08-03; #2469 / 0 migration / 0 cron / 0 RBAC / 0 MCP). [`app/core/credential_redaction.py`](../../server/app/core/credential_redaction.py) is the shared credential-only scrubber for both non-cloud `test_connection` diagnostics and CMDB provider exceptions. It recognizes vendor access IDs, Tencent `SecretId` / `SecretKey`, Basic/Bearer authorization, signed-query credentials, OSS security tokens, private keys, JWTs and supported generic key forms while preserving ordinary diagnostic identifiers. `IntegrationService` recursively scrubs `message` and nested `detail`, drops secret-bearing structured keys, and applies its stricter long-base64 rule before the same result reaches the HTTP response, `integrations.last_test_message`, or `integration.test_connection` audit detail. CMDB's `redact_provider_error` is now a thin compatibility wrapper over that core primitive, preventing the two surfaces from silently drifting again. Regression contracts: [`test_test_connection_detail_scrub.py`](../../server/tests/test_integrations/test_test_connection_detail_scrub.py), [`test_crud_api.py`](../../server/tests/test_integrations/test_crud_api.py), and [`test_provider_error_redaction.py`](../../server/tests/test_cmdb/test_provider_error_redaction.py).
>
> **Non-asset Feishu approval creates use the migration-442 durable-intent plane** (2026-08-03; #2392 complete / migration 448 for the pre-existing SSO 1 MB contract / existing cron extended / 0 RBAC / 0 MCP). The reusable provider boundary in [`approval/submission_intents.py`](../../server/app/approval/submission_intents.py) commits a stable Feishu UUID plus immutable provider/finalization payload before create, reuses it after timeout/process death, and resolves Feishu 60012 only through bounded list pagination followed by detail UUID comparison. All enumerated non-asset submissions are migrated. SSO and identity-consumer flows use recovery subkeys; data-access and API-key flows use exact durable request keys. SSO's provider-created finalizer uses a locking current lifecycle read: a new leaver is materialised as a cancelled grant + cancelled `ApprovalRequest` for callback correlation, then durable `compensation_pending` recovery retries Feishu withdrawal to `compensated`. Up-to-1 MB SAML XML/X509 recovery material is held once and compacted from the completed intent after migration 448's `MEDIUMTEXT` domain write. Asset and non-asset reconciliation are scope-separated, safe error persistence excludes credentials, and the AST contract requires zero UUID-less production calls.
>
> **Procurement leaver write boundary is enforced at transport and transaction layers** (2026-08-02; #2286 #2424 / 0 migration / 0 cron / RBAC tightening / 0 MCP count change). The procurement router keeps GET/HEAD/OPTIONS readable for `leaver_pending` users but rejects every POST/PUT/PATCH/DELETE with `LEAVER_RUNTIME_LOCKED`; both procurement MCP writes use the same mutating runtime semantics. Transport checks are not the atomicity boundary: authentication can leave a stale active ORM actor while offboarding commits concurrently. Therefore all 17 procurement service mutation primitives locking-current-read the actor `identity_persons` row before any business write and hold it through commit/rollback; supplier sensitive reveal uses the same lock around disclosure plus audit. If offboarding committed first, the write fails closed; if procurement obtained the lock first, offboarding waits and the procurement commit linearizes before the status transition. Regression contract: [`test_leaver_runtime_gate_2026_08_02.py`](../../server/tests/test_procurement/test_leaver_runtime_gate_2026_08_02.py).
>
> **Payment creation holds its dedup lock through one payment+audit commit** (2026-08-02; 0 migration / 0 cron / 0 RBAC / 0 MCP). `procurement.service.create_payment` owns the transaction boundary because its distributed lock must remain held until the financial row is visible to the next identical request; the 120-second lease auto-renews, so an exceptional DB wait cannot let a second writer enter mid-commit. The HTTP route supplies a `before_commit` audit callback: a new payment is flushed, its `procurement.payment.create` evidence is flushed, then both commit once while the lock is still held. Any failure rolls back before lock release and leaves the session reusable; this includes Redis `LockBusy` after the actor lifecycle row lock has started a DB transaction (#2441), preventing a retained actor lock or stale retry snapshot. A recent-duplicate hit returns the original payment without emitting a second create audit. The dedup identity is canonical before ANY of those operations (#2337): amount uses `ROUND_HALF_UP` at the stored `Numeric(14,2)` scale, currency is trim+uppercase, invoice is trim+uppercase/NULL, and payment number is trim+uppercase/NULL; the recent-row predicate and persisted payload therefore cannot disagree. An explicit payment number is part of that identity (#2439), so two intentionally split payments with otherwise equal fields are not collapsed; automatic-number retries continue to compare without a generated number. The audit freezes both the canonical booked values and original caller values plus per-field normalization flags, so cleanup/rounding remains reconstructible. The Redis key is a SHA-256 of a structured JSON identity (#2342), not delimiter-joined caller text: distinct currency/invoice fields cannot collide and invoice identifiers do not leak through Redis key listings. Number allocation remains the migration-446 counter-first transactional allocator; this path has exactly one renewable distributed intent lock and no private serial allocator. Fault injection, formatted-variant contention/retry, empty invoice, half-cent rounding, explicit split payments, delimiter-collision concurrency, and a whole-app call-site AST inventory are in [`test_payment_audit_atomicity_2026_08_02.py`](../../server/tests/test_procurement/test_payment_audit_atomicity_2026_08_02.py).

> **Book-close GETs never materialise calendar configuration or template seeds** (2026-08-02; 0 migration / 0 cron / 0 RBAC / 0 MCP). `close_service.load_config` is read-pure: when the singleton is absent it returns a transient object carrying the built-in defaults, so config GET, run previews, run lists, and window computations perform no INSERT/flush/commit. Template GET likewise only lists persisted rows; it no longer seeds/refreshes entities, templates, mappings, or historical task owners according to the caller's role. Only explicit mutation paths call `ensure_config` (`update_config` and `restore_defaults`) or `ensure_seeded` (`POST /templates/seed`, instantiate, roll-forward), behind the appropriate finance-operate RBAC, rate limiting/audit at their public command boundary. Concurrent first config writers converge through a savepoint plus locking current-read; seed/instantiate/roll-forward transactions additionally lock that stable config singleton before reading entity/template state, so two first writers cannot collide on seed UNIQUE keys (#2442). The XLSX export GET retains its deliberate immediate data-egress audit. Tests prove ordinary GETs work while `db.commit`/seeding is forbidden, read roles cannot materialise via PUT, explicit writes persist exactly with audit, rollback removes a newly materialised row, concurrent first writes and template instantiation converge, existing template fields are not refreshed by GET, and every close GET/materialisation call site is AST-enumerated: [`test_close_config_read_purity_2026_08_02.py`](../../server/tests/test_finance/test_close_config_read_purity_2026_08_02.py).

> **GitHub OIDC is purpose-split and workflow-pinned** (2026-08-02). The legacy shared `vars.AWS_ROLE_ARN` / `repo:*` role is retired. Frontend config read, ECR image publish, CLI/plugin artifact publish, DAX activation, and production deploy each have a distinct role; publishers require main plus their exact workflow, and the last two additionally require `environment:production`. PR Terraform receives no AWS identity or production state and validates backendless. The authoritative role/action matrix and no-downtime rollout order are in [`docs/ops/github-oidc-roles.md`](../ops/github-oidc-roles.md).
>
> **A `partial` outbox row is only terminal when the miss is PERMANENT** (2026-09-07; 0 migration / 0 cron / 0 RBAC / 0 MCP). `_deliver`'s `any_delivered` branch used to freeze every partially-delivered row at `partial` with `next_attempt_at=NULL`, and `retry_pending_outbox` excluded `partial` from its pool outright. The reasoning was sound for one case and wrong for the other: a recipient with no Feishu account (`Feishu open_id not found`) will not acquire one during a backoff, but a provider returning `2200 Internal Error` or `9499 too many request` will recover in minutes. Because the two shared a status, **one transient blip permanently dropped that channel's message after a single attempt** — and since `_DELIVERED_STATUSES` counts `partial` as reach, the recipient still read as notified. Found on the 2026-09-07 CEO-fraud awareness broadcast (416 recipients, 1 lost her Feishu card to the only `2200` in 60 days of outbox history). **And it was not a one-off:** re-scanning 60 days of `partial` rows against the new predicate finds **8** frozen at `attempts=1` on a transient error — roughly one a week. Two `40003 Internal Error` (asset-inventory reminders, 2026-08-26), one `9499 too many request` (2026-07-16), and five in the same second on 2026-08-22 03:00:01 with `Expecting value: line 1 column 1` (an empty Feishu response body) whose template was **`scheduled_job.failure`** — the alerting system's own alerts, to five IT admins. Their email landed, so the critical-template webhook backstop correctly stayed quiet; the card on-call actually watches was dropped and never retried. All 8 carry NULL and are NOT revived by this change. The distinction now lives in `next_attempt_at`: **armed = claimable, NULL = terminal**, decided by the existing `_all_failures_permanent` classifier plus the attempts ceiling. `retry_due_clause(now)` in [`notifications/service.py`](../../server/app/notifications/service.py) is the single predicate for BOTH the worker's candidate-id scan and its per-row locked re-check, so the two can no longer disagree about what is due. `_deliver` only attempts channels absent from `delivered_channels`, so a claimed partial re-fires the missing channel alone and never re-sends what landed. **Every partial row written before this change carries NULL and stays out** — deliberate: their transient window closed long ago and reviving them would deliver stale notices. **Interaction with `fanout.OUTBOX_RETRY_FENCE`** (checked deliberately, because this landed the same day as #3492's move of campaign fan-out into a background task, and two independently-correct halves are how cross-PR defects happen): the fence writes a 30-minute `next_attempt_at` to keep the retry worker off rows a long drain is about to send, and `_deliver` overwrites it when the row settles — so a transient partial's 5-minute arming can now come due *while a 15-minute drain is still running*. That is safe, and for two independent reasons: phase 2 walks its own explicit list once and never revisits a settled row, and `_deliver` recomputes `todo` from the freshly re-read `delivered_channels`, so the worker can only send the channel still outstanding. No double-send; just an earlier completion than before. Regression: [`test_partial_transient_retry_2026_09_07.py`](../../server/tests/test_notifications/test_partial_transient_retry_2026_09_07.py) — mutation-verified in both directions, which matters because the two halves (the arming and the clause) mask each other: removing only one leaves part of the file green.
>
> **用工类型 is a first-class, readable field** (2026-09-07; 0 migration / 0 cron / 0 RBAC / 0 MCP). `identity_persons.employee_type_code` (SF `EmpJob.customString25`) has been populated since the HRIS sync was built and was readable from **nowhere**: zero references in `web/src/`, and its only two consumers were translation tables pointing elsewhere (`identity_sync.presets.EMPLOYEE_TYPE_CODE_TO_FEISHU`, `pending_leaver_notice_service`'s two-code allow-list). "Is this person a consultant, an outsourced contractor or a regular employee" had no answer on any screen, and the RBAC 角色 chip on the person page had been quietly standing in for it. [`app/identity/employee_types.py`](../../server/app/identity/employee_types.py) is now the single code→meaning source: `1070` 正式员工 / `1071` EOR / `1072` 实习生 / `1073` 劳务顾问 / `1074` 外包人员 / `1075` 派遣人员. **It maps codes to i18n SLUGS, never labels** — SF owns the vocabulary and swapped the 1074/1075 labels on 2026-07-15 without touching the codes, so a label-shaped store would have silently inverted that day; and a Chinese label in an API response is a string no ja/en reader can be shown. An **unmapped** code renders as the raw code, not a blank cell: SF can add a picklist value any day, and the code is visible, searchable and directly actionable with HR. Surfaces: the person detail 基本信息 row, a 用户列表 column plus `GET /users?employee_type=1073,1074` (comma list, same shape as `status`), and a new `employee_type_codes` dimension on the shared `FilterConfig` — so awareness audiences, identity-sync scopes and identity consumers can all scope by engagement type. **A new `CandidatePerson` field is a trap twice over**: it defaults to `None`, so a construction site that forgets to pass it makes the new dimension match nobody on that surface (silent); and one of the five sites — `identity.consumers.directory_service.resolve_in_scope_person_ids`, the Directory/SCIM **pull authorization boundary** — builds its candidates from a narrow `select(col, col, …)` Row rather than an ORM object, so reading an unselected attribute raises `AttributeError` and takes the whole open-people surface down (caught by `test_people_github_api.py`, 24 red). Adding a field to `CandidatePerson` means auditing every site for WHICH of the two shapes it feeds from. All five are wired, and `person_visibility`'s allowlist/narrowing/audit-detail lists carry the key too (the allowlist REJECTS unknown keys, so the shared editor emitting a dimension the API refuses would be a hard 422). `is_non_employee_type` covers consultant/outsourced/dispatched only — EOR staff and interns are directly engaged — and is explicitly **not** an authorisation predicate. Regression: [`test_employee_type_filter_2026_09_07.py`](../../server/tests/test_identity_sync/test_employee_type_filter_2026_09_07.py), including a test pinning this catalogue to the Feishu value-map so a future picklist addition cannot land in one and not the other.
>
> **`NotificationService.deliver()` must never run inside a SAVEPOINT** (2026-08-28; 0 migration / 0 cron / 0 RBAC / 0 MCP). It COMMITS — the durable provider-I/O claim below, then the outcome — and a commit inside an open `best_effort` / `session.begin_nested()` block closes the transaction that block still owns: the next statement raises `InvalidRequestError: Can't operate on closed transaction inside context manager`, the claim is already durable, and the `delivery_uncertain` fallback handler cannot run either. The row is stranded in `delivering` with `attempts=0` until stale-lease reconciliation flips it to `delivery_uncertain`, a status that deliberately never auto-resends — so the notification is never sent and `best_effort` swallows the only evidence. That combination (durable claim from #2414 + `best_effort` wrapper from #2672) lost **every** `offboarding.handover_assigned` push between 2026-08-13 and 08-28 (8 production rows, 0 delivered), plus the `offboarding_delete_preview` and `google.active_employee_account_dark` alerts. `_deliver_with_durable_claim` now refuses BEFORE claiming when `db.in_nested_transaction()`, so a repeat leaves the row PENDING for `notification.outbox_retry` instead of stranded. The shape every call site should use is `app.notifications.deliver_enqueued(db, queued, trace_id=...)`: enqueue (and its idempotency receipt) inside the savepoint, then commit + deliver after the block, one row at a time; a vendor failure is logged and left to the retry worker, and it degrades to "leave it all to the worker" when an OUTER caller still holds a savepoint. A caller that is itself inside `best_effort` collects rows instead (`notify_handover_assigned(..., queue_into=...)`). Note the failure mode this replaced is itself a trap: `deliver()` rolls the session back on error, which EXPIRES the caller's ORM instances — read ids before delivering. Regression contract: [`test_notify_deliver_outside_savepoint_2026_08_28.py`](../../server/tests/test_offboarding/test_notify_deliver_outside_savepoint_2026_08_28.py).
>
> **Notification provider I/O has a committed uncertainty boundary** (2026-08-02; migration **443** / 0 cron / 0 RBAC / 0 MCP). Retry/manual-resend delivery commits `status=delivering`, a 15-minute lease, claim token/channels/count, and stable provider keys BEFORE Feishu/SMTP calls. No second worker or admin retry may send a live claim. Success/failure is committed afterward; a process death, response-loss exception, final-status commit failure, or stale lease becomes `delivery_uncertain` and is excluded from automatic retry. The only safe automatic stale recovery is Feishu-only inside 55 minutes, using the same outbox UUID inside Feishu's documented one-hour de-dup window. Older Feishu, SMTP, and mixed claims require operator reconciliation: stable RFC Message-ID / Feishu UUID is visible in admin, and an audited decision either confirms delivery or confirms absence before requeue. SMTP Message-ID is reconciliation evidence and best-effort downstream dedup, **not** an exactly-once guarantee. An uncertain external result remains visible in the personal inbox because the local outbox content is durably present. Regression contract: [`test_delivery_claim_boundary_2026_08_02.py`](../../server/tests/test_notifications/test_delivery_claim_boundary_2026_08_02.py).
>
> **Identity-sync destructive confirmation plane** (2026-08-02; migration **441** / 0 cron / 0 RBAC / 0 MCP). Every mass user deactivate/delete and Feishu/汇联易/NetSuite department removal uses `DestructivePlanService`: the first run persists the exact target-config/action/complete-subject snapshot and blocks; the target's integration tenant/version (id/kind/provider/slug/enabled/updated_at, public-config/capability digests, and encrypted credential-envelope digest) is part of that snapshot. A global admin confirms the displayed SHA-256 fingerprint and receives a 10-minute opaque bearer token whose digest alone is stored; the next full reconcile bypasses the ORM identity-map cache, locks and recomputes the current target/integration snapshot, then atomically consumes the one-use token before vendor calls. Expiry, target/integration/credential or subject drift, tampering, concurrent confirmation, and replay fail closed. `identity_sync_destructive_plans` is the recovery/audit record; active plans are exposed through admin list/confirm/reissue endpoints and the Sync Now review modal.
>
> **Finance budget alerts are claim-first durable outbox work, never inline delivery** (2026-08-02; compatible with migration **443** / 0 cron / 0 RBAC / 0 MCP). `evaluate_and_alert` takes `SELECT ... FOR UPDATE` on each crossed budget before inspecting `last_alerted_threshold`, revalidates the report's amount/scope/owner snapshot, enqueues a PENDING `notification_outbox` row, advances the marker, and leaves their one atomic commit to the finance job. It never calls `dispatch`/`deliver`; real provider I/O is performed after commit by `notification.outbox_retry` under 443's durable delivery claim. Thus concurrent evaluators enqueue once, an enqueue failure advances nothing, and a transaction rollback removes both intent and marker instead of producing a ghost notification. An actual owner/scope change resets the marker so the replacement definition is evaluated afresh; note/no-op edits do not. Confidential cloud/mixed alerts carry a generic delivery-role guard in persisted context: immediately before external I/O the worker locking-current-reads finance/cloud_admin/global_admin assignments and downgrades revoked/malformed/failed policy checks to an amount-free fallback. That fallback is committed while the row remains `delivering` and **before provider I/O**; final-commit failure, worker death, or stale-claim reconciliation also redacts a guarded row before exposing `delivery_uncertain` in the personal inbox. Authorized sends retain the role-row lock through provider I/O and the final outcome commit, so concurrent revocation cannot enter the checked interval. Stable Feishu UUID / email Message-ID and uncertain reconciliation retain 443's exact guarantees. Regression contract: [`test_budget_alert_outbox_atomicity_2026_08_02.py`](../../server/tests/test_finance/test_budget_alert_outbox_atomicity_2026_08_02.py).
> **CMDB destructive sync guards compare unique persisted observations, never loop throughput** (2026-08-02; 0 migration / 0 cron / 0 RBAC / 0 MCP). Resource `_safe_sweep`, scope close-sweep, and role soft-delete all count active rows stamped with the current `last_seen_sync_job_id` after upsert. The raw `upserted` counter is diagnostic only: duplicate provider pages or a tenant-wide role returned once per scope can update one unique row many times, so using it as cardinality can make a partial snapshot appear healthy and authorize mass deletion. Historical previous-sync raw counts are capped by current persisted active cardinality, with `previous_sync_capped_by_existing` recorded when that recovery applies. MySQL permits duplicate NULLs in a composite UNIQUE key, so tenant-wide `scope_id=NULL` roles use one shared canonical-row helper from both full sync and single-scope prewarm; repeated fan-out stamps one logical role, merges admin-owned curation from historical duplicates, and lets the guard sweep the rest. Drop thresholds use exact integer cross-multiplication; explicit admin force-sweep remains the only bypass and records both raw and unique counts. Aliyun RD discovery requires canonical STS master/member identities, rejects a directory master that disagrees with STS, requires a non-negative integer `TotalCount`, and enforces exact page cardinality; malformed or truncated snapshots fail before sweep. Admin scopes→roles→grants chaining consumes real `success/partial_failure` stage contracts and recursively redacts all nested provider failure diagnostics. See #2319/#2343/#2349/#2351/#2352/#2425/#2426/#2427/#2428/#2429/#2430/#2432/#2433/#2434 and the regressions in `test_legacy_id_sweep_protection.py`, `test_safe_sweep_sync_to_sync_baseline.py`, `test_role_curation_and_groups.py`, and `test_scope_sync_background_failure_chain.py`.
>
> **Role-change event list has one bounded read contract** (2026-08-02; migration **447** / 0 cron / 0 RBAC / **+1 MCP** `list_role_change_events`, hint 444→445). HTTP `GET /api/v1/role-changes` and MCP call [`build_role_change_list`](../../server/app/hr_lifecycle/role_change_service.py): **four** business SELECTs regardless of row count (count + joined page with aggregate grant counts and a LEFT JOIN to the review packet, then one batched department-label lookup and one batched person-name lookup — 2026-09-05, was two), SQL-side literal search/pending filters, and a signed keyset cursor binding the fixed time snapshot, authenticated principal, and every filter. The packet join is on the unique `(person_id, effective_date)` key so it cannot multiply a row, and it is added to the PAGE query only — the COUNT query is left byte-identical so its result stays provably unchanged. The two label lookups are batched per page, never per row; [`test_role_change_list_move_2026_09_05.py`](../../server/tests/test_hr_lifecycle/test_role_change_list_move_2026_09_05.py) counts statements through `before_cursor_execute` and caps them at 8 (mutation-verified: moving resolution inside the serializer takes a 12-row page to 29). Ordering is `created_at DESC, id DESC`; concurrent inserts after page 1 appear only on a fresh traversal, never by shifting later pages. Migration 447 upgrades the ordering timestamp to MySQL/TiDB `DATETIME(6)`, matching the application snapshot precision so a later commit in the same wall-clock second cannot enter the old traversal. Cursors expire after one hour so retaining one cannot extend the endpoint beyond its rolling 180-day read boundary. The database ordering timestamp is selected as a separate scalar so stored precision, not a potentially fresher ORM identity-map value, enters the cursor. Both transports use `require_admin_read` semantics (global_admin / auditor / management; ordinary employees denied); changing identity, filters, page size, refreshing, expiry, or tampering requires a fresh cursor.
>
> **Finance mutations commit their audit evidence atomically** (2026-08-02; 0 migration / 0 cron / 0 RBAC / 0 MCP). A finance route must never commit the business mutation and then attempt `AuditService.log`: an audit persistence failure would leave a durable, unaudited monetary/configuration change while returning 500 and inviting a retry. Budget CRUD, fiscal-year settings, cost-centre reconciliation, governance-finding acknowledgement, and NetSuite GL sync/mapping now flush business state, write the success audit in the same transaction, and commit once. If the audit write fails, the request transaction rolls both back. The AST contract automatically discovers every finance POST/PUT/PATCH/DELETE handler across all router modules, requires its success audit, and orders every explicit commit after that audit; the contract and database failure-injection proof live in [`test_mutation_audit_atomicity_2026_08_02.py`](../../server/tests/test_finance/test_mutation_audit_atomicity_2026_08_02.py).
>
> **Cloud-cost ingestion: two hard rules every driver/provider must obey** (2026-07-31; 0 migration / 0 cron / 0 RBAC / 0 MCP). **(1) The amount and its `currency` label must agree.** The read side (`CloudCostUsdConverter`, the billing overview, the alert gates) converts by that label and ONLY by that label, so a pre-converted amount carrying a native label is divided twice — the Round-666 Aliyun bug (~7×) and its intl twin (~51× with a 0.14 constant against a ~7.2 rate). Store **native value + native label** and let the read side do the single live-FX conversion, or store USD and say `USD`; never mix. A column called `total_cost_usd` holding a native amount is fine and intentional (CN clouds do it) — the label is what makes it legible. A monthly rollup that can only return one `(total, currency)` pair must **fail loudly** on mixed currencies rather than sum two units and pick a label (`providers/gcp.py`). Any provider comment claiming the API returns a currency must be checked against the SDK model — Tencent's `BusinessSummaryOverviewItem` has none, and the comment that said otherwise read as a safety property the code did not have. **(2) Never drop a group on one measure's zero.** `cmdb_cost_facts` carries invoice / list / public side by side; a group whose invoice figure nets to zero can still hold real list spend (an RI-covered resource; a Usage row cancelled by a same-cell Credit). Skip only when EVERY measure is zero. The plain CE walks that have no list column keep a bare zero-skip and are right to — the rule is per-path, not global. Guards + the mutation-verified evidence: [`test_cost_measure_hygiene_2026_07_31.py`](../../server/tests/test_cmdb/test_cost_measure_hygiene_2026_07_31.py). Related invariants that also live in this family: every provider bill walk is **bounded** (`_MAX_BILL_PAGES` + a non-advancing-cursor guard — a degenerate BSS response returns quickly, so neither the httpx timeout nor the worker watchdog catches the loop), Azure Cost Management's `to` is **inclusive**, and every wide `GROUP BY` on `cmdb_cost_facts` is **month-chunked** (TiDB 8175; `top_resources` merges a per-chunk top slice rather than all groups because resource_id cardinality would move the memory problem into Python, and it logs when a chunk hits that cap).
>
> **Multi-recipient notification fan-out has ONE primitive — `notifications/fanout.py::enqueue_then_drain`** (2026-07-31; 0 migration / 0 cron / 0 RBAC / 0 MCP). Any fan-out to more than a handful of recipients MUST use it, on a session holding no locks. Why it is not optional: `notification_outbox.recipient_person_id` is an FK onto `identity_persons`, so each INSERT holds a shared record lock on the recipient's person row until commit — a loop that enqueues and sends inside one transaction therefore holds N person locks across N slow HTTP calls, and every concurrent `UPDATE identity_persons` (the SSO `last_login_at` write included) queues to 1205. That is the 2026-06 login brown-out ([[reference_outbox_fk_lock_login_outage]]); the 2026-07-30 audit found the same shape independently re-derived in five more places (#2126 #2128 #2140 #2143 #2145). The primitive is the extracted form of the already-correct `AssetInventoryService._dispatch_initial_notifications`: **phase 1** writes every recipient's PENDING outbox row and commits each (sub-millisecond lock windows, and a durable record of every intended message before the first send, so a crash is recoverable by `notification.outbox_retry`); **phase 2** delivers and commits per row (one failure never rolls back a sibling that already went out — the quieter half of the bug, which used to erase the record of *sent* messages and break "once a year" idempotency into a re-send). Phase 1 stamps `next_attempt_at = now + OUTBOX_RETRY_FENCE` so an hourly retry tick landing mid-drain cannot claim the same rows and double-send. **`enqueue` does NOT de-duplicate** — a docstring in the auditor domain claimed it did; callers needing idempotency must query the outbox themselves (and compare `related_resource_id` as a **string**: the column is a raw `CHAR(36)`, so a UUID object matches nothing). **The fence is renewed PER ROW and the row is re-read before each send** (#2240, 2026-07-31): a fence set once in phase 1 is outlived by any drain longer than 30 minutes (500 recipients x 2 channels at ~2s per SMTP hop already is, before any rate-limit backoff), and past that point the retry worker can claim a tail row while the drain still holds a pre-fetched ORM instance whose `delivered_channels` is stale-empty -- same card, twice. A row the worker already took to `delivered` is skipped; a `partial` one is not (deliver recomputes `todo` from the FRESH channel list). **Phase 2's commit lives INSIDE the try, with a rollback in the except** (#2241): `deliver` lets a 1213 deadlock out of its savepoint by design, so an unconditional commit after the handler raised PendingRollbackError straight out of a primitive whose contract is 'one bad address must not cost the other 499 recipients their message'. Callers that fan out on a scheduled job's pooled session for minutes must also register `weight="heavy"` (#2243) -- the registry's criterion is holding a pool connection by the minute, not CPU. Guarded by [`test_fanout_lock_hygiene_2026_07_31.py`](../../server/tests/test_notifications/test_fanout_lock_hygiene_2026_07_31.py), including an AST rule that no recipient loop calls `dispatch` directly.
>
> **Invoice-measure confidentiality is a CORE predicate + a declared withholding contract** (2026-07-31; 0 migration / 0 cron / 0 RBAC role change / 0 MCP). `can_view_invoice_measure` (finance / cloud_admin / global_admin) now lives in [`app/core/rbac.py`](../../server/app/core/rbac.py) — `app.domains.cmdb.cost_view` re-exports the same object, so there is exactly ONE definition and no domain has to import another to ask the question. Any surface that can return a post-discount (实付) cloud figure must gate on this predicate, **not** `can_view_finance` (which admits auditor + management): the pair "invoice total ÷ list total for the same scope" is the company discount rate, and both halves used to be reachable with one role. Where a surface cannot be restated in list terms (`/finance/spend/reconcile/internal`) the authorisation is narrowed instead; where it can, the pattern is **measure routing with a declaration** — `/finance/spend/*` drops `cost_type='cloud'` rows for out-of-circle readers via a `withhold_cloud` filter threaded through [`read_service`](../../server/app/domains/finance/read_service.py) (`_withhold` / `withheld_meta`), keeps hardware / software / AI, and names what it dropped in `withheld_cost_types` on every payload. Silent narrowing is not an option: a figure that changes meaning by reader with no marker is worse than one that says it is incomplete. The MCP twins call `_mcp_withhold_cloud` for the same reason (one ledger, two transports, one answer). Full surface inventory + the two judgement errors this class has produced: [cost-measure-surface-sweep.md](cost-measure-surface-sweep.md). Tests: [`test_spend_invoice_circle_2026_07_31.py`](../../server/tests/test_finance/test_spend_invoice_circle_2026_07_31.py) (table-driven over all eight reads + a predicate-level invariant). **Coverage is enumerated by TABLE, not by route prefix** (#2216, 2026-07-31 second correction): the same router's `/leadership-dashboard`, `/bu-costs`(+`/drill`), `/vendors/top`, `/vendor-360`, `/cost-centers/coverage`, `/budgets`(+`/me`) and the un-gated `/me/attributed-cost` + `/me/bu-costs` read the SAME ledger and were missed by the first pass; they now take the same flag (as do `budget_service.budget_vs_actual` and every MCP twin). Two of those leak by arithmetic rather than display and are pinned separately: a concentration ratio must withhold on both sides of the division, and a still-cloud-inclusive `ytd_actual` next to a cloud-free per-BU breakdown is one subtraction from the withheld figure. Siblings: [`test_spend_invoice_circle_siblings_2026_07_31.py`](../../server/tests/test_finance/test_spend_invoice_circle_siblings_2026_07_31.py).
>
> **一个 `$0` 必须自己说清是「真的 0」还是「我们没取到」—— `cost_coverage` 覆盖判定**（2026-09-07；0 迁移 / 0 cron / 0 RBAC / 0 新 MCP 工具）。[`app/domains/cmdb/cost_coverage.py`](../../server/app/domains/cmdb/cost_coverage.py) 是这个判断的唯一出处，`/cmdb/scopes/{id}/billing-detail`（桌面抽屉 + 订阅详情总览 + 移动端 + 同名 MCP 工具，四个面共用同一个响应）在 `coverage` 块里返回五态之一：`complete`（同云链路覆盖到的每一天都有行 ⇒ 这个 $0 是真的）、`gap`（本订阅停在 `covered_through`，而**同一个集成**已到 `expected_through`，且它是连续计费型 ⇒ 总额被低估约 `estimated_missing_usd`）、`pipeline_stale`（整条链路滞后，与单个订阅无关 —— **必须先判它**，否则一次云级故障会把它下面几十个订阅同时标红，噪声压倒信号）、`no_history`（从未计费）、`indeterminate`（停得早但该订阅本来断续计费 ⇒ 如实说判不了）。判定的关键信号是**同集成新鲜度**：它到了今天，就证明 BQ/API 那一跳是通的，于是同集成下某个订阅停在三周前只能是这个订阅自己的事。前端表述在 [`web/src/lib/costCoverage.ts`](../../web/src/lib/costCoverage.ts)（纯函数，三个渲染面共用；桌面与移动的组件库不同，所以共享的是函数不是组件）。**有意的边界**：「连续计费的订阅被真的关掉计费」与「导出表配漏了」在数据上形状完全相同（两条链路同源，没有第二个信源可复核），所以 `gap` 的文案只陈述观测事实并列出可能原因，不断言取数失败 —— 别把它改成断言式。由来：2026-08-14 `gcp-digital-bi` / `gcp-digital-infra` / `gcp-digital-bi-dev` 被挪到账单账号 `0125F7-277DBE-14DC04`，而 GCP 集成的 `billing_bq_table` 只列了旧账单账号的三张导出表，于是这三个订阅 8/13 之后零行入库、页面显示 `$0.00` 整 25 天（真实约 $5.1k/月折前），**同期同集成另外 34 个项目一路同步到 9/05**；25 天里没有任何告警（日环比要有「今天」才能比、`cost_chain_crosscheck` 是两条链路互查而这次两条一起断、闲置扫描在「30 天一行都没有」时刻意弃权）。测试：[`test_cost_coverage.py`](../../server/tests/test_cmdb/test_cost_coverage.py)（11 条表驱动 + 4 条真 DB 端点，含「同云健康订阅仍判 complete」这条反向格）、[`costCoverage.test.ts`](../../web/src/lib/costCoverage.test.ts)。
>
> **Client-supplied S3 keys are an authorisation decision, wherever they land** (2026-07-31; 0 migration / 0 RBAC / 0 MCP). A presigned GET bypasses Helix auth for its whole lifetime, so **any** code path that mints one is the last line of defence — not just `/uploads/presign-get`. `app/storage_router.py` exports `parse_object_key` (the same parser that route's ACL uses) + `FEED_PURPOSES`; the feed validates every incoming `s3_key` on write (purpose must be its own AND the key's stamped uploader must be the author) and **again before signing** on read, because rows written before the gate existed are still in the table. Any future surface that accepts a caller-supplied object key inherits this: validate the shape, validate ownership, and re-validate at the signing site. Tests: `test_feed_service.py::test_post_cannot_attach_*`.
>
> **Every mutating action-token consumer re-checks its subject — enforced by an AST contract** (2026-07-31). Action tokens are 14-day one-click links carrying only `sub`/`res`/`purpose`/`exp`; `decode_action_token` proves the signature, `enforce_action_token_subject` proves the person is still employed and not force-logged-out. [`test_action_token_subject_contract.py`](../../server/tests/test_core/test_action_token_subject_contract.py) walks `app/` and fails any handler registered on POST/PUT/PATCH/DELETE that decodes a token without calling the helper (render-only GETs are exempt — email clients prefetch them, so they must be side-effect-free anyway). Written because attendance's `/confirm` was the one consumer of five that skipped it, and that one writes the payroll basis.
>
> **Front-end out-of-order guard is a shared hook — `lib/useLatestRequest`** (2026-07-31; 0 migration / 0 RBAC / 0 MCP). Any page whose filters can fire overlapping requests MUST route its loader through `useLatestRequest` (or `useLatestRequestWithReset` when a close/cancel action has to invalidate an in-flight response, e.g. a drill-down drawer). `isCurrent()` is false once a LATER call has started **and** after unmount, so it doubles as the don't-setState-after-unmount check. Why it is a hook and not a convention: the pattern had been hand-written four times in four shapes (`requestSeqRef`, a `cancelled` boolean, `signalRef`, `reqId`) and had already been MISSED twice — `SoftwareCostRangeTab` had none (#2149) and `FinanceExplorerPage`'s own drill was the unguarded half of a guarded file (#2181). The failure it prevents is silent: the earlier, slower response lands last and paints money under the wrong filter label, with the spinner already off because the newer request's `finally` ran first. Tests: [`useLatestRequest.test.tsx`](../../web/src/lib/useLatestRequest.test.tsx).
>
> **Money surfaces that share a ledger must agree — the drill is the contract** (2026-07-31). Three rules the finance explorer and its siblings are held to, each of which broke once: (1) **a drill's rows must sum to the aggregate cell that opened it** — `pivot` / `timeseries` / `get_summary` exclude the hardware CASH lens because it overlaps depreciation, so `detail` and `export.xlsx` must too (#2155), and every dimension offered in `ALL_DIMS` must be a filter the drill can actually apply or the drawer silently widens (#2148); (2) **a filter list-ified on one endpoint must be list-ified on all of them**, frontend serializer included — `SpendFilters.vendor` is `string[]`, so a scalar param binds to nothing and the filter vanishes (#2183); (3) **an option-list resolver must honour the same filters as the surface it feeds**, or it offers choices that match zero rows (#2190). Regression tests: [`test_caliber_parity_2026_07_31.py`](../../server/tests/test_finance/test_caliber_parity_2026_07_31.py).
>
> **Auditor upstream-read contracts — adapter kind pinning + the `partial` scan channel** (2026-07-31; 0 migration / 0 cron / 0 RBAC / 0 MCP). Two invariants worth knowing before touching `app/domains/auditor/integration_adapters.py`. **(1) An adapter's `(KIND, PROVIDER)` must be a registered driver.** `_resolve_integration` matches `Integration.kind` EXACTLY (no alias), so a kind that no driver registers makes every read raise `IntegrationMissingError`, which the sync services swallow as INFO-level "not configured" — the Google / GitHub / Atlassian adapters shipped with `KIND = "software"` (registry only knows `software_provisioning`) and produced nothing from day one while `/coverage` reported them healthy. Use the `SOFTWARE_KIND` / `CLOUD_KIND` constants; [`test_adapter_kinds.py`](../../server/tests/test_auditor/test_adapter_kinds.py) pins every adapter pair to the registry AND pins the `/coverage` `_entry(...)` declarations to the same set, so the reader and the health indicator cannot describe different systems again. The same test file pins that the cloud drivers are constructed with **keyword** `integration_id=` / `config=` — all six Aliyun/Tencent/Azure reads used to pass a bare positional dict, i.e. TypeError before any API call. **(2) A read that covered only part of the fleet must say so.** Anything feeding an auto-resolve / mark-and-sweep consumer takes an optional `partial: list[str]` out-parameter: unresolvable account, no static credentials, a bucket whose ACL and policy probes both failed → append a reason and keep going; **`partial=None` means fail-loud (raise)**, which stays the default for callers that cannot express partiality. `PublicExposureScanService.scan` only adds a source to `sources_scanned` (the sole auto-resolve gate) when its partial list is empty, reports the rest under `partial_sources` in both the audit detail and the API response, and `_resolve_if_complete` re-checks before flipping anything. Empty-because-clean and empty-because-broken are different answers; a silent fallback between them closes real findings.
>
> **Finance ledger reads are range-capable — `enumerate_periods` + vendor multi-select picker** (2026-07-30; 0 migration / 0 cron / **+1 MCP** (`finance_spend_vendors`; hint 443→444) / 0 RBAC). The USD-normalised spend ledger (`/finance/spend/*`) was single-month everywhere except `timeseries`, and its `vendor` filter was a single **exact-match** value behind a free-text box (typing half a name silently matched nothing). Now `pivot` / `detail` / `export.xlsx` / the new vendor picker all take EITHER `period=YYYY-MM` (unchanged shape — every saved link, dashboard and MCP call keeps working) OR `start`+`end` (inclusive, ≤`RANGE_MAX_MONTHS=36`), resolved through one shared [`enumerate_periods`](../../server/app/domains/finance/read_service.py) so the five reads can never disagree on what a range means. Summing months is legitimate on THIS surface — unlike software billing — because every fact is USD-normalised at its own month's frozen FX rate; what a range CAN mix is closed and open months, so every range response names the open ones in **`provisional_periods`** (the range analogue of `get_summary`'s `is_provisional`). `vendor` became a **repeatable** query param → `IN` filter (deliberately not comma-separated: vendor names are free text and "Acme, Inc." would split into two non-matching names); a single string still works. New `GET /finance/spend/vendors` returns the vendors that actually spent in the scope, biggest first, honouring every filter EXCEPT `vendor` itself (an option list narrowed by its own selection collapses to what's already picked), capped at `VENDOR_PICKER_LIMIT=500` with an explicit `truncated` flag. `detail` rows now carry their own `period`, and the export gained a leading `period` column + `X-Export-Total-Matched/Returned/Truncated` headers (a wide range multiplies the grain, so the pre-existing 20k cap can now actually bite and must be visible) — and because the download opens in a new tab where those headers are unreachable, the button first probes `detail`'s `meta.total` and warns when the file will be truncated. `enumerate_periods` validates its own endpoints (`BAD_PERIOD`) rather than letting a malformed month walk to a confusing `RANGE_TOO_WIDE`. Frontend: [`FinanceExplorerPage`](../../web/src/pages/finance/FinanceExplorerPage.tsx) month picker → `RangePicker` (defaults to last month on both ends = old behaviour), vendor free-text → multi-select fed by the picker endpoint, plus range/provisional/truncated banners and a Month column on the drill drawer. MCP: `finance_spend_pivot` gained `start`/`end`/list-`vendor`, and `finance_spend_vendors` is its resolver — a tool that takes exact-match names is unusable without one (same lesson as `search_directory_people`). Tests: [`test_spend_range.py`](../../server/tests/test_finance/test_spend_range.py). **Two calibers, one question**: this is the FX-normalised cross-cost-type lens (and it books AI-category software under `cost_type='ai'`); the native-currency per-software-catalog lens is `/assets/software-billing/range-summary` — pick per question, never cross-check the totals.
>
> **Software cost across a MONTH RANGE — `billing_service.summarize_range` + 区间汇总 tab** (2026-07-30; 0 migration / 0 cron / **+1 MCP** (`software_cost_range_summary`; hint 442→443) / 0 RBAC). Every software-cost surface was single-month (`/cost-stats/*` live, `/software-billing/{period}/*` frozen); finance's standing question — "这几个软件这段时间**分别**一共花了多少" — had no answer but "open 12 monthly tabs and add by hand". [`summarize_range`](../../server/app/domains/asset/billing_service.py) aggregates per software × per currency across an inclusive `YYYY-MM` range (endpoints in either order, hard cap `RANGE_MAX_MONTHS=24`) and returns range totals + a per-month matrix + `distinct_users`. **Caliber is inherited, not re-invented**: a month with frozen `software_monthly_bills` rows is read from that snapshot (so the post-close price-edit immunity the snapshot exists for still holds inside a range), a month with none is live-previewed via `preview_current_or_draft`, and money stays in NATIVE currency bucketed per currency — never FX-converted, never summed across currencies. The frozen-vs-missing decision comes from a scope-UNFILTERED `DISTINCT period` probe, so "month never closed" is never confused with "closed but the filter matched nothing". A month **later than the current one is never computed** (`preview_current_or_draft` would happily bill every active seat for it — a fabricated projection labelled "live"; it also used to eat the live-preview budget the real current month needs) and is reported in `future_periods` / status `future`, with the picker refusing future months client-side. Live previews are capped at `RANGE_MAX_LIVE_PERIODS=3` (each is a full allocation scan) and the months past the cap are **named** in `excluded_periods` + logged + surfaced as a UI warning rather than silently shortening the total (practically unreachable while the `software_billing_auto_close` cron is healthy — it freezes last month on the 1st). HTTP: `GET /assets/software-billing/range-summary` + `…/range-summary/export.xlsx` (one row per software × currency, one column per month, each header carrying its close state), both with a `view=my|subordinates|department|all` param whose per-branch RBAC mirrors the matching per-period endpoint via `_resolve_range_summary_scope` — this surface aggregates the same rows over more months and must not open a wider door. Frontend: [`SoftwareCostRangeTab`](../../web/src/pages/asset/SoftwareCostRangeTab.tsx), a 区间汇总 tab on the existing 软件费用 page that reuses the shared 视角/软件/部门 filters (and says out loud that the row-level status/keyword/grant-date filters don't apply to a monthly aggregate). **The FX-normalised USD lens across cost types stays a different surface** (`/finance/spend/*`, which also excludes `category='ai'` software) — pick per question, don't cross-check the two numbers. The response also carries **`currency_drift`**: frozen months whose currency label disagrees with the software's CURRENT plan currency, evaluated only for software whose live plans share ONE currency (a genuinely multi-currency product has no single right answer, and a noisy banner is an ignored banner). It reports, never rewrites — correcting a frozen financial snapshot is an audited, reversible data fix (`scripts/fix_ai_seat_bill_currency_2026_07_30.py`), not something a read endpoint does behind the reader's back. It exists because migration 117's blanket `'CNY'` backfill (2026-05-05) mislabelled the USD-priced Claude / OPEN AI seats for 2026-04/05 and nothing surfaced it until a human noticed ¥ next to $ on this table. Tests: [`test_billing_range_summary.py`](../../server/tests/test_asset/test_billing_range_summary.py).
>
> **Offboarding auto-sweep of a leaver's AWS Identity Center assignments — root-cause fix for blank-name orphans** (2026-07-24; 0 migration / 0 cron / **+2 MCP** (`scan_leaver_cloud_assignments` / `sweep_leaver_cloud_assignments`; hint 433→435) / 0 RBAC). An Identity Center account assignment is `(PrincipalId, PermissionSet, Account)`; offboarding's SCIM deprovision DELETEs the directory *user*, but AWS does NOT cascade-delete the assignments — every one Helix never tracked as a grant row is left pointing at a dangling `PrincipalId` and renders as a **blank-name "User not found" row** on the account forever (the same class `orphan_cleanup` cleans up *after the fact*). New abstraction closes it at the source: driver contract [`CloudDriver.list_principal_assignments(email)`](../../server/app/integrations/driver_base.py) (+ `PrincipalAssignment` dataclass; base `NotImplementedError`), implemented on `AwsCloudDriver` via `sso-admin list_account_assignments_for_principal` (one call lists every account/permission-set a live principal holds; principal already deleted → `[]`; old botocore lacking the op degrades to the orphan-scan backstop). [`cmdb/leaver_sso_sweep.py`](../../server/app/domains/cmdb/leaver_sso_sweep.py)::`sweep_leaver_sso_assignments(person_id, dry_run)` deletes the leaver's assignments **while the principal is still alive** (before SCIM deletes it), writing a `status='revoked'` grant row with the **real `person_id`** + `revoke_reason='leaver_principal_swept'` + `cloud.grant.leaver_principal_swept` audit. **Ordering** is enforced by [`coordinators/leaver_cloud_sweep_coordinator.py`](../../server/app/core/coordinators/leaver_cloud_sweep_coordinator.py)::`sweep_then_offboard_sync()` — sweep first, THEN `enqueue_person_sync(reason='offboard')` (which triggers the SCIM delete); both the `run_tick` auto path and the leaver 立即推进 on-demand path route their post-commit fan-out through it. The sweep uses its own session, runs post-commit (never inside the advance savepoint → no long-txn lock across cloud I/O), and is best-effort (failure logs, never blocks the directory offboard). **Guardrails** (= "全帐号 · 只非生产"): only non-prod `CmdbCloudScope`-tracked accounts are swept (`ensure_prod_scope_writable`, same default-locked prod gate as `orphan_cleanup`); accounts with no Helix scope row are surfaced but never touched; assignments already tracked as an active grant are left to the standard `RES_CLOUD_ACCESS_GRANT` revoke. Idempotent. MCP twins gated by `can_manage_cloud`. Pre-existing blank-name orphans (principal already deleted) remain the province of `scan/cleanup_cloud_orphan_assignments`. Tests: [`test_leaver_sso_sweep.py`](../../server/tests/test_cmdb/test_leaver_sso_sweep.py) + [`test_aws_principal_assignments.py`](../../server/tests/test_integrations/test_aws_principal_assignments.py).
>
> **Onboarding provisioning lead is HR-configurable — `onboarding.provision_lead_hours`** (2026-07-23; 0 migration / 0 cron / 0 RBAC / 0 MCP). The "create Google + Feishu accounts & send Day-1 mails ~24h before the local hire date" lead (SF-agreed 2026-07-06) is no longer a code constant: [`working_hours.get_provision_lead_hours(session)`](../../server/app/core/working_hours.py) reads the `onboarding.provision_lead_hours` platform setting (int hours, band 0–168, malformed/out-of-band falls back to the 24h default; 60s in-process TTL cache + `set_provision_lead_hours_cache` on write so the writing process is immediate and the worker converges ≤60s). `is_future_hire(lead_hours=None)` now defaults to the last-resolved cached value; **every gate resolves per flow and passes explicitly**: the 4 hr_sync_coordinator onboarding hooks + `enqueue_identity_sync_for_hire`, the identity-sync sweep/preview/dry-run first-time-creation deferrals, the Google `account_provisioner` internal gate, and the hire-day cron main loop + account backfill — whose SQL prefilter upper bound is now `provision_lead_window_days(lead)` = ceil((lead+14h)/24) days (== the historical `[target−1, target+2]` superset at 24h; without this a >48h lead would pass the gate but never enter the scan window). Endpoints: `GET/PUT /api/v1/provisioning/onboarding-lead-settings` — read `require_lifecycle_read`, write **`require_hr_admin` (global_admin + hr)**, write lands in platform_settings via `PlatformSettingService` (audited). UI: onboarding-strategy page Layer-1 建号 column shows the live lead as the timing tag + an 调整 modal (InputNumber 0–168 + 8/24/48h presets) for hr/global_admin. Tests: [`test_onboarding_lead_settings_2026_07_23.py`](../../server/tests/test_provisioning/test_onboarding_lead_settings_2026_07_23.py) (8).
>
> **HR single-person HRIS refresh — SF → Helix → Feishu on demand** (2026-07-22; 0 migration / 0 cron / **+1 MCP** `hr_refresh_person_from_hris` → hint 428 / 0 new RBAC). Closes the ~4h latency between an HR edit in SuccessFactors and it showing up in Feishu (previously: daily incremental sync + per-target cron, two serial hops). New cross-domain coordinator [`app/core/coordinators/hris_person_refresh_coordinator.py`](../../server/app/core/coordinators/hris_person_refresh_coordinator.py) — `refresh_person_from_hris(person_id)`: (1) routes to the person's owning SF instance (`hris_instance_id`) and force-fetches the one `userId` via `get_users` (no `lastModifiedDateTime` window); (2) applies it through the SAME staged-batch path the incremental sync uses — new [`SuccessFactorsLifecycleService.preview_single_person_sync`](../../server/app/domains/hr/service.py) (`sync_mode='single_person'`) + the newly-extracted shared `_stage_user_lifecycle_event` (the incremental per-user staging body, now called by BOTH paths so the active/inactive/leaver-classifier decision can't drift) → `confirm_batch` (dept/manager/title resolution, role-change coordinator, audit all identical to a scheduled sync); (3) pushes the `feishu_contact` target **synchronously** with the masked request/response round-trip returned to the caller; (4) fans out to every other event-aware target (Google / 汇联易 / NetSuite …) in the background via the existing `enqueue_person_sync` primitive. **Safety gate (non-bypassable)**: a single-person refresh only refreshes an ACTIVE employee's attributes — if the SF pull's staged action is a lifecycle transition (`create`/`reactivate`/`deactivate` = new hire / rehire / termination) the coordinator REFUSES it, cancels the batch, and returns `blocked=true` routing HR to the onboarding/offboarding flow (an HR "sync" click must never silently start an offboarding or mint an account); `update`/`skip` (pure role-change goes through `skip` → refresh-only replay) proceed. Leaver-after-sync → no downstream push (offboarding path owns deprovisioning). **Downstream broadcast uses `force_in_scope=False`** (filter-respecting), unlike the IT-admin per-target "push this exact pair" button — so the fan-out never force-creates the person into a target they're out of scope for. Entry: HR-view 「HRIS 同步」 page top card [`SinglePersonSyncCard.tsx`](../../web/src/pages/hr/SinglePersonSyncCard.tsx) (person picker → 4-step timeline: SF pull / Helix field diff / Feishu round-trip / downstream); gated `require_hr_admin` (global_admin + hr). HTTP `POST /api/v1/hr/people/{person_id}/hris-refresh`; MCP `hr_refresh_person_from_hris`. **Change diff (2026-08-07)**: the before/after diff is generic over `_SNAPSHOT_FIELDS` in the coordinator — a single ordered list of EVERY HRIS-owned `IdentityPerson` column (45: names, employment, location, dates, contact, 外派, `github_id`, …) plus the two derived, name-resolved fields (`manager_name`, `department`). It replaced a hand-written 6-field snapshot that reported 「无字段变更」 for everything else. Two guards keep it honest: an **import-time** check that every listed key is a real `IdentityPerson.__table__` column (a rename fails the smoke import, not the click), and a **source-scanning test** asserting every `person.<col> = …` write in `hr/service.py` is either in the list or in an explicit exclusion set (bookkeeping columns, the leave-carryover job's, and `manager_person_id` which shows resolved). **Add a synced column → add it to `_SNAPSHOT_FIELDS` in the same PR.** Phone-shaped fields are masked by field name (the diff rows key on before/after, so the generic key-regex masker can't reach them). Tests: [`test_single_person_hris_refresh_2026_07_22.py`](../../server/tests/test_hr/test_single_person_hris_refresh_2026_07_22.py) (9).
>
> **Cloud efficiency / cost-optimization — runtime utilization joins inventory + cost (PR1: backend engine)** (2026-07-20; migration **419** = 2 tables / **+2 cron types** `cmdb.server_metrics_sync` + `cmdb.efficiency_compute` (per metrics-capable cloud integration) / 0 new RBAC role / 0 MCP this PR). Fills the missing third leg of cloud FinOps: Helix synced **inventory** (`cmdb_servers`: vcpu/mem/disk) and **cost** (`cmdb_cost_facts`) but had **zero runtime utilization**, so "which boxes are underused?" was unanswerable. New capability `ProviderCapability.METRICS` + base `list_server_metrics(targets)` ([providers/base.py](../../server/app/domains/cmdb/providers/base.py)); **four cloud adapters reuse existing connector auth (no new SDK deps)** — AWS `CloudWatch GetMetricData` (boto3), Aliyun `CloudMonitor DescribeMetricList` (httpx-signed; intl subclass fans out per RD sub-account via AssumeRole), Azure Monitor (`microsoft.insights/metrics` per-VM, ARM id from `raw["id"]` since Azure `instance_id` is the vmId GUID), GCP `Cloud Monitoring timeSeries.list` (only projects owning target instances, not all 474). **CPU + network are agentless on all four → the idle signal; memory/disk need an in-guest agent → best-effort, NULL when absent (NULL≠0, never read as idle)**. Storage (migration 419, single TiDB): `cmdb_server_metrics_daily` (per-server×day rollup, 90-day retention) + `cmdb_server_efficiency` (one profile row/server). Rule engine ([efficiency.py](../../server/app/domains/cmdb/efficiency.py)): rolling window (default 14d) → verdict `idle`/`oversized`/`busy`/`healthy`/`insufficient_data`/`no_metrics` + advisory recommendation; **memory is a veto/strengthen signal only** (a low-CPU box with live memory = memory-bound, not idle); thresholds default in code, overridable via `cloud_efficiency.rules` platform setting (no deploy). Cost attribution: one aggregation of `cmdb_cost_facts` → `{resource_id: USD}` matched to instances (exact/ARN-suffix; billing resource_id is NOT FK-linked to `instance_id`) → monthly cost + estimated savings (stop = full, downsize = configurable ratio); unmatched → utilization still shows, savings NULL. Scheduling: `cmdb.server_metrics_sync` (daily 03:xx pull) + `cmdb.efficiency_compute` (daily 07:xx recompute, after cost_facts 04 / billing_daily 06), both `weight=heavy`; auto-provisioned per integration whose provider declares METRICS (→ only AWS/Aliyun/Azure/GCP). **METRICS is excluded from the resource_sync capability set** (`_SYNC_CAPABILITY_VALUES`) so it never schedules an inventory-sync row. Read-only API `/api/v1/cmdb/efficiency` ([efficiency_router.py](../../server/app/domains/cmdb/efficiency_router.py)): `summary` / `instances` (filter+sort+paginate) / `instances/{id}` (profile + daily series) / `rules` GET+PUT — admin-tier reads gated by `can_read_cloud`, employees self-scoped to owned (self+subordinates) servers, threshold edits `can_manage_cloud`. **This feature performs NO cloud writes — verdicts are advisory; execution stays in ServerLifecycleActions.** Tests: [`test_efficiency_engine.py`](../../server/tests/test_cmdb/test_efficiency_engine.py) + [`test_efficiency_api.py`](../../server/tests/test_cmdb/test_efficiency_api.py). **PR2 shipped** (frontend): insights dashboard `/admin/cloud/efficiency` (`CloudEfficiencyPage`: KPI StatCards + verdict Donut + savings-by-cloud BarTrend + filterable/sortable/paginated instances table + `can_manage_cloud` threshold editor) + server-detail `ServerEfficiencyCard` (verdict/reco tags + KPIs + utilization LineTrend, calm empty state when no profile) + self-service `/my/cloud/efficiency` (owner-scoped) + zh/en/ja. **PR3 shipped** (migration **420** = seed 1 cron〔**disabled/opt-in**〕 / **+2 MCP → hint 425**): Bailian AI narrative ([efficiency_ai.py](../../server/app/domains/cmdb/efficiency_ai.py)) for top-N actionable instances, cached on `ai_narrative`/`ai_narrative_at`, regenerated only when missing/>7d-stale, LLM-not-configured/error degrades silently, runs as a best-effort tail of `cmdb.efficiency_compute` + on-demand `POST /instances/{id}/narrate`; org/department savings rollup (`savings_by_department` walks `IdentityPerson.department_chain` subtree, `GET /by-department` + dashboard Top-10 HBar); MCP read tools `get_cloud_efficiency_summary` + `list_cloud_efficiency_instances` (owner-scope mirror, hint 423→**425**); IT-only weekly digest `cmdb.efficiency_digest` (one Feishu card to `list_admin_recipients` = global_admin/it_admin only, **never employees/managers**, silent on zero-savings weeks, **seeded `enabled=0`** per the notification IT-only red line — admin opts in). **The 3-PR cloud efficiency theme is fully shipped.** **PR4 shipped** (2026-07-20, migration **422** = 1 table / **+1 MCP → hint 426** / 0 cron / 0 RBAC): **subscription-embedded recommendations + owner participation**. Ends the fragmentation where efficiency lived in its own page tree, disconnected from 云订阅, as a read-only dead-end. Server→subscription attribution **reuses the existing cost-facts path** (`cmdb_cost_facts.resource_id == instance_id` within the integration → `scope_id`; servers have no direct scope FK, so no sync-driver change) — `efficiency_service` threads an optional `scope` filter via a correlated EXISTS. New endpoints `GET /cmdb/efficiency/scopes/{id}/summary|instances` ([efficiency_router.py](../../server/app/domains/cmdb/efficiency_router.py)): admin-tier `can_read_cloud` sees all attributed servers, the **subscription owner** sees all of their subscription's, everyone else is owner-scoped; bad/absent scope → 404 (non-oracle). Frontend adds a 「成本优化」 tab to both `CloudScopeDetailPage` (admin) and `MyCloudScopeDetailPage` (owner) via new `ScopeEfficiencyPane` (待处理/idle/oversized/savings KPIs + per-server list), the standalone efficiency page is kept and cross-linked. **Owner triage (non-destructive)**: new table `cmdb_server_efficiency_triage` (separate from `cmdb_server_efficiency` so it survives the daily recompute) records the owner's keep(reason-required)/acknowledge/snooze decision via `POST /cmdb/efficiency/instances/{id}/triage` (owner-self or admin-tier; audit `cmdb.efficiency.triage`). **Read-time freshness**: a triage counts only while `verdict_at_decision` still matches the current verdict — a recompute that drifts the verdict (or an elapsed snooze) collapses it back to `open` (`_effective_open_clause`/`_effective_triage`), so a "keep this idle box" decision never silently suppresses a materially different future finding; the "待处理" count is open-only. **Still performs NO cloud writes** — triage records intent only; stop/decommission/downsize stay in ServerLifecycleActions. MCP `triage_cloud_efficiency_recommendation` (mutates; hint 425→**426**). Tests: [`test_efficiency_scope_triage.py`](../../server/tests/test_cmdb/test_efficiency_scope_triage.py) (7: scope rollup isolation, actionable_only, 404s, keep-requires-reason, pending-clears, verdict-drift→open, non-owner 404). **Follow-up — server owner inherits scope owner (2026-07-20, 0 migration/cron/RBAC/MCP)**: a `cmdb_servers` row with no explicit `owner_person_id` now DISPLAYS the owner of its cloud subscription, **live-resolved and never written back** (`resolve_inherited_server_owners` in [service.py](../../server/app/domains/cmdb/service.py); mirrors `cert_owner_resolver`'s "always current, nothing to backfill" philosophy). Attribution links: GCP `raw_data._project_id`→`scope.external_id`, else `cmdb_cost_facts` `resource_id==instance_id`→`scope_id`. Because nothing is persisted, the semiannual owner-confirmation cron (keys on stored owner) is untouched → **no notification wave**. Server list/detail expose `owner_inherited` + `effective_owner_*` + `inherited_from_scope_*`; frontend renders a 「继承自订阅」 tag. Explicit assignment (`PATCH /cmdb/servers/{id}`) still wins and sticks; scope of change is display-only (execution / `/my` visibility unchanged). Servers with no cost-facts attribution (stopped AWS boxes, non-cost tenants) stay blank. Tests: [`test_server_inherited_owner.py`](../../server/tests/test_cmdb/test_server_inherited_owner.py) (5). **Follow-up — realized-savings outcome layer (2026-07-20, migration 423, +1 MCP → hint 427)**: closes estimate→realized. Audit post-log hook [`savings_tracker`](../../server/app/domains/cmdb/savings_tracker.py) (registered in main+worker) writes a `cmdb_efficiency_savings_events` row when a server that WAS flagged idle/oversized (live verdict or triage `verdict_at_decision` — the conservative "only flagged machines count" gate) is destroyed/stopped, snapshotting the pre-action monthly cost baseline. [`savings_service`](../../server/app/domains/cmdb/savings_service.py) computes REALIZED at read time = baseline − actual post-action cost from `cmdb_cost_facts` (destroy→full; restart self-corrects to ~0; no cron). Leadership endpoint `GET /cmdb/efficiency/savings-outcomes` (`can_read_cloud`) + MCP `get_cloud_savings_outcomes` (hint 426→427) return realized monthly/annualized, by-method, by-department, monthly trend, and the still-open estimated opportunity; surfaced in `CfoOverviewPage` (`/admin/finance/leadership`, management-accessible). Also fixed: the `/admin/cloud/efficiency` frontend RoleRoute omitted management+auditor despite backend `can_read_cloud` admitting them. Realized accrues from launch (no historical backfill — pre-tracker actions were never flagged/snapshotted). Tests: [`test_cloud_savings_outcomes.py`](../../server/tests/test_cmdb/test_cloud_savings_outcomes.py) (3). **Follow-up — AWS Organizations cross-account fan-out (2026-07-21, 0 migration/cron/RBAC/MCP, default OFF)**: the AWS estate is an Organization — `aws-primary`'s credential lands in the **management/payer account** (`317766989874`, self 0 EC2); all compute lives in **90 member accounts** the management key can `ListAccounts` but cannot `sts:AssumeRole` into (verified: AccessDenied). New opt-in `org_fanout` on the AWS driver ([providers/aws.py](../../server/app/domains/cmdb/providers/aws.py) + [drivers/aws_cloud.py](../../server/app/integrations/drivers/aws_cloud.py) config_schema `org_fanout`/`org_member_role`): `list_servers`/`list_server_metrics` enumerate every active member account and STS-assume `org_member_role` (default `OrganizationAccountAccessRole`) into each — concurrent (sem 8), **per-account failure isolation** (one denied account → `partial_failures`, rest proceed; only `ListAccounts` failure → `ProviderAuthError`). Each server's `raw` is stamped `_aws_account_id` → persisted `raw_data` → `ServerMetricTarget.raw` so metrics re-assume the correct account for CloudWatch. **Gated OFF at ship (zero behaviour change); flips on only after the AWS org grants the management user `sts:AssumeRole`** (see [cloud-lifecycle-ops.md](../ops/cloud-lifecycle-ops.md): quick path = assume built-in `OrganizationAccountAccessRole`; least-privilege = a `HelixCloudReadOnly` StackSet). New accounts auto-covered (dynamic `ListAccounts`). Tests: [`test_aws_org_fanout.py`](../../server/tests/test_cmdb/test_aws_org_fanout.py) (20). **Follow-up — PRIMITIVE unified self-service server visibility (2026-07-23, `app/domains/cmdb/visibility.py`, 0 migration/cron/RBAC/MCP-count)**: one shared answer to "which servers may this non-admin see" = server-level `owner_person_id` (self+reports) **∪ every server attributed to a cloud subscription they (or reports) own** (cost-fact `scope_id` link any-cloud + GCP `raw_data._project_id`→scope `external_id` — same links as `resolve_inherited_server_owners`). `OwnerVisibility` / `resolve_owner_visibility` / `visibility_clause` (SQL) / `is_server_visible`. Applied uniformly to the efficiency surface (summary/instances/detail/by-department/triage-write; the old server-owner-only scope made the employee page ~always empty), the servers list/export/filter-options/overview-tops/detail, `/analytics/my` server counts, and the 4 MCP mirrors — a machine reachable from any list never 404s on its detail. Also fixed MCP `triage_cloud_efficiency_recommendation` using the READ scope (auditor/finance could triage org-wide; now mirrors the HTTP write scope). Efficiency rows/detail now carry `owner_inherited`/`inherited_from_scope_*` for the 归属 tag; self-view empty state explains the visibility contract + metrics coverage. **Use `visibility.py` for any future self-scoped cmdb-server surface; do not re-derive owner-only scopes.** Tests: [`test_visibility_scope_attribution.py`](../../server/tests/test_cmdb/test_visibility_scope_attribution.py) (5). **Follow-up — console-side realized-savings detection (2026-07-23, migration 425, 0 cron/RBAC/MCP-count)**: the realized-savings ledger previously credited only Helix-executed destroy/power_off (audit hook). New [`savings_detector.py`](../../server/app/domains/cmdb/savings_detector.py) runs at the START of `cmdb.efficiency_compute` (before the recompute overwrites yesterday's verdicts/spec snapshots): a flagged (idle/oversized, live verdict or triage `verdict_at_decision`) server that disappeared from sync → `destroy`, turned stopped → `stop`, or shrank its spec (vcpu ↓, or equal-vcpu memory ↓; snapshot columns `instance_type`/`vcpu`/`memory_mb` on `cmdb_server_efficiency` written at compute time) → new **`downsize`** method — event stamped `source='console'` with from/to `evidence` (hook events stamp `source='helix'`). One event per server (unique key) so nothing double-counts; baseline prefers the flagged-time `monthly_cost_usd` snapshot; realized stays read-time-derived (self-corrects on restart/re-upsize). `savings_service` adds a `by_source` split; CFO page shows the downsize method + helix/console source line. Detection is per-server best-effort — never fails the compute job. Tests: [`test_savings_console_detector.py`](../../server/tests/test_cmdb/test_savings_console_detector.py) (6). **Follow-up — owner-facing weekly digest (2026-07-23, migration 426 = seed 1 cron `cmdb.efficiency_owner_digest`, DEFAULT OFF via platform setting)**: [`owner_digest.py`](../../server/app/domains/cmdb/owner_digest.py) aggregates PENDING actionable recommendations per effective owner (server owner ∪ subscription 业主, the shared visibility chain) into ONE Feishu card + in-app notification per owner per week (never per-machine; keep/acknowledge/snooze suppress via `_effective_open_clause`; inactive owners dropped; per-person weekly outbox dedup on `related_resource_type='cloud_efficiency_owner_digest'`). The cron row (Mon 02:15 UTC, after the 07:xx CST recompute) stays ENABLED — the single real gate is `platform_settings['cloud_efficiency.owner_digest']` (default off, optional `canary_person_ids` allowlist), controlled from the efficiency page's 触达设置 modal (`GET/PUT /cmdb/efficiency/owner-digest/config`, write=can_manage_cloud+audit; `GET /owner-digest/preview` = zero-send dry-run showing the exact blast radius). Complies with the notifications IT-only red line: ships off, admin previews then opts in. Tests: [`test_efficiency_owner_digest.py`](../../server/tests/test_cmdb/test_efficiency_owner_digest.py) (6). **Follow-up — environment filter (2026-07-23, 0 migration)**: config gains `exclude_environments` (vocab-validated dev/staging/prod) — a machine inherits its subscription's `environment` (resolved for ALL digest candidates, owned servers included, via the new shared **`resolve_server_scope_ids`** in [service.py](../../server/app/domains/cmdb/service.py) — the project-id + cost-facts links extracted from `resolve_inherited_server_owners`, reuse it for any future scope-attribute inheritance); machines in an excluded environment drop out of the digest, unattributable machines are unaffected (unknown≠excluded). Preview accepts a live `?exclude_environments=` override so the 触达设置 modal shows the not-yet-saved blast radius; tests now 9.
>
> **「放松一下」Security Arcade — quiz points buy server-judged game rounds (PR1: foundation + 答题竞速)** (2026-07-18; migration **411** = 3 tables / 0 cron / 0 RBAC / **+1 MCP** `get_my_arcade_overview` → hint 417). New domain [`app/domains/arcade`](../../server/app/domains/arcade) (prefix `arcade_`): 安全每日一题 becomes the PRODUCTION side of a point economy (answered-correct +10 / wrong +2 / ≥7-day-streak correct +15, lazily reconciled on read via a per-wallet watermark + `UNIQUE(person,reason,ref)` ledger — zero changes inside the security_quiz domain; one-time capped-100 historical backfill on first touch), and games are the SPEND side (10 pts/round entry + **hard 3 rounds/day cap** — anti-addiction is the economy, not a policy doc). **Wallet row lock (`arcade_wallets` SELECT…FOR UPDATE) is the per-person serialization point** for all balance math + the daily cap (never locks identity_persons); `arcade_game_sessions` stores the FULL secret round state (questions+answers) server-side, players get a public projection and submit CHOICES — **scores are computed exclusively server-side**, so arcade leaderboards are forgery-proof by construction. Engine contract `GameSpec.build/grade` ([catalog.py](../../server/app/domains/arcade/catalog.py)); adding a game = one engine module + one spec + i18n. Round lifecycle: one-live-round invariant (409), 15-min lazy TTL expiry (fee consumed — communicated pregame), idempotent submit, 10s submit grace. **Games (PR1–2)**: **知识竞速 (quiz_rush)** — 60s trivia from a DEDICATED trivia bank (`arcade_trivia_questions`, migration 412: 常识/历史/科学/地理/中华文化/生活, seeded + AI-generatable + admin CRUD), NOT the security quiz (the daily quiz earns, this spends — kept distinct per the user); a round is played in a category `variant` (`mixed` + 6 categories) persisted on the session for PR3's per-category leaderboards. **弱密码狙击** (weak-vs-strong password judgment, code-defined bank + per-item "why weak" reveal), **舒尔特方格** (1→25 attention grid), **记忆翻牌** (4×4 pair match), **色词反应** (Stroop). Every engine is `GameSpec.build(db, person, variant) / grade(payload, submission, elapsed)` — add a game = one engine module + one spec; all graded server-side. HTTP `/api/v1/arcade/*` player + `/api/v1/arcade/admin/trivia/*` (global_admin: overview/list/create/status/generate). Frontend: desktop top-level menu **放松一下 (LAST employee menu item, user's call)** → `/me/arcade` (三区: 知识竞技/安全小游戏/轻松一刻) + one page per game; mobile `/m/arcade/*` + `/m/security-quiz`; shared round state machine [`features/arcade/useArcadeRound`](../../web/src/features/arcade/useArcadeRound.ts) + platform-neutral board components + shared GamePageFrame/MArcadeRoundShell chrome + QuizRushPregame category picker. **Lazy expiry uses each game's `time_limit+grace`** (not only the 15-min TTL) and the client only adopts a live round with `remaining_ms>500` — fixes the adopt→instant-timeup→reject→reload loop found in the PR1 preview walk. Zero push, pull-only. **Competition layer (PR3)**: three live leaderboards from `arcade_game_sessions` ([leaderboard.py](../../server/app/domains/arcade/leaderboard.py)) — **知识榜/科举** (top by lifetime correct trivia, SUM `correct_count` — migration 413 denormalizes it), **单游戏榜** (best score per game), **品类榜** (per-trivia-category best — "who knows history best?"), each with caller rank, departed excluded. **科举 title ladder** ([titles.py](../../server/app/domains/arcade/titles.py), by lifetime correct): 白丁→学童→秀才→举人→贡士→进士→探花→榜眼→状元. **Per-category champion titles** (历史达人…) = live rank-1 of each category board. Home shows a 称号 strip (title chip + champion chips); leaderboard panel (desktop modal + `/m/arcade/leaderboard`). MCP `get_arcade_leaderboard` (hint 418). **Content games (PR4)**: **钓鱼邮件鉴别 (phishing_spot)** — judge rendered emails phishing/legit (bank `arcade_phishing_samples`, migration 414, seeded 8 w/ fabricated domains + AI-generatable) w/ per-email flag reveal; **安全找茬 (spot_risk)** — find every hazard in an office scene (bank `arcade_spot_scenes`, element list w/ hazard+reason), scored by precision/recall. Truth server-side; admin CRUD + AI gen ([content_service.py](../../server/app/domains/arcade/content_service.py), global_admin). **Honors + ops (PR5)**: 4 arcade badges wired into the gamification wall (first play / 20 plays / 进士 200-correct / 状元 680-correct; `_compute_facts` gains `arcade_plays` + `knowledge_correct`); **participation dashboard** ([participation.py](../../server/app/domains/arcade/participation.py)) — adoption rate, per-game, per-department, points economy (admin+management); **ops admin page** `/admin/arcade` (participation + content management: trivia/phishing/scene overview + AI-generate). MCP `get_arcade_participation` (hint 419). **The 「放松一下」5-PR theme is fully shipped** — 7 games (知识竞速 + 弱密码狙击/钓鱼鉴别/安全找茬 + 舒尔特/翻牌/色词) + points economy + leaderboards/科举 titles/champions + content banks/AI/admin + badges/participation. Tests [`tests/test_arcade/`](../../server/tests/test_arcade). **Prod repair (2026-07-19, migration 415)**: migration 412 was found stamped-but-unapplied on prod (`alembic_version` past it, yet `arcade_trivia_questions` + `arcade_game_sessions.variant` absent → arcade home 500 on the missing column). Self-healing migration [`415_arcade_trivia_variant_repair.py`](../../server/alembic/versions/415_arcade_trivia_variant_repair.py) re-asserts 412's schema effects under existence guards (no-op on healthy envs, repairs skipped ones). **Lesson**: a schema-bearing deploy must be verified against the actual columns/tables, not just `alembic current`. **Tunable economy + Schulte variants (2026-07-19, 0 migration, +2 MCP → hint 421)**: the point economy (points_correct/wrong/streak + streak_threshold + backfill_cap + entry_cost + daily_play_cap) is now admin-tunable at runtime via the `arcade_economy` platform setting ([economy.py](../../server/app/domains/arcade/economy.py) `EconomyConfig`/`get_economy_config`/`set_economy_config`, bounds-validated, fails open to defaults) — admin `GET/PUT /arcade/admin/economy` + MCP `get_arcade_economy`/`set_arcade_economy` (global_admin) + an admin 经济参数 tab; no deploy needed to retune. 舒尔特方格 gained variants (classic 5×5 / grid6 6×6 / grid7 7×7 / rotating / rings) via the existing variant mechanism (score scales `72×cells`, classic base unchanged). The one-live-round 409 (`ARCADE_ROUND_IN_PROGRESS`) now carries the active game key in `errors[0].field` so the client jumps the player straight to the unfinished round; the home shows a resume banner (overview.active_session) + a prominent leaderboard CTA. **Bank expansion ~10× (2026-07-19, migration 417, data-only)**: the curated question banks grew — 知识竞速 `TRIVIA_SEED` 60→**781** (6 categories) and 安全每日一题 `SECQUIZ_SEED` 39→**309** (9 security categories, every correct answer real best practice). Content is now per-category data modules under [`arcade/seedbank/`](../../server/app/domains/arcade/seedbank) + [`security_quiz/seedbank/`](../../server/app/domains/security_quiz/seedbank), aggregated + deduped-by-prompt by `trivia_seed.py` / `secquiz_seed.py`. Migration **417** idempotently inserts any prompt not already present (prod's existing rows skipped, safe to re-run) into `arcade_trivia_questions` + `secquiz_questions`. **UX pass (2026-07-19, 0 migration, +2 MCP → hint 423)**: per-round `entry_cost` default 10→**3** (`economy.py`; live value still `arcade_economy`-overridable — a prod admin who tuned it keeps their value). 记忆翻牌 gained difficulty variants (轻松 4×3 / 经典 4×4 / 进阶 5×4 / 烧脑 6×6) via the variant mechanism — `pairs`/`cols` server-issued, `grade` reads persisted `pairs`, completion bonus `25×pairs` (classic unchanged), symbol bank →20, single limit 150s; frontend `MemoryBoard` enlarges the tile symbol + sizes the grid from `cols`, shared `MemoryPregame` difficulty picker (desktop + mobile). Leaderboard redesigned into a shared, platform-neutral **podium view** ([`LeaderboardView.tsx`](../../web/src/pages/arcade/LeaderboardView.tsx) + CSS module) — top-3 medals/pedestals/bobbing crown + animated ranked rows — reused by desktop `LeaderboardPanel` and mobile `MArcadeLeaderboardPage`. **Per-round log/audit**: `service.get_history` (self — finished rounds only, active excluded) + `admin_list_sessions` (cross-person audit w/ player identity + primary dept, filter person/game/status, paginated); HTTP `GET /arcade/sessions` (self) + `GET /arcade/admin/sessions` (admin+management); MCP `get_my_arcade_history` (self) + `list_arcade_sessions` (admin/management). Frontend: home 我的战绩 entry (desktop modal / mobile inline, shared `HistoryList`) + admin `/admin/arcade` 对局审计 tab. Arcade home compacted (slimmer hero/leaderboard-CTA/margins) so the game wall clears the fold.
>
> **Leaver revocation is now a read+write zero-trust loop — `leaver_revocation_reconcile` actively closes what the sweep can't** (2026-07-18; migration **410** = seed 1 cron / **+1 cron** `identity_sync.leaver_revocation_reconcile` (daily 03:25 UTC) / 0 MCP / 0 RBAC). `leaver_assurance` (below) only ALARMS; once `vendor_snapshot_refresh` made all 8 targets visible to Signal B it surfaced a standing backlog nothing closed (~101 prod findings: alicloud-sso 94 + huilianyi 3 + aws-ai 2 + aws-sso 1 + navan 1; Signal A=0). Two root causes: **orphans** (accounts predating Helix managing a target — alicloud-sso onboarded 2026-07-09, 94 leavers left before it existed, no state link, no offboard event ever reached it; 54 of them are `2059` push-enabled, unrelated to 2061) and **2061 leavers** (candidate-loader excludes them, sweep never revokes). Fix (write sibling of assurance, [`scheduler_handler.py`](../../server/app/domains/identity_sync/scheduler_handler.py) + [`service.py`](../../server/app/domains/identity_sync/service.py)): new `IdentitySyncService.active_leaver_vendor_records` is the **shared Signal-B population** both assurance (read) and the reconcile (write) use — so the reconcile provably drives assurance to zero; `revoke_leavers_on_target` deactivates/deletes by the vendor's OWN `external_key` (**no prior Helix link needed** → reaches orphans), **bypasses `_load_candidates`** (→ reaches 2061 leavers — per DECISIONS 2026-07-18, 2061 suppresses provisioning of an ACTIVE employee, it does NOT exempt a departed one's revocation), writes a terminal state row and flips the vendor record `is_active=False` (self-heals the same-night assurance pass). Daily cron runs 03:25 UTC (after 03:00 refresh, before 03:47 assurance), per-target isolation + aggregate-raise, honors registry-retention exemption + snapshot-freshness guard + `employment_status='left'` gate + runner capability flags (google_schema deactivate → excluded). A newly-onboarded target's pre-existing backlog auto-converges within ≤24h (the daily scan is target-agnostic). `revoke_leavers_on_target(dry_run=True)` gives a would-revoke preview for gated prod remediation. NOTE: the ~101 existing prod accounts await explicit go-ahead to actually revoke. Tests: [`test_leaver_revocation_reconcile_2026_07_18.py`](../../server/tests/test_identity_sync/test_leaver_revocation_reconcile_2026_07_18.py).
>
> **Leaver-assurance findings are now TRUE + vendor snapshots auto-refresh daily** (2026-07-17; migration **408** = seed 1 cron / **+1 cron** `identity_sync.vendor_snapshot_refresh` (daily 03:00 UTC) / 0 MCP / 0 RBAC). First `identity_sync.leaver_assurance` run fired 48 findings; 36 were stale-snapshot artifacts and 9 were documented CBG retention — only 3 real. Fixes in [`scheduler_handler.py`](../../server/app/domains/identity_sync/scheduler_handler.py): (1) **vendor-side freshness guard** — a snapshot row counts only when `DATE(fetched_at) > termination_date` (a snapshot taken before the departure legitimately shows the person active and cannot testify about revocation; NULL termination_date stays in scope). (2) **Retention-registry exemption, both signals** — leavers with a KNOWN classification (`shared_service`/`external_guest`/`contractor`) in `external_account_registry` are exempt, mirroring the offboarding write path's `_is_google/_is_feishu_account_retained` semantics; ACTION classifications (`former_employee`/`unknown`) still fire. (3) **`identity_sync.vendor_snapshot_refresh` cron** — snapshots previously moved ONLY on manual refresh (huilianyi sat at 6-16, feishu at 7-2, six of eight revocation-enabled targets never snapshotted), and with the freshness guard a stale snapshot = Signal B silently dead; the daily job does the read-only roster pull per enabled deactivate/delete target at 03:00 UTC (after the 02:00 sweeps, before the 03:47 assurance), skips snapshot-unsupported kinds (netsuite), isolates per-target failures (rollback + materialized scalars) and aggregate-raises into the failure notifier. Alert wording fixed to "N on X of Y checked target(s)" + `affected_targets` in summary. GOTCHA surfaced while remediating: **`sf_downstream_push_code=2061` leavers are invisible to the sweep** (candidate-loader global exclusion → out-of-scope rows marked `excluded` "person no longer in Helix", never auto-deactivated — deliberate, HR-owned hands-off), so a 2061 leaver with a live vendor account was closeable ONLY manually or via registry classification, and the assurance job kept flagging them (was "by design"). **SUPERSEDED 2026-07-18**: `leaver_revocation_reconcile` (top callout) now actively revokes 2061 leavers (and orphans) from the vendor snapshot — 2061 no longer buys a departed employee a permanently-live account. Tests: [`test_offboarding_zerotrust_2026_07_17.py`](../../server/tests/test_identity_sync/test_offboarding_zerotrust_2026_07_17.py).
>
> **Intern attendance 出勤天数 is duration-weighted** (2026-07-16; migration **403** = `attendance_reports.work_days` Integer→Float / 0 cron / 0 MCP / 0 RBAC). 实习生出勤按打卡时长计,不整天记:单日出勤 = `min(1.0, 打卡总时长 / 9h)` 四舍五入到 0.1 天([`attendance/facts.py`](../../server/app/domains/attendance/facts.py)::`_attendance_fraction`, `FULL_DAY_HOURS=9.0`),打卡总时长 = 当日最早实际上班到最晚实际下班打卡跨度(`check_in_record.check_time`/`check_out_record.check_time` 真实流水);仅在岗类目(normal/late/early/lack_in/lack_out)计入,请假/出差/外出/旷工/休息记 0。满勤口径来自 HR 给定弹性规则(基准 09:00~18:00 含午休 1h,满勤=打卡总时长满 9h 含午休,弹性 ±2h)。**降级**:飞书只回应到、无真实流水时一个在岗日仍按 1.0 计(不清零)。快照每日 `attendance_days` 字段 + 日历浮层「当日出勤 X 天」;`ReportListItem.work_days` schema float;飞书卡 `:g` 去尾零。已确认历史月报不重算,重新生成的周期采用新口径。See [[project_intern_attendance_monthly_report]].
> **Partial-leave and lifecycle follow-up (2026-08-31):** The earlier blanket zero for leave is superseded for proven single-day partial leave. `ApprovalSpan` retains exact bounds and Feishu `interval` (seconds, independent of `unit`). The overlay deduplicates intervals and the daily snapshot retains `leave_intervals`. With both actual punches present, subtract overlap with approved leave from the punch span, retain the existing /9h conversion, and cap at `1 - approved net leave seconds / 8h` (4h afternoon leave + 09:26–15:00 punches = 0.5 days). Distinct non-overlapping single-day leaves add together. Full-day leave remains zero. Missing duration/punches, ambiguous overlapping approvals, or a multi-day total without a daily allocation retain the previous zero; never distribute a total across dates by guesswork. No leave-free calculations or confirmed reports are rewritten. A cycle is now immutable once its `cycle_key` exists: a changed start-day or roster department is applied only to new cycles, preventing historical report windows from moving. Feishu roster matching uses exact work email first; a local-part fallback with more than one active candidate stays visibly unmapped. A missing direct Leader turns an intern confirmation into a system-returned HR work item, and a later re-pull refreshes the leader snapshot. Every HR lifecycle mutation loads the report with `FOR UPDATE`, sharing the existing action-token concurrency protection.
>
> **API-key AK/SK self-service rotation (dual-active + auto-retire)** (2026-07-16; migration **401** = +1 col `api_key_assignments.rotation_retire_at` (indexed) + seed 1 cron / **+1 cron** `apikey.rotation_retire` (hourly) / **+3 MCP** (`rotate_api_key` / `complete_api_key_rotation` / `cancel_api_key_rotation`; hint 413→416) / 0 RBAC). Employees rotate a cloud-auto-granted key's credential without downtime: a fresh credential goes live immediately, the old one keeps working for an employee-chosen grace period (`0`/`3`/`7`/`14` days; 0 = hard cut), then the hourly cron retires it. New abstraction [`apikey/rotation.py`](../../server/app/domains/apikey/rotation.py) holds a provider-agnostic state machine (`minting → pending_retire → retired/cancelled`, stored on `detail.<provider>.rotation`) + per-provider mint/retire/cancel primitives. **Bedrock** = second IAM access key on the same user (`AWSProvider.{create,delete,list}_access_key`; AWS 2-key hard limit is prechecked); **Bailian** = new DashScope `sk-` in the same workspace (old kept until retire); **Azure OpenAI** = regenerate the *other* key slot for overlap, retire regenerates the old slot (`AzureCloudDriver.regenerate_foundry_account_key`). Service: `ApiKeyService.rotate_key` (owner/admin, `active`-only, `ROTATION_IN_FLIGHT` mutex, **no plaintext returned** — new credential read via the existing `/reveal` gate), `force_complete_rotation` (retire now), `cancel_rotation` (discard new + revert prior `key_value` from an encrypted stash, stripped from every API response). `revoke_key` clears rotation state; rotation start reuses the autogrant leak-safety tombstone. Tests: [`test_rotation.py`](../../server/tests/test_apikey/test_rotation.py) (15 cases). **UI + admin visibility land in PR2.**
>
> **Identity-sync mapping engine & config safety** (2026-07-17; 0 migration / 0 cron / 0 MCP / 0 RBAC). ([person_filters.py](../../server/app/core/person_filters.py), [scim.py](../../server/app/domains/identity_sync/runners/scim.py), [service.py](../../server/app/domains/identity_sync/service.py), [google_schema.py](../../server/app/domains/identity_sync/runners/google_schema.py)): (1) **Test-employee band by numeric range** — `is_test_employee_no` now matches the 9900–9999 band in BOTH widths (4-digit `P99XX` AND zero-padded 5-digit `P099XX`, e.g. `P09966` test.test that escaped the old `P99\d{2}`), while real `P0XXXX`/`P1XXXX` ids outside 9900–9999 never match. (2) **`matches()` checks `hris_person_id` too** (added to `CandidatePerson`) — the pull-surface authorization boundary now gates the test band on both ids, matching the push loader. (3) **runner_options unknown keys rejected (400)** instead of silently dropped; the documented-but-dropped `schemas_user_create` (CloudSSO POST-schemas override) + `drop_root_segments` + new `put_merge` are wired through the scim/navan validator + `_build_runner`. (4) **SCIM PUT opt-in GET-merge** (`put_merge` runner_option, default off) — GETs the current resource and merges mapped fields over it so a full-replace PUT can't erase a transiently-empty mapped field (AWS IdC / RFC 7644 §3.5.1); off by default to avoid a GET-per-PUT read amplification (PR2 preflight + value_map-miss signal already cover the main subclass; #1965 covers manager refs). (5) **google_schema caches a missing-schema result** — no more per-person `list_custom_schemas` re-query storm. Tests: [`test_mapping_config_safety_2026_07_17.py`](../../server/tests/test_identity_sync/test_mapping_config_safety_2026_07_17.py).
>
> **Identity-sync NetSuite/Huilianyi write correctness** (2026-07-17; 0 migration / 0 cron / 0 MCP / 0 RBAC). NS (20% 7d run success) + Huilianyi vendor rejections aligned to the PR2 `blocked` taxonomy + create/replay fixes ([netsuite.py](../../server/app/domains/identity_sync/runners/netsuite.py), [huilianyi.py](../../server/app/domains/identity_sync/runners/huilianyi.py), [service.py](../../server/app/domains/identity_sync/service.py)): (1) **NS entity-name (login) collision** — drop `firstName`, sync the rest, return `blocked` (the duplicate login is a manual NS dedup, not a daily-red failure); entityId takeover NOT done (would change finance's login names). (2) **NS create subsidiary preflight** — no resolvable subsidiary → `blocked` BEFORE the PUT (no 400 burned every sweep). (3) **NS create injects lastName** (create requires it; golden record has a single display name — last token, "." fallback, create-only). (4) **NS SuiteQL precompute fails loud** — a failed supervisor/adopted-dept precompute now aborts the sweep with one systemic error instead of proceeding with empty maps (which flipped the whole population's hash → mass reduced-payload re-push). (5) **NS 429 honors Retry-After**. (6) **Huilianyi non-transient rejections → `blocked`** — unmapped companyCode (new legal entity), missing custDeptNumber, vendor-resigned row; dept "has children/employees" (120124/…) → `skipped` (not failed → no daily partial; retires once members move). (7) **Huilianyi non-idempotent POST not blind-replayed** on a read/write-phase transport error (may have landed → duplicate); only connect-phase retries, else surfaces for the lookup-first path to converge. (8) **`netsuite` added to `SUPPORTED_KINDS`** (was wired end-to-end but couldn't be created via API/UI). NS single-person ns_* token injection (hash-churn, low-frequency) deferred. Tests: [`test_netsuite_huilianyi_correctness_2026_07_17.py`](../../server/tests/test_identity_sync/test_netsuite_huilianyi_correctness_2026_07_17.py).
>
> **百炼在 AI 大盘上有三个口径,且「实付为 0」不等于「没有数据」** (2026-08-15; 0 迁移 / 0 cron / 0 MCP / 0 RBAC). `bailian_usage_daily` 每行携带三档金额:`cost_usd_gross`(折扣前 / 牌价)、`cost_usd_after_discount`(折扣后)、`cost_usd`(实付)。**百炼的实付被优惠券/合同折扣抵到 0 是常态**(生产 2026-06/07/08 实付 $20.15 / $2.56 / $0.00,同期折前 $3,226 / $4,345 / $8,404)。`_bucket_bailian` 曾按 `if cost == 0: continue` 逐行丢弃,于是整个桶连折前一起消失(不是 `amount=0`,而是 `fact_count=0` / `amount_gross=None` / 不进 `grand_total_gross`),大盘上百炼卡片显示 US$0.00。**判据必须是「三口径全空」,不是「实付为 0」**;任何新的 AI 花费聚合都照此写。两个连带不变量:**(a)** `bailian_usage_daily` 同一天既有逐 model 明细行也有一条 `model_name=BAILIAN_AGGREGATE_MODEL` 汇总行,**任何求和都必须排除它**(常量在 [`bailian/models.py`](../../server/app/domains/bailian/models.py),此前是散在 4 处的字面量,ai_spending 就漏了一处,只因上面那个丢弃恰好遮住才没双计);**(b)** 覆盖率类指标(`gross_coverage_pct`)以实付为分母,在实付恒 0 的桶上是 0/0,必须退化为按行数,否则会把「每行都有原价」谎报成「覆盖率 0%」。另:迁移 299(2026-06-17)之后百炼**有**按 (天 × API Key × 模型) 的粒度与人头归属,凡是写着「百炼只有账号级」的注释/caveat/LLM 提示词都是过期的 —— 2026-08-15 清掉了四处,其中 AI 总结的系统提示词曾明令禁止模型使用已经存在的正确归因。Tests: [`test_bailian_three_calibers_2026_08_15.py`](../../server/tests/test_ai_spending/test_bailian_three_calibers_2026_08_15.py).
> **成本分摊阶梯 = 规则 → 百炼 API Key → 订阅 Owner** (2026-08-15; 0 迁移 / 0 cron / 0 MCP / 0 RBAC). `auto_owner_source` 取值集新增 `bailian_api_key`(与 `rule` / `scope_owner` / `savings_plan` / `none` 并列)。阿里云 BSS 把 API Key id 编码在 `InstanceID` 首段,驱动原样落进 `cmdb_cost_facts.resource_id`(`3589778;ws-…;qwen3.7-max;input_token;;0`),迁移 299 起 `bailian_api_keys` 就有该 id + owner —— 但阶梯此前认不出这个形状,**整个百炼账号的钱全落到账号 Owner 一个人头上**(生产 2026-08:$8,100.94 全记一人名下,其中 $4,272.39 / 52.7% 可定位到 11 位同事)。跨域走 [`bailian_cost_owner_coordinator`](../../server/app/core/coordinators/bailian_cost_owner_coordinator.py)(CMDB 不 import 百炼域);映射在 **Python 侧**查而非 SQL JOIN —— `cmdb_cost_facts.resource_id` 与 `bailian_api_keys.api_key_id` 的 **collation 不同**(`utf8mb4_unicode_ci` vs `general_ci`),直接 JOIN 生产报 `1267`,加 `COLLATE` 又会废掉索引而回填是逐日全表批处理。三条必须一起记住的性质:**(a)** 摄取与重算两条路径的阶梯顺序必须逐字一致,不一致会让重算把摄取算对的归属改错、且只在回填后才显形;**(b)** `recompute_allocation` 的行查询是**逐列**列举的,新阶梯用到哪列就得加进 SELECT(本次补了 `resource_id`,漏了会在夜间 cron 里 `AttributeError`,炸在生产而不是 CI);**(c)** 资源目录**成员账号**里的 Key 无法 `ListApiKeys` 枚举,那部分残留**继续**走订阅 Owner 兜底 —— 宁可诚实地粗,不要精确地错。Tests: [`test_bailian_per_key_allocation_2026_08_15.py`](../../server/tests/test_cmdb/test_bailian_per_key_allocation_2026_08_15.py).

> **账单 cron 的建立与库存能力正交 —— 不要按 capabilities 闸** (2026-08-15; 当天试过并回退). `sync_jobs_for_integration` 是 **diff-and-apply**:不在 desired 里的行会被 **DELETE**。所以任何「按 X 决定要不要建 cron」的判据,同时也是「X 变了就删掉这条 cron」。给 `billing_daily` / `cost_facts` 按**库存能力**闸的后果:管理员把某集成能力收窄(临时改成 `["iam"]`)就**静默删掉它的账单同步** —— 对 `aws-primary`(capabilities=NULL,全公司最大花费方)是账单从此不再摄取,而删掉的恰好是那条本来会在健康横幅上告警的 cron,失效因此静默(合并后全量测试里 `test_sync_diffs_when_capabilities_change` 当场抓到:收窄能力掉了 3 行而非 1 行)。月度账单行走 `_provider_supports_billing`(provider 类有无 `get_monthly_billing`)、**不看能力**,正是因为计费与库存能力无关;而 `tencent-dnspod` 与 `tencent-primary` 同一个 provider 类,provider 维度又区分不开它俩。[`test_cost_cron_provisioning_capability_gate_2026_08_15.py`](../../server/tests/test_cmdb/test_cost_cron_provisioning_capability_gate_2026_08_15.py) 现在是**反向守卫**(账单两条对任何能力组合都必须存在)。一般规律:**diff-and-apply 的 desired 集合里,新增判据前先问「这个判据变化时,我愿意删掉这行吗?」**

> **百炼 / ModelStudio 是分区域的 —— 同步必须逐区域轮询** (2026-08-15; 0 迁移 / 0 cron / 0 MCP / 0 RBAC). 每个区域有**自己独立的「默认业务空间」**,`ListWorkspaces` 只返回**调用端点所在区域**的那一个。生产实测(同一把 AK、账号 5962436752113096):`ap-southeast-1` → `llm-tbeflduouo79t4fv`(28 把 Key)、`cn-beijing` → `ws-glxk2cmse8jwzzgz`(5 把,含当期 $4,960)、`us-east-1` → `ws-7tcent4dizu8z1s7`(1 把)。同步此前写死新加坡端点,后两个区域的 Key **从未入库** —— 它们的费用因此解析不到人,全部兜底记到账号 Owner 名下(用户看到「按归属人拆分 100% 都是我」的由来)。**这个缺口是静默的**:拿别的区域的 workspace id 去查 `ListApiKeys` 返回 **HTTP 200 + 空列表**,不报错。区域现在是 `modelstudio_host(region)` 的一等参数,`_MODELSTUDIO_REGIONS` 是覆盖清单,`bailian_workspaces.region` 存**端点区域**(权威值,响应里的 `region` 字段实测可能缺失)。三条必须一起记住:**(a)** `ListApiKeys` 必须用该 workspace 自己的区域 —— 打错区域拿到空列表,而 `sync_api_keys` 的漂移清扫会据此把那个区域的 Key 全部软删;**(b)** `CreateApiKey` / `DeleteApiKey` 同理,打错区域「200 但什么也没做」,撤销会静默失效(6 个写调用点已接 `BailianService.workspace_region()`);**(c)** 单区域失败降级 + WARN,全部失败才抛 —— 静默少一个区域等于静默丢一批 Key。Tests: [`test_multi_region_sync_2026_08_15.py`](../../server/tests/test_bailian/test_multi_region_sync_2026_08_15.py).

> **两条云账单链路的日级互查** (2026-08-16; 迁移 **482** / **+1 cron** / 0 MCP / 0 RBAC). `cmdb.cost_facts_chain_check`(每天 08:30 SH,排在 cost_facts 04:xx 与 billing_daily 06:xx 之后)把每个云账号每天的**日总额**(`cmdb_cloud_billing_daily`)与**逐行明细之和**(`cmdb_cost_facts` 走 [`fact_engineering_amount()`](../../server/app/domains/cmdb/cost_view.py))对一下。两张表来自**两个不同的厂商调用**,页面上的每个折前数字都来自后者。**抓的是「明细被静默截断」**:明细逐日翻页拉,厂商提前返回空 `NextToken` 就等于「这天到此为止」,而少拉的部分没有任何痕迹 —— 任务写 `status=ok`,run summary 的行数与实际入库行数完全相等。2026 全年 3 起(阿里云国际站):5/06 日总 12,424 vs 明细 **200.83**、5/05 12,210 vs 8,521、7/31 473.88 vs 91.93 —— 前两起就是 5 月 BQ 对账剩下的 $15,913,第三起就是 7 月对账报出的 $381.95。**不打任何厂商接口**却能当天定位到「哪一天丢了」,与 `cmdb.cost_recon.aliyun_bq` 互补(那条慢而权威:次月、与财务口径比;这条快而窄:当天、定位到日)。**阈值 比值<0.85 且缺口≥$200 是在 11,716 个账号×天上量出来的** —— 恰好命中那 3 起、零误报;按 5% 报会在 AWS 上刷出 224 条(AWS 两条链路来自 Cost Explorer vs BigQuery CUR 两个数据源),而 `gcp` 3,221 天比值恒为 1.0000(σ=0.0001),证明比较本身苹果对苹果。**只读**:只报告 + 给出合并好的重拉窗口,绝不自动重拉(不一致 ≠ 明细一定是错的那条)。Tests: [`test_cost_chain_crosscheck.py`](../../server/tests/test_cmdb/test_cost_chain_crosscheck.py).

> **阿里云成本口径 × 财务数仓的月度对账** (2026-08-16; 迁移 **476** / **+1 cron** / 0 MCP / 0 RBAC). `cmdb.cost_recon.aliyun_bq`(每月 14 号 09:00 SH,迁移 481 从 8 号后移)把上一个完整自然月的阿里云折前成本与 BigQuery `gcp-digital-bi.alibaba_cloud.monthly_en_*` 按财务口径逐账号对比。**这是「主链路用 API 还是用数仓」的落地答案:主链路继续用 API(免 BQ 扫描费、日/明细粒度;财务那张表是月粒度账号级,喂不动日趋势/Top 资源/按 Key 分摊/预算告警),BQ 只做对账。** 口径一致本身由 [`cost_view.engineering_row_clause`](../../server/app/domains/cmdb/cost_view.py) 的行白名单保证,这个 cron 把它从一次性结论变成每月可证的事实。四条设计约束:**(a)** 只对**已结月**,且排在 `cmdb.cost_facts_monthclose` 的**第二遍**(12 号)之后 —— 当月两边都在动;更要紧的是**月结本身曾比阿里云结算还早**:2026-08-05 00:28 时 BSS 对 2026-07-31 只返回 133 行,11 天后是 536 行($473.88 vs 入库的 $91.93),这一天就是 7 月对账报出的全部 $381.95。**对一份厂商还没写完的账做对账,只会把厂商的滞后报成我们的漂移** —— 假阳性比没有对账更坏,它训练人忽略告警。迁移 481 于是把月结改成 `0 7 5,12 * *`、对账改成 `0 9 14 * *`;12 天是上界不是实测,如果仍不够,下个月的报告会用美元说出来;**(b)** **只读**,发现漂移只告警 + 出逐账号清单,绝不自动「对齐」(那会把已知分歧变成隐藏分歧);**(c)** Helix 侧**复用 `fact_engineering_amount()`** 而非重实现 —— 否则对上了也不能证明页面是对的;**(d)** 两侧账号取**并集**,只在一边出现的账号才是最值得看的信号。权限:`gcp-primary` 的 SA(`pch-resource-manager@gcp-digital-infra`)需 `alibaba_cloud` 数据集 READER,**用经典 dataset ACL 授予** —— `bq add-iam-policy-binding` 在本组织报 *This feature requires allowlisting*。Tests: [`test_cost_recon_bq_2026_08_16.py`](../../server/tests/test_cmdb/test_cost_recon_bq_2026_08_16.py).

> **AI-spending rollup has two writers, serialised by one lock** (2026-07-28; 0 migration). `ai_spending_period_rollup` is a materialised cache rebuilt window-by-window with DELETE-then-INSERT, and it carries **no unique constraint** — only `ix_ai_rollup_window_fp`. Two writers overlapping on one window therefore leave DUPLICATE rows, and every read SUMs them, so the dashboard silently double-counts. There was one writer until [#2083](https://github.com/pingcap-inc/Helix/pull/2083) gave the drift-check cron an auto-heal path that also re-materialises windows. The scheduled-jobs single-flight lock is keyed `sched_job:{job_id}` — **per job, not per resource** — so it does not serialise two different crons; the 30-minute gap between them (06:30 refresh / 07:00 drift check) is a schedule coincidence, and either can be delayed by the concurrency gate. Every writer now takes `ROLLUP_WRITE_LOCK` ([locks.py](../../server/app/domains/ai_spending/locks.py)); a busy lock degrades to `skipped` / `heal_deferred` rather than raising, because the other holder is performing the same repair. **General rule: a "single writer by construction" comment on a mutation helper is a liability the moment a second caller appears — if the invariant matters, take a resource lock.**

> **Idempotency: secret redaction, per-scope takeover, own-row release** (2026-07-28; 0 migration). Three follow-ups from the security/logic scans, all in [idempotent_route.py](../../server/app/core/idempotent_route.py) / [idempotency.py](../../server/app/core/idempotency.py). (1) `redact_cached=("data.plaintext",)` on `auth.create_pat`: the response carries a live credential, and caching it verbatim parked the PAT plaintext in `core_idempotency_records` for the full TTL. The first caller still gets the real value; the persisted copy is blanked and flagged `plaintext_withheld_on_replay`, which both token UIs render as "revoke and mint again". `require_browser_session` moved to `Depends` — a cached response returns before the handler body, so a body-level gate is skipped on every replay. (2) `takeover_minutes=None` disables abandoned-reservation takeover per scope; set on `cloud_scope.decommission`, whose provider fan-out is not bounded by the proxy timeout (uvicorn does not cancel the coroutine when the client disconnects). (3) `release_reservation(scope, key, row_id)` matches the row the request actually reserved: the release is deferred 1s while the mobile retry backoff is 500ms, so without the id it lands on the SUCCESSOR's reservation and marks a live attempt failed. **Every `@idempotent` scope now includes the caller** (`user.id` / `current_user.id`) — `lookup()` returns before the handler body, so authorization written in the body does not run on a replay.

> **Idempotency: end-to-end contract + durable reservation release** (2026-07-28; migration **430** = data repair / 0 cron / 0 MCP / 0 RBAC). Three gaps closed in the `@idempotent` stack. (1) **The key must actually be sent.** Six of the fourteen decorated endpoints had API-client functions with no parameter for it, so the guard was a no-op — including the irreversible cloud-scope decommission. [test_idempotency_client_contract.py](../../server/tests/test_mobile/test_idempotency_client_contract.py) now AST-collects every `@idempotent` route (resolving `APIRouter(prefix=…)`) and asserts each `web/src/api` function calling that (METHOD, path) sets an `Idempotency-Key`; it carries a floor on its own collection counts because the first draft silently parsed a third of the client layer. (2) **Reservation release is durable.** Most decorated handlers commit internally, so on the failure path `get_db`'s rollback undid `mark_failed()`'s flush and the row stayed `pending` for the full 24h TTL. `release_reservation()` ([idempotency.py](../../server/app/core/idempotency.py)) runs the state change on its own session/commit, matching only `pending`; the decorator schedules it ~1s later because the failing request still holds the row lock. `IN_PROGRESS_TAKEOVER_MINUTES = 10` covers the case no code can handle — the process dying mid-handler. (3) **Migration 430** repairs seeded `platform_settings` content: `_seed_setting` is insert-if-absent, so PR2's edit to migration 051's dead template URL only ever reached fresh installations. `downgrade` is deliberately a no-op (the symmetric rewrite cannot tell a repaired row from one that was always correct).

> **Auditor list search covers people** (2026-07-28; 0 migration). `_apply_text_search` ([auditor/router.py](../../server/app/domains/auditor/router.py)) takes `person_fk` and outer-joins `identity_persons`, adding `display_name`/`work_email` to the searchable columns on the five person-shaped endpoints. The MFA and sign-in shells label the box "搜索员工" but neither model stores a name, so searching one returned "no matching results" — which reads as "this person has no record". The term is also LIKE-escaped: an unescaped `_` matched any character and returned the whole table.

> **Frontend crash telemetry** (2026-07-28; 0 migration / 0 cron / 0 MCP / 0 RBAC). `POST /api/v1/observability/client-errors` ([client_errors.py](../../server/app/observability/client_errors.py)) is the sink both error boundaries report to. Deliberately **log-only, no table** — the goal is "the crash lands in the same structured log stream as everything else, with a trace id", not a second observability product. Authenticated (an anonymous crash sink on a public origin is an open log-injection endpoint, and the caller identity answers "one person or everyone?"), per-user Redis fixed-window limited **fail-open** (dropping a crash report is worse than logging an extra one — the opposite trade-off from the login limiter), every field length-capped server-side, and `extra="forbid"` with **no** `context`/`extra` dict: page-local state is exactly where PII lives. The client reporter ([clientErrorReport.ts](../../web/src/lib/clientErrorReport.ts)) sends `location.pathname` and **never** `location.search` — one-click landing pages carry live action tokens in the query string. `MobileErrorBoundary` takes `boundaryName` + `fallback`, so nested boundaries degrade one card instead of the page; the mobile home page wraps its seven sections individually. App version comes from the Vite `define` build stamp (`VITE_APP_VERSION` → `GITHUB_SHA[:7]` → `dev`), not a hardcoded literal.

> **HRIS 入职日期更正保持三条时间线一致** (2026-08-24; 0 migration / 0 cron / 0 RBAC / 0 MCP). `SuccessFactorsLifecycleService` detects an existing person's actual `hire_date` change in both confirmed batches and webhooks, then invokes the coordinator-only `refresh_onboarding_for_hire_date_change`: it never repeats new-hire provisioning, account creation, or welcome notifications. The coordinator first re-anchors only pending automatic onboarding-grant `ProvisioningTask.not_before` values to `hire_date 00:00` in the person's local timezone, then re-enters `OnboardingService.start_process` to refresh process date, Day-0/SLA reminders, and lifecycle status. Completed, in-progress, and manual-notification tasks retain their operational history. This keeps HRIS person data, the onboarding process, and future task timing aligned after a correction without early access or duplicate side effects.

> **Overseas Feishu new-account login identifiers are deliberate** (2026-08-24; 0 migration / 0 cron / 0 RBAC / 0 MCP). The multi-geo Directory create keeps both the HRIS work `email` and mobile number. Its immediate official Contact-v3 projection additionally writes `enterprise_email` to that same work-email value for a newly-created overseas account. `enterprise_email` is the Feishu-admin **Business/Login email** field, distinct from Contact `email`; this gives the new employee the intended email and mobile sign-in choices. The rule is create-only: existing accounts are not mass-backfilled or overwritten, since their enterprise mailbox may be separately administered.

> **Identity-sync Feishu write correctness** (2026-07-17; 0 migration / 0 cron / 0 MCP / 0 RBAC). The Feishu contact/department runners got aligned to the PR2 `blocked` taxonomy + several write-correctness fixes ([feishu_contact.py](../../server/app/domains/identity_sync/runners/feishu_contact.py), [feishu_department.py](../../server/app/domains/identity_sync/runners/feishu_department.py), [`_run_feishu_dept_phase`](../../server/app/domains/identity_sync/service.py)): (1) a mobile-less create the tenant rejects (**41010**) → `blocked` (was failed/daily-red); (2) **42006** "resigned" on the PATCH/update path → `blocked` (#1958 only covered the freeze path); (3) **prime_catalog failure fails loud** — Phase A aborts (records + counts) instead of running POST-only and creating duplicate parallel departments (#1757 class); (4) the dept runner **omits `parent_department_id` on an update** when the parent is transiently unresolved (never re-parents an adopted dept to root "0" then freezes it there); (5) the user dept-merge **keeps the existing Helix-managed ods when any HRIS dept code is unresolved** (never leaves the user department-less in the gap); (6) 41041 residual gaps closed — a no-dept-slot 41041 drops `orders`/degrades or → `blocked` "grant scope", and the dept+mobile degrade fallbacks now **chain** (both bad → both dropped, not hard-failed); (7) **mobile-fallback hijack guard** — a mobile hit is verified with one targeted GET of the matched account's `employee_no` before adoption, so a shared/recycled number can't overwrite another person's account; (8) the leaver **freeze PATCH routes through `_write`** for per-person ledger observability, and a create that drops an unformattable mobile records a `slot_outcome`. (Deactivate email/mobile fallback — a cross-runner signature change — deferred; PR3's leaver-assurance reconcile is the safety net.) Tests: [`test_feishu_write_correctness_2026_07_17.py`](../../server/tests/test_identity_sync/test_feishu_write_correctness_2026_07_17.py).
>
> **Identity-sync event-push must-deliver — out-of-scope no-op guard + LockBusy re-enqueue** (2026-07-17; 0 migration / 0 cron / 0 MCP / 0 RBAC). Lifecycle events fan out to every event-aware target regardless of that target's filter, so the single-person chokepoint ([`_push_one_person`](../../server/app/domains/identity_sync/service.py) / `_push_person_to_target`) got two must-deliver fixes. (1) **No-op guard**: an ACTIVE, out-of-scope, never-linked person (a hire event fanned out to a target that doesn't cover them) is a clean `excluded` no-op — not routed into deactivate, which would fabricate a "deactivated" row or, on email-addressed vendors (SCIM/Navan), revoke an unrelated same-email account. LEAVERS are exempt (offboard must still email-resolve + deactivate an account never linked in Helix). (2) **LockBusy re-enqueue**: a push that finds the target locked by an in-flight sweep is re-enqueued up to `_EVENT_PUSH_LOCKBUSY_RETRIES` (3) times at `_EVENT_PUSH_LOCKBUSY_RETRY_DELAY_S` (45s) via `_retry_person_push_after_delay` (own session, through the public chokepoint so the in-flight guard still collapses genuine duplicates) — critical for `event_only` targets (no sweep to reconcile) and collapses cron-target offboard-revocation latency from "next daily sweep" to "~a minute". Retries exhausted → old behaviour (cron → skipped/green; event_only → failed → IT alert). Tests: [`test_event_delivery_2026_07_17.py`](../../server/tests/test_identity_sync/test_event_delivery_2026_07_17.py).
>
> **Identity-sync offboarding zero-trust — deactivate stale-id re-verify + active-only auto-include exemption + leaver-assurance reconcile** (2026-07-17; migration **405** = seed 1 cron / **+1 cron** `identity_sync.leaver_assurance` (daily 03:47 UTC) / 0 MCP / 0 RBAC). Leaver revocation no longer trusts the write path's word. (1) **SCIM `deactivate`/`delete`** on a STORED id that 404s now re-verifies by email ([scim.py](../../server/app/domains/identity_sync/runners/scim.py) `_patch_deactivate` + `_safe_find_user_id_by_email`): a live account under a DIFFERENT id (AWS IdC federation re-provision / Navan recreate) is caught + revoked instead of reported deactivated; a failed re-verify GET degrades to idempotent-gone. (2) **NetSuite `deactivate`** on an `eid:{工号}` that 404s resolves the work number via SuiteQL (`custentity_swc_work_num`) to the real internal id (the unadopted Authing-era record) and deactivates THAT ([netsuite.py](../../server/app/domains/identity_sync/runners/netsuite.py) `_resolve_internal_id_by_work_num`); only a genuine no-record claims success. (3) **Auto-included exemption is active-only** — the out-of-scope reconcile ([`_sweep`](../../server/app/domains/identity_sync/service.py)) exempts an auto-included row ONLY while the person is `active`; a LEFT auto-included manager is recovered (their Navan/SCIM account no longer stays open forever behind the one-shot offboard event). (4) **Single-person path** gained the `_leaver_last_working_day_passed` guard on its out-of-scope deactivate (parity with the sweep). (5) **`identity_sync.leaver_assurance` cron** independently reverse-verifies, per enabled deactivate/delete target, that departed employees have no lingering account — Helix-side (a leaver still linked with a non-terminal state) AND vendor-side (a vendor-snapshot record still `is_active` matched to a leaver) — and `raise`s so the framework failure notifier pages IT (never the leaver/manager). DB-only, generalises the Feishu-only `account_backstop` to every target. (Feishu freeze-path email/mobile fallback + wire-observability land in PR5 to avoid dual-editing that runner.) Tests: [`test_offboarding_zerotrust_2026_07_17.py`](../../server/tests/test_identity_sync/test_offboarding_zerotrust_2026_07_17.py).
>
> **Identity-sync outcome taxonomy — `blocked` person status + required-field preflight + value_map-miss signal** (2026-07-17; migration **404** = +1 col `identity_sync_runs.blocked_count` / 0 cron / 0 MCP / 0 RBAC). Person outcomes gained a fifth terminal status, **`blocked`**, between `failed` (transient → retry every sweep, red run, alert) and `excluded` (policy no-op): a NON-transient, data-dependent vendor refusal — a required field that didn't render, a STRICT `value_map` code with no entry, a resigned vendor account. A `blocked` row **persists its payload hash** (so the unchanged-payload short-circuit re-affirms it cheaply with NO vendor call — the hash-skip set is now `success/skipped/excluded/blocked`), keeps `retry_count` at 0, carries an actionable `last_error`, counts as `summary.blocked` (NOT `failed`), keeps the run **green**, and auto-retries the instant the source data changes (hash changes → drops out of the short-circuit). **Since 2026-07-30 a blocked backlog actually alerts** — `maybe_notify_blocked_backlog` ([alert_notifier.py](../../server/app/domains/identity_sync/alert_notifier.py)), spawned from `_finalise_run` on any outcome, lists person + reason to the target's own `alert_recipient_ids`, dedup'd by the blocked-SET outbox signature. Until then the green-run-plus-no-signal combination meant a blocked person was invisible unless somebody opened the ledger: that is how the Zoom create gap ran for a month (every run `ok`, zero accounts created) and was ultimately reported by a new hire, not by the platform. Reasons in `SELF_DRAINING_BLOCKED_REASONS` ([runners/base.py](../../server/app/domains/identity_sync/runners/base.py)) are excluded — an expected, self-clearing state (e.g. Zoom's per-run create budget) must not page anyone nightly, and a drift test pins the runner-side constants against that set. Wired into BOTH `_sweep` and the single-person `_push_one_person`. Pieces: (1) **required-field preflight** — `MappingRule.required` + `collect_render_diagnostics()` ([mapping.py](../../server/app/domains/identity_sync/mapping.py)); the orchestrator blocks a person whose required field renders empty BEFORE any vendor call (no burned 400), auto-clearing on a data fix (PR6 sets `required` on the NetSuite subsidiary/name preset rules). (2) **STRICT value_map-miss signal** — misses tallied into `summary.value_map_misses` → `result_summary`; `maybe_notify_value_map_misses` ([alert_notifier.py](../../server/app/domains/identity_sync/alert_notifier.py)) fires a dedup'd (outbox set-signature) "new unmapped code" heads-up ONCE per changed set, on any run outcome — the general defence behind the 1074/1075 and "new company_code" incidents. (3) **slot-degrade honesty** — a resilient-write drop (`slot_outcomes` dropped/drift/failed) no longer bumps `last_verified_at` (forces a next-sweep retry instead of freezing the field for the 7-10d drift window) and keeps the degradation note on the row. (4) **failure-alert dedup** widened from the immediate predecessor to the last *alerting* run within a 25-run window (kills the interleaved-event-run alert storm). Tests: [`test_outcome_taxonomy_2026_07_17.py`](../../server/tests/test_identity_sync/test_outcome_taxonomy_2026_07_17.py).
>
> **Identity-sync execution plane — `cancelled` run status + outcome-aware cron re-arm + background manual runs + stale-run cleanup** (2026-07-16; migration **402** = +1 col `identity_sync_targets.sched_failure_streak` + seed 1 cron / **+1 cron** `identity_sync.cleanup_stale_runs` (every 15 min) / **+1 read API** `GET /identity-sync/runs/{run_id}` / 0 MCP / 0 RBAC). The run lifecycle used to lose a full day's reconcile on any deploy: `_finalise_run` advanced the cron cursor to *tomorrow's* slot on ANY scheduled finalise, so a worker restart that cancelled the in-flight sweep (every merge auto-deploys) recorded it `failed` and skipped that day (a hard SIGKILL, which never reaches `_finalise_run`, self-healed — graceful shutdown was strictly worse). Now: (1) **`cancelled`** is a first-class run status distinct from `failed` — the `CancelledError` path records it, `_run_status_bucket` gives it its own bucket, it is **excluded from `success_rate` and failure alerting** (infra event, not a data failure), and the KPI strip surfaces `cancelled_runs`. (2) **Outcome-aware cursor re-arm** in `_finalise_run` (scheduler runs on cron targets): `ok`/`partial` → next cron slot + reset streak; `cancelled` → **now+60s** (re-runs on the next tick — day's reconcile not lost); `failed` → **same-day exponential backoff** (5/10/20/40/60m, escalating with the new `sched_failure_streak` column) up to `_SCHED_FAILURE_MAX_SAMEDAY_RETRIES=5`, then fall back to the daily slot (staleness watchdog + failure notifier still cover a persistently-broken target). (3) **Manual "Sync now" is now background** — [`start_manual_run_background`](../../server/app/domains/identity_sync/service.py) creates the run row in-request (returns `run_id` + `status=running` immediately) and hands the sweep to `background.spawn` off-request, so a browser-tab close / nginx proxy timeout no longer cancels a multi-minute NetSuite reconcile; the UI polls `GET /runs/{run_id}`. (4) **`identity_sync.cleanup_stale_runs` cron** (every 15 min) finalises orphan `status='running'` rows (worker hard-exit / OOM) as `cancelled`, covering the runtime blind spot the boot-only startup watchdog (30-min floor) missed; the startup watchdog now also records `cancelled` not `failed`. **Reaper is lock-aware** (2026-07-17 review): it finalises a stale row ONLY when the target's `identity_sync:target:<slug>` lock is NOT held — a live long sweep (NetSuite full reconcile / mass re-push at 60 req/min + Retry-After) holds the auto-renewed lock its whole duration, so "lock lapsed" is the true orphan signal; without this the reaper cancelled healthy in-flight sweeps mid-write. Uses [`distributed_lock.is_lock_held`](../../server/app/core/distributed_lock.py). Worker `stop_grace_period: 30s` in prod compose gives the drain + cancel-finalise room. Tests: [`test_run_rearm_policy_2026_07_16.py`](../../server/tests/test_identity_sync/test_run_rearm_policy_2026_07_16.py).
>
> **INVARIANT — large notification fan-outs never dispatch inside a locked transaction** (2026-07-14). A caller that must notify many recipients (asset inventory launch/reminder over hundreds of holders, and any future mass push) MUST NOT loop `NotificationService.dispatch` while holding a row lock (`SELECT ... FOR UPDATE`) or an open transaction — each `dispatch` does slow external channel sends AND takes a shared `identity_persons` FK lock on its outbox INSERT, so a synchronous loop pins those locks for minutes → repeat callers 1205 on the locked row AND concurrent logins (`force_logout_at` update) 1205 (the 2026-07-14 「开始盘点失败」 outage; same class as the #1837 outbox-FK login lock). Pattern to use instead: (1) do the fast all-DB state change under the lock and **commit** (releases it in ms); (2) fan out AFTER commit, off the lock — `background.spawn` for HTTP paths (return immediately), inline for crons (worker tolerates the drain); (3) inside the fan-out, **commit per recipient** so each FK lock is held sub-ms. `NotificationService.dispatch` is now split into **`enqueue`** (write the PENDING `notification_outbox` row only, savepoint-isolated) + **`deliver`** (attempt channels on an already-enqueued row); `dispatch = enqueue + deliver` (single-recipient callers unchanged). Big fan-outs **enqueue every row first (per-row commit) then deliver (per-row commit)** so a crash mid-drain leaves un-sent rows PENDING for the hourly `notification.outbox_retry` worker — guaranteed delivery holds. Reference impl: [`AssetInventoryService._dispatch_initial_notifications` / `dispatch_start_notifications` / `run_start_notifications_in_background`](../../server/app/domains/asset/inventory_service.py). See [[reference_outbox_fk_lock_login_outage]].
>
> **↑ AMENDED 2026-09-06 — the watchdog was right, the dispatcher's own alarm was the broken half.** Two corrections to the paragraph below, both found by chasing one NetSuite alert.
>
> **(a) `run_due_targets`'s `ran_failed` gate had never worked.** `run_target` creates the `running` row on `self.db`, finalises it through `_finalise_run`'s **own** session, then re-read it with a plain ORM `select()` — which does **not** overwrite the loaded attributes of an instance already in the identity map (only `populate_existing` does), and `async_session_factory` is `expire_on_commit=False` so the earlier `commit()` never expired it either. The dispatcher therefore read `status='running'` for **every** sweep, and the branch that promotes a target into `ran_failed` was unreachable for the whole "finalises `failed` WITHOUT raising" class — the exact class the gate was built for after the 2026-08-14 `aws-ai` credential outage. Prod evidence: netsuite finalised `failed` on 2026-08-21/22/23 and `identity_sync.dispatch` reported ok all three nights; `notification_outbox` holds **zero** 主调度 failure cards between 2026-08-21 and 09-06, `google-dept`'s 09-01 `failed` included; 10 failed sweeps in 30 days, all silent. The MCP `trigger_identity_sync_target` tool had the same staleness in its response (`status: "running"`, null summary). Fixed by `IdentitySyncService._reload_finalised_run()`, which reads the terminal row through a **fresh session** — not `populate_existing` on `self.db`, because `_execute_manual_run` and the MCP tool both read before the sweep and hold a REPEATABLE READ snapshot taken before the finalise committed, and not a `rollback()` on the caller's session, because that would discard the MCP tool-call audit row pending on `rt.db`. **Transferable trap: in this codebase a cross-session write is only visible to the writer's session; re-reading it on another session needs `populate_existing` AND a snapshot that starts after the commit — a bare `select()` gives you neither.** **And the meta-lesson: the 2026-08-14 fix's dispatcher test stubbed `run_due_targets` and hand-fed `ran_failed`, so it proved the handler raises and never that anything puts a target INTO `ran_failed`. A stub that supplies the shape under test cannot verify the wire that produces it.** Tests: [`test_dispatch_failed_run_gate_2026_09_06.py`](../../server/tests/test_identity_sync/test_dispatch_failed_run_gate_2026_09_06.py).
>
> **(b) The watchdog now names which shape of staleness it found.** "No successful scheduled sweep in > one period" has two causes and they need different responses; reporting both as "silently stopped running their scheduled full sweep" is how prod's 2026-08-20..23 netsuite alert came to describe a target that ran every night and failed every night. It reads `last_attempt` (most recent `scheduler` run of ANY status) alongside the last ok/partial one and splits: **silent** = the dispatcher never fired it (this job's original blind spot; nothing else reports it — and `target.last_run_at` cannot detect it because per-person runs refresh that column) vs **failing** = it fired and failed (loud, has an `error_message` on the runs list, and — since (a) — the dispatcher reports it too). Both still raise. `result_summary` gains `silent` / `failing`; `stale` stays their union for existing readers. **Also worth remembering for any alert-triage: a fixed-width "last N days" window clips a contiguous failure block into what looks like flapping.** The 4-failures-in-56-runs that opened this investigation was the tail of 14 consecutive failed ticks that ended 13 days earlier, not an intermittent fault.
>
> **Identity-sync scheduled sweep no longer starved + silent-miss watchdog** (2026-07-14; migration **399** = seed 1 cron / **+1 cron** / 0 MCP / 0 RBAC). Two things. **(1) `next_run_at` only advances on `scheduler` runs.** `IdentitySyncService._finalise_run` recomputed a cron target's `next_run_at` on EVERY run finalisation, not just the dispatcher's scheduled one — so a target with heavy per-person traffic (feishu-contact + onboarding 24h-ahead provisioning firing `onboarding_feishu_provision` at the top of every hour, incl. the cron minute) had its daily slot perpetually shoved into the future before the 60s dispatch tick could see it as due. The scheduled full sweep fired ~7×/30d, so existing-employee dept/manager changes never reached Feishu (张业祥 P0625 Forge-Pod transfer: reached Helix + dept adopted, but no sweep ran to push it). Fix: `_finalise_run` advances `next_run_at` only when `run_row.trigger_source == "scheduler"`; per-person/event/manual runs leave the cursor alone; a drifted `NULL` cursor self-heals on any finalise. Google targets (no hourly per-person runs) were unaffected — the asymmetry pinpointed the cause. **(2) `identity_sync.staleness_watchdog` cron** (every 6h, `37 */6 * * *` UTC; [`scheduler_handler.py`](../../server/app/domains/identity_sync/scheduler_handler.py)) closes the detection blind spot: the existing failure notifier only alerts when a run *executes and fails*, so a sweep that silently STOPS running produced no alert. The watchdog flags any enabled cron target whose last **`scheduler`**-triggered ok/partial sweep is older than one cron period + grace (config `grace_hours`, default 6) and `raise`s → the scheduled-jobs framework failure notifier pages IT admins (no dependency on the target's own `alert_recipient_ids`). It looks specifically at `scheduler` runs because `target.last_run_at` is refreshed by every per-person run and can't signal "did the sweep run". Tests: [`test_next_run_at_scheduler_only_2026_07_14.py`](../../server/tests/test_identity_sync/test_next_run_at_scheduler_only_2026_07_14.py), [`test_staleness_watchdog_2026_07_14.py`](../../server/tests/test_identity_sync/test_staleness_watchdog_2026_07_14.py).
>
> **Identity-sync drift re-verification — `last_verified_at`** (2026-07-13; migration **397** = +1 col on `identity_sync_state` + `identity_sync_department_state` / 0 cron / 0 MCP / 0 RBAC). Closes the core Authing-parity gap: the outbound sync `_sweep` hash-skip compares only the **HRIS-rendered payload** hash, never live vendor state — so an already-linked person/dept whose HRIS source is unchanged was skipped WITHOUT re-reading the vendor, and any field edited on the vendor side outside Helix (a manually-changed Feishu email/name/dept) was frozen forever (the P10012 王静文 wrong-email case; Authing, the predecessor, re-compared live vendor state every run). Now each state row carries `last_verified_at` (last REAL vendor touch — success/skipped/excluded; a hash-skip does NOT bump it), and the skip is gated on `_drift_reverify_stale()` (helper in [`service.py`](../../server/app/domains/identity_sync/service.py), tz-tolerant for naive DB datetimes): a row not vendor-verified within `_DRIFT_REVERIFY_INTERVAL` (**7 days**) is NOT skipped even on an unchanged hash — it re-runs the runner, which idempotently re-asserts the Helix-managed fields (no-op when nothing drifted) and self-heals drift + retries best-effort slots dropped last push (leader/HRBP resolved inside the runner, after the hash was computed). Wired into the user phase (all vendor kinds) AND all three dept phases (feishu/huilianyi/netsuite). Throttled analogue of Authing's every-run re-assert. Also same PR: the `feishu_contact` runner's account lookup is now **email → mobile** (was email-only; Feishu has no employee_no lookup — `user_id_type` rejects it 99992402), and on a mobile-matched existing account it PATCHes `email=work_email` to correct a mismatched registered email. Migration 397 backfills `last_verified_at = last_synced_at` (staggers the first re-verify — no thundering herd). Tests: [`test_drift_reverify_2026_07_13.py`](../../server/tests/test_identity_sync/test_drift_reverify_2026_07_13.py), [`test_feishu_runners.py`](../../server/tests/test_identity_sync/test_feishu_runners.py).
>
> **Google Directory has a post-`users.insert` settle window — `_set_suspended(must_exist=...)`** (2026-08-25; 0 migration / 0 cron / 0 MCP / 0 RBAC). A freshly inserted Google address is **not immediately readable or writable**. Measured against the real tenant with throwaway accounts: `~0.7s` → `users.get` returns **404**; `~1.5s` → 200 but the `suspended` key is **absent from the body entirely**; `2–5s` → `users.update` returns **412 `User creation is not complete.`**; `≥~6s` → 200 and the change actually applies (the read side can still lag another 20–40s before it reflects it). Two driver idioms are unsafe in that window and caused a prod incident (`hiroki.shinagawa01@pingcap.com`): (1) the **`404` ⇒ `already_absent` ⇒ `ok=True`** idempotency shortcut, which is correct for the offboarding callers (the mailbox really was hard-deleted out-of-band) but a **lie** for any caller holding an id `create_user` just returned — the create-rollback safety-suspend hit the settle-window 404, returned success, and never sent the update, leaving an active mailbox whose password nobody received while the audit row said `failure_rollback_suspend_ok: true`; and (2) treating **HTTP 200 as "state changed"** — Google echoes the resulting record, and inside the window that echo can come back with `suspended` absent or still `False`. Contract now: callers that **know** the account exists pass `must_exist=True` (404 → hard failure, never `already_absent`), `ok` is gated on the **echoed** `suspended` matching the requested state, and `412` is retried via a **per-call-site** `extra_retry_statuses` opt-in rather than being added to `_GOOGLE_TRANSIENT_STATUSES` (a 412 from any other Google surface is a genuine precondition failure and must still surface immediately). `must_exist=True` today: the create-rollback suspend and the `pending_welcome` re-suspend, both in [`account_provisioner.py`](../../server/app/domains/google_workspace/account_provisioner.py). The leaver/offboarding callers (`account_lifecycle_coordinator`, `revoke_seat`) deliberately do **not** pass it. Audit rows now also persist the driver's `raw_response` (`failure_rollback_suspend_result` / `resuspend_result`) — the bare boolean made `already_absent`, `already_suspended` and a real suspend indistinguishable in prod forensics, which is why the investigation had to be run against an already-hard-deleted mailbox. Tests: [`test_google_suspend_settle_window_2026_08_25.py`](../../server/tests/test_integrations/test_google_suspend_settle_window_2026_08_25.py).
>
> **Rehire → Google unsuspend closed loop — `_reprovision_rehired_leaver`** (2026-07-10; 0 migration / 0 cron / 0 MCP / 0 RBAC). Closes the P01485 jiong.li gap: offboarding auto-SUSPENDs a leaver's Google mailbox and leaver hygiene flips the `google` binding to `identity_status='inactive'`, but when the person is REHIRED (`leaver_pending`/`left` → `active`, SF new employment stint) nothing reversed either — the onboard `reactivate` dispatch reached [`create_account_for`](../../server/app/domains/google_workspace/account_provisioner.py), which dead-ended the non-active binding at "manual review required"; `google.seat_sync` Phase A skips suspended tenant accounts, so the seat stayed gone too. Now the `inactive` binding routes into **`_reprovision_rehired_leaver`**: mailbox **suspended** → flip to `pending_welcome` and reuse the existing heal (fresh password → welcome to personal_email → unsuspend ONLY on email success; a failed heal parks in the canonical `pending_welcome` state the self-heal machinery owns); mailbox **live** (already hand-unsuspended) → restore the SSO binding (`active` + `is_login_identity`, auth hard-rejects non-active bindings) + re-ensure the base seat, no credential rotation (`linked` outcome); mailbox **gone** (delete-sweep ran during the gap) → manual review, never silently re-mint. Guards: shared/service/retained mailboxes (`external_account_registry` known classifications — same set offboarding's suspend exempts) are never auto-touched, and `identity_status='suspended'` (the deliberate per-person admin freeze) stays at manual review. Note the related-but-different paths: offboarding **cancel** already reverses its own suspend via `account_lifecycle_coordinator.unsuspend_google_account` (2026-07-08 #1750) — this entry covers the rehire-as-new-onboard-event path, which never goes through cancel. Tests: [`test_rehire_unsuspend_2026_07_10.py`](../../server/tests/test_google_workspace/test_rehire_unsuspend_2026_07_10.py).
>
> **`core_idempotency_records` now has a cleanup cron — `core.idempotency_cleanup`** (2026-07-08; migration **394** = seed the daily job / +1 cron / 0 MCP / 0 RBAC). The idempotency table grows one row per `Idempotency-Key`'d request (asset-transfer / offboarding / Leaver-Blocked routers); keys are unique so the lazy expired-row reclaim in `IdempotencyService.lookup` almost never fires — the table grew **unbounded**, degrading every future `SELECT … FOR UPDATE` reservation. `idempotency.cleanup_expired`'s docstring had long claimed a `core_idempotency_cleanup` handler existed, but none did (nor a job row). Fix: handler [`run_core_idempotency_cleanup`](../../server/app/scheduled_jobs/handlers.py) (auto-registered — `handlers.py` is imported by both `main.py` and `worker._run`), seeded daily by migration 394, calling `cleanup_expired` which now deletes in **bounded `batch_size` (default 500) chunks** — a single unbounded DELETE against a months-old backlog can trip TiDB's "transaction too large" and fail every run (unbounded is the worse failure mode). Paired same-batch fix: SSO app-catalog CSV import (`app_catalog_service.upsert_app`) now attempts each row's INSERT inside a **SAVEPOINT** (`begin_nested`, add-inside) so a unique-collision rolls back only that row — the old `db.rollback()` on `IntegrityError` rolled back the whole session, silently discarding every row a batch had already staged (data loss reported as success).
>
> **Single-person provisioning now runs the dept/group phase (feishu + huilianyi + scim)** (2026-07-07; 0 migration / 0 cron / 0 MCP / 0 RBAC). The full sweep's Phase A (dept adopt/create, or SCIM group create) does NOT run on the per-person event-hook path (`sync_person_to_target` ← `events.enqueue_person_sync`), so a new hire whose dept/group hasn't been swept yet was mishandled per kind. Fixed across every kind that has a dept-object model: **feishu_contact** (root cause — Ritika Bharati P10002 showing "Department = SV Office" with no business dept): (1) `sync_person_to_target` runs a **restricted Phase A** over the person's primary-membership dept + ancestor chain before Phase B (`_run_feishu_dept_phase(restrict_dept_codes=…)` + generic `_person_dept_chain`; `restrict` implies `push_only` → never soft-deletes; skipped when the chain is already fully adopted); (2) the runner now writes `orders.is_primary_dept=true` on the business dept for both POST-create and PATCH so the office node (SV Office etc.) no longer shadows it as the card's 主部门 — `orders` must accompany `department_ids` (API 44002) and is dropped on the slot-skip / dept-not-found / recreate fallbacks; **zero-churn guard** — PATCH only sends `orders` when the business dept isn't already primary (`_get_user_current_dept_state` reads back the current primary). **huilianyi**: same restricted-Phase-A ensure (`_run_huilianyi_dept_phase(restrict_dept_codes=…)`) so a new hire's `custDeptNumber` exists before Phase B, instead of the user create hard-failing `120103 部门不存在` until the next full sweep. **scim (AWS SSO/AI) / navan**: the event-path `_add_member_to_dept_group` now **creates the missing dept group on the spot** (`_ensure_scim_dept_group`, reusing the extracted `_resolve_scim_group_names` disambiguation so the event-created group == the nightly-swept group), instead of skipping the membership until the nightly sweep. **netsuite** (dept sync gated off by default) and **google_schema** (department is a plain string attribute) have no dept-object model and are unaffected.
>
> **International assignment (外派) is first-class HRIS data** (2026-07-07; migration **392** / 0 cron / 0 MCP / 0 RBAC). PingCAP models global assignments as **custom fields on SF EmpJob** — the standard `EmpGlobalAssignment` entity is empty for this tenant. Field IDs confirmed via live `$metadata` labels (NOT guessed): `customString44`=外派前国家 (home), `customString38`=外派后国家 (host), `customString39`=外派状态 (status), `customDate2`=外派开始日期, `customDate4`=外派结束日期. The HR sync ([`_apply_event`](../../server/app/domains/hr/service.py) via `list_current_jobs`) lands these on `identity_persons`: `assignment_home_country` / `assignment_host_country` (ISO-3), `assignment_start_date` / `assignment_end_date`, `assignment_status_code` (RAW — the SF status picklist doesn't resolve cleanly over OData, so the raw code is stored for fidelity, mirroring `sf_downstream_push_code`). **On-assignment signal:** `IdentityPerson.is_on_international_assignment` (host set AND host != home) — reliable + picklist-free. **Scope:** supplementary context for HR reports / person detail; host country does NOT repoint directory placement (office/company/dept stay as the home EmpJob) — only the phone dialing code (see below) + display use it.
>
> **Mobile carries its OWN country code — `identity_persons.mobile_country_code`** (2026-07-07; migration **391** / 0 cron / 0 MCP / 0 RBAC). The mobile's dialing code is now derived from the phone's OWN country (SF `PerPhone.countryCode`, a `CountryCode` picklist option whose `externalCode` is the ISO-2 — option 1734 → `SG`), not the person's home `country`. Root cause: an international-assignment employee (home CN, on assignment to SG) carries a host-country phone (+65); deriving `+86` from the home country produced an invalid number Feishu rejected (`41004 mobile is invalid`). Pipeline: [`SuccessFactorsClient.list_personal_phones`](../../server/app/identity/successfactors_client.py) now selects `countryCode`; `list_country_code_picklist()` resolves option→ISO-2; the HR sync writes `identity_persons.mobile_country_code` (ISO-2, from the SAME primary PerPhone row as the number). The feishu preset threads it as the synthetic `mobile_country_iso2`, and [`_format_mobile_e164`](../../server/app/domains/identity_sync/runners/feishu_contact.py)'s new `phone_country_iso2` param takes **precedence** over every home-country signal (set-but-unmapped → drop, never guess home; absent → fall back to home-country derivation). **Defense in depth:** a still-invalid mobile (`_MOBILE_INVALID_CODES={41004}`) makes the user PATCH/POST retry WITHOUT the mobile slot — the rest of the fields land and Feishu keeps its existing number, never failing the whole person. **Scope decision:** an assignee's host country drives the phone dialing code + display only; office/company/dept stay as SF EmpJob (host country does NOT auto-repoint directory placement).
>
> **↑ CORRECTED 2026-09-01 — that preset threaded the RAW column behind an ISO-3 value-map, and that was wrong.** The line above says the feishu preset "threads it as the synthetic `mobile_country_iso2`"; until 2026-09-01 it did so as `{mobile_country_code}` + `value_map=COUNTRY_ISO3_TO_ISO2` (passthrough false), on the belief that SF hands us ISO-3. **SF does not.** Live picklist pull 2026-09-01: sf-cn-prod 17 of 19 `CountryCode` externalCodes are ISO-2, sf-eu-prod 17 of 27 — the remainder ISO-3 plus the free-text `GB / UK`. An ISO-3-keyed map misses every already-correct ISO-2 (`CN` is not a key, `CHN` is), renders empty, and `_format_mobile_e164` falls back to the person's WORK country — invisible whenever the two agree, wrong every time they don't. Prod cost, measured from the wire bodies in `identity_sync_run_items`: of 411 synced phone-holders, **18 were written with a wrong dialing code** and **9 were rejected 41004 and slot-dropped** (so Helix silently stopped maintaining their number). All 5 active HKG staff were in the 18 — they are HK employees carrying mainland SIMs, i.e. exactly the work-country ≠ phone-country case. The fix is one rule: source the DERIVED `{mobile_country_iso2}` token (see the mixed-shape note near the top of this file) with no value_map — the same token 汇联易 adopted on 2026-08-27. **The lesson worth keeping: the normaliser already existed and was wired to one downstream only. When you add a cleaning token for a mixed-shape HRIS column, grep every consumer of the raw column in the same PR** — a half-wired normaliser reads as "handled" at review time and fails silently on exactly the cohort it was built for.
>
> **Google account welcome-email self-heal — `identity_status='pending_welcome'`** (2026-07-07; 0 migration / 0 cron / 0 MCP / 0 RBAC). Closes a latent HIGH gap in [`account_provisioner.create_account_for`](../../server/app/domains/google_workspace/account_provisioner.py): when a mailbox is minted but the welcome email (first-login password) fails, the code suspends the mailbox (deliberate security control — never leave an active mailbox with an undelivered password) but used to leave the identity `active`, so every re-run short-circuited `skipped` forever AND `google.seat_sync` drift-cleanup stripped the suspended account's base seat after 2h = permanent lockout on any email outage. Fix: a first-class self-heal state **`identity_status='pending_welcome'`** (15 chars, fits `String(16)`; `auth/service.py` blocks login on any non-active status, and the onboarding cockpit's active-login readiness excludes it — both correct for a suspended, credential-undelivered account). On partial failure the create path suspends **and** flips to `pending_welcome`; on the next `create_account_for` run the short-circuit enters **`_reprovision_pending_welcome`** — regenerate password → `reset_user_password(change_password_at_next_login=True)` → re-send welcome → unsuspend and promote to `active` **ONLY on email success** (email fails again → re-suspend + keep pending; unsuspend fails → keep pending, report `failed`). A leaver is never re-provisioned (the top-of-function `employment_status=='active'` gate skips them before the short-circuit). [`google_handlers.run_google_seat_sync`](../../server/app/scheduled_jobs/google_handlers.py) base-seat drift-cleanup skips persons with a `pending_welcome` google identity so the seat survives the pending window. No coordinator change — re-provision returns the existing `created`/`failed` outcomes the admin-notify path already handles. (Also resolves the sibling finding that a doubly-failed welcome left the temp password unrecoverable: re-provision regenerates it, so no new plaintext-at-rest site was added.)
>
> **PRIMITIVE — person-name sentinel sanitiser (`app/core/name_sanitize.py`)** (2026-07-14; 0 migration / 0 cron / 0 MCP / 0 RBAC). `strip_sentinel_name_tokens(name)` = the single chokepoint that removes a literal `null`/`none`/`undefined` token (whole-word, case-insensitive) an upstream string-concat left in a person name. Root cause: SF composes `displayName` as `"<CJK 名> <English name>"` and, when the English half is absent, serialises the gap as the literal `null` → `"张东晖 null"`, which `_upsert_person` stored verbatim and then leaked into directory search + `name_pinyin`, feed celebration snapshots, `software_monthly_bills.user_name`, and the outbound `latin_name`→Feishu `en_name` transform. Whole-token match only, so a real name like `Knull Hu` (substring "null") is untouched; all-sentinel → `None` so callers' `... or fallback` chains fire. Applied at HRIS ingest (`hr/service.py::_upsert_person` + the two leaver/preview `person_data` builders) and defensively in `identity_sync/mapping.py::latin_name`. **Reuse this — do NOT hand-roll a `.replace("null","")`** (that mangles substrings). **(2026-07-15)** same file now also exports the language-split helpers `chinese_name` / `english_name` / `localized_name(display_name, *, is_en)` (same CJK regex as the identity_sync `latin_name`/`cjk_name` transforms): the CJK half of a bilingual `"孟凡生 Jason Meng"` for zh readers, the Latin half for en, each falling back to the whole name when its half is absent. Used by the Day-1 welcome to render people names (self / manager / HRBP / network owner) language-matched.
>
> **Dept-mapping audit alerts — non-blocking recurrence guard (huilianyi + NetSuite)** (2026-07-07; 0 migration / 0 cron / 0 MCP / 0 RBAC). Same "Helix SF code vs legacy/Authing code" root cause guarded in both vendors, as **non-blocking** IT heads-ups (user decision: NOT hard errors — that would block genuinely-new depts, and cross-system name-matching is unreliable, so the map/adoption decision stays human). Both live in [`alert_notifier.py`](../../server/app/domains/identity_sync/alert_notifier.py), are `spawn`-ed fire-and-forget at the end of their dept phase (**never touch run status — the sync stays green**), reuse the run-failure notifier's `alert_enabled` + `alert_recipient_ids` audience (no admin fallback), and dedup by the affected-SET signature stored in the prior alert's outbox `context` (the outbox IS the dedup state — no new columns). **(1) Huilianyi `maybe_notify_unmapped_depts`** — the `huilianyi_dept_code_map` (hand-maintained SF→legacy translation) silently passes an UNMAPPED code through as the raw SF code → parallel vendor dept mismatching the NS code (the "AI Lab split"; recurred 3× in prod: `4019716 AI Lab`, `3963958 IT Services & Security`, `2730378 Business Operations`). Fires from `_run_huilianyi_dept_phase` (every full sweep) for active nodes with no map entry; empty map = passthrough-by-design → no alert. **(2) NetSuite `maybe_notify_unadopted_ns_depts`** — NS depts are Authing-era, keyed by internal id with an EMPTY externalId; an **unadopted** node (no `eid:` match) is safely SKIPPED while the dept runner is update-only, but the moment `netsuite_create_missing` is flipped on it is CREATED as a parallel SF-coded dept — the same split on NS. Fires from `_run_netsuite_dept_phase` (gated behind `netsuite_sync_departments`, so silent until dept sync is enabled), builds an NS client + SuiteQL's the adopted-externalId set, and lists unadopted nodes as a **pre-flight before create_missing** (warn tone; danger + "will create parallels" once create_missing is on). Aborts (no alert) if the externalId set can't be read — never storms on an unreliable set. Turns "finance finds the split weeks later" into a same-day heads-up. (Note: the target's `alert_recipient_ids` must be populated for delivery — `huilianyi-prod`'s is currently empty.)
>
> **HR-relationship sync = 3 SF roles + Feishu email sibling-resolve** (2026-07-06; migration **388** / 0 cron / 0 MCP / 0 RBAC). The SF `EmpJobRelationships` (工作关系) sync now pulls **three** relationshipType codes into `identity_persons`: `2448`→`hris_hrbp_person_id` (HRBP), `2447`→`hris_budget_owner_person_id` (调薪负责人), and **new `2450`→`hris_hrops_person_id` (HR Ops)** — all via `apply_hris_role_relationships` (apply-only, never clear-on-absence; self-reference/unresolvable clears). The new-hire **"account created" card** (`hr_sync_coordinator._notify_admins_google_account_outcome`) and the personal-address **first-login password email CC** fan out to **HRBP + HR Ops + all active local office asset admins + configurable IT contact** (`google.account_provisioning.created_notify_emails`, default 孟凡生), deduped by person id/address. Local office resolution is the asset-domain canonical `work_location_name / city → asset_office_addresses` mapping; all active 当地 Admins are included, not only the primary (the table was renamed `asset_office_local_admins` and the `admin_person_id` mirror column removed by migrations 535 / 537; the audience rule is unchanged). **Feishu resolution fix**: [`FeishuApprovalClient.resolve_user_id_by_email`](../../server/app/approval/feishu_client.py) now tolerates the `.com`/`.com.cn` corporate sibling (`_PINGCAP_EMAIL_SIBLING`) — Helix stores `@pingcap.com` but many Feishu accounts are `@pingcap.com.cn`; the old exact-match silently missed, so HRBP/leader **user-reference** pushes were omitted and the Feishu field never updated. Now it queries both siblings and matches by local-part within the pingcap pair (never crosses to an unrelated domain); when an HRBP email still doesn't resolve, the `feishu_contact` runner logs a WARN instead of silently dropping the slot. **Data caveat (not a code bug):** SF `2448` correctly resolves to a real HRBP for CN, but some US rows have an HR Generalist sitting in the HRBP slot — HRIS data to fix in SF (impact table handed to HR), the mapping is unchanged.
>
> **Day-1 provisioning chain — timezone-correct, hourly, phone-resilient** (2026-07-06; migration **387** = re-enable + hourly the hire-day cron / 0 MCP / 0 RBAC). Root-caused from a US new hire (P10002 Ritika Bharati, New York) with no Google account + no welcome email on her start day. Five fixes to the deferred-hire catch-up path: **(1)** the ONLY cron that provisions a deferred hire's Google account + Day-1 email (`hr.hire_day_it_notify` → `HireDayNotifyService.run_for_today`, which re-fires `provision_for_new_hire` + `provision_google_account` + the IT-info email) was `enabled=0` in prod since 2026-04-24 — every deferred US/EU hire silently got nothing. Migration 387 **re-enables** it and moves it from **daily 09:00 Asia/Shanghai → hourly** (`0 * * * *`) so every timezone is caught within ~1h of the moment their provisioning window opens (the per-person `is_future_hire` gate + `it_info_sent_at` idempotency keep each hire once). **(2)** [`working_hours.resolve_timezone`](../../server/app/core/working_hours.py) was keyed by ISO-2/names but HRIS stores **ISO-3** (`USA`/`CHN`/`JPN`/…) → every non-US hire silently resolved to Asia/Shanghai; added ISO-3 aliases + **city-level refinement** for multi-zone countries (`_CITY_TO_TZ`, US/CA) so New York → Eastern, Denver → Mountain, Vancouver → Pacific instead of the country default. **(3)** [`account_provisioner.create_account_for`](../../server/app/domains/google_workspace/account_provisioner.py) now **drops the (optional) recovery/home phone and retries the same address once** when Google rejects it (`_is_phone_error`; a Malaysia hire's E.164-shaped number 400'd "Invalid recovery phone" and blocked the whole mailbox — 2026-07-02). **(4)** the failed-account admin card (`_notify_admins_google_account_outcome`) is now **deduped per hire (20h window)** so the hourly cron can't storm the HRBP/IT contact on a permanently-failing hire. **(5) 24h-ahead provisioning** (SF-agreed 2026-07-06): `is_future_hire` now carries a `DEFAULT_PROVISION_LEAD_HOURS=24` lead, so the Google account + Day-1 welcome/password mail are made ready **~24h BEFORE** the employee's local start date (account live + credentials delivered the day before day-1; "info received <24h before start → create immediately" falls out for free). Software-seat/permission provisioning-task `not_before` is unchanged (still hire-date local midnight — seats activate on the actual start day); `run_for_today`'s candidate window widened to `[target-1, target+2]` so eve-hires in every timezone enter the 24h window on time. Left as-is (correct behaviour): the failed-welcome-email → auto-suspend rollback, and INVALID_OU_ID as a loud failure (self-healed once the `/PingCAP` OU existed). **Hire-date corrections (2026-08-24):** both staged HRIS confirmation and real-time webhook processing compare the pre-apply and authoritative `identity_persons.hire_date`; a changed date calls `hr_sync_coordinator.refresh_onboarding_for_hire_date_change` and only re-enters `OnboardingService.start_process`. This corrects the existing process date (and demotes a stale future `live` process) without treating an active employee as a second new hire — no account provisioning, welcome mail, or permission fan-out is repeated. **Feishu identity preservation (2026-08-24):** overseas and China users retain both the work email and mobile fields on Multi-Geo Directory creation and Contact v3 projection. The admin-console “登录邮箱” is an independent vendor account property, so it must be set through its documented account-management surface rather than by suppressing the mobile field.
>
> **Stale-backfill hire gate — the symmetric partner of `is_future_hire`** (2026-07-08; migration **396** = retro-mark existing rows / 0 cron / 0 RBAC / 0 MCP). `is_future_hire` stops provisioning that fires too EARLY; [`is_stale_backfill_hire` / `hire_date_is_stale_backfill`](../../server/app/core/working_hours.py) (const `STALE_HIRE_BACKFILL_DAYS=90`, employee-local TZ) stops it firing far too LATE. When an existing / long-tenured employee is first synced into Helix long after their start date (HRIS-late detection / bulk backfill), their genuine hire_date flows in months/years in the past; with no past-side gate the pipeline treated it as a same-day new hire — scoring an instant SLA breach that paged all IT「已入职 N 天仍未配齐」and auto-minting Google/seat/VPN accounts the person already had (2026-07-08 roger.zhou03 incident: 杨懿梅 Julia, SF P01572, hire 2021-07-21, first synced 2026-07-08 → days_past 1813). Fix wires the long-dormant `onboarding_processes.backfilled` flag (only ever set by the migration-229 one-time seed) onto the live path: `OnboardingService.start_process` marks `backfilled=True` at birth for a stale hire_date (the day0 / SLA-urgent / SLA-breach dispatchers + t7 verify + analytics already filter `backfilled`), and `hr_sync_coordinator`'s `provision_for_new_hire` / `provision_google_account` / `send_onboarding_it_info` each skip on a stale hire right after their future-hire defer (identity-sync directory push is deliberately NOT gated — an existing employee belongs in the directory). Migration 396 retro-marks existing OPEN processes with `DATEDIFF(created_at, hire_date) > 90` (row created long after the hire date = late ingest, vs a genuinely months-stalled onboarding). NOT changed: the SSO/email readiness lights — a `pending_welcome` identity is login-blocked in `auth/service.py`, so the account really is unusable and the red light is correct.

> **HRIS-purged tombstone gate — the third hire-day provisioning gate** (2026-07-12; 0 migration / 0 cron / 0 RBAC / 0 MCP). `is_future_hire` stops provisioning that fires too EARLY, `is_stale_backfill_hire` too LATE; [`_is_hris_purged`](../../server/app/core/coordinators/hr_sync_coordinator.py) stops it firing for a record that no longer EXISTS. `hris.absent_person_check` stamps `hris_record_state='purged'` when a person vanishes from the SF full roster (HR deleting a botched entry is the common cause) but deliberately leaves `employment_status='active'` — and every hire-day gate only looked at status + hire_date. 2026-07-11 roger.zhou02 incident: HR entered future hire P10012 with a cloned junk email, deleted the SF record the same day, Helix tombstoned it — and the hourly hire-day cron still minted the live mailbox `roger.zhou02@pingcap.com` at the 24h-lead tick and mailed credentials to the personal inbox (P10014/roger.zhou01 was queued to repeat a week later). Fix: purged short-circuits in the three `hr_sync_coordinator` hooks + `reserve_and_writeback_email`, backstops in `account_provisioner.create_account_for` / `reserve_work_email_for`, and `hris_record_state != 'purged'` in the three `hire_day_service` candidate queries (Day-1 notify / Day-90 survey / Feishu-welcome catch-up). Identity-sync targets already skipped purged rows — the account minter now agrees. HR re-entering the same employee number flips the state back to `present` and provisioning resumes unaided. Regression: `tests/test_google_workspace/test_purged_hire_gate_2026_07_12.py`. **Part 2 (same day)**: the purge itself was a FALSE positive — SF Onboarding future hires sit at `User.status='active_external_suite'` (EU sometimes `'f'`) until day 1, invisible to the `'t' or 'f'` roster filters, and their future-dated EmpJob is structurally absent from `list_current_jobs` (effective-as-of-today). Fixes: shared [`_USER_STATUS_ANY_FILTER`](../../server/app/identity/successfactors_client.py) (t/f/active_external_suite) on `list_all_users` + `list_users_modified_since`; incremental routing treats `active_external_suite` as active and `'f'`+future-hireDate+no-endDate as an onboard (P10013 Robert Wood shape); `run_hris_absent_person_check` skips future-hire candidates. Regression: `tests/test_hr/test_hidden_status_future_hires_2026_07_12.py`.

> **Live SF presence re-check at mint time — the fourth hire-day provisioning gate** (2026-07-16; 0 migration / 0 cron / 0 RBAC / 0 MCP). The three gates above stop provisioning that fires too EARLY (`is_future_hire`), too LATE (`is_stale_backfill_hire`), or for a record already tombstoned (`_is_hris_purged`) — but the tombstone only exists once the daily `hris.absent_person_check` has recorded the deletion, and that check deliberately EXEMPTS pre-start hires (a future-dated EmpJob is structurally absent from `list_current_jobs`, so roster absence means nothing — the 静文/亦寒 false-tombstone lesson). Net effect: an SF entry HR hard-deletes 1-2 days before start (the roger.zhou02 shape) stays `hris_record_state='present'` straight through the 24h-lead mint window and the purged gate never fires. Fix: [`HireDayNotifyService._filter_by_live_sf_presence`](../../server/app/hr_lifecycle/hire_day_service.py) re-verifies every pre-start candidate (`hire_date >= as_of` Asia/Shanghai — the same rule-1 boundary as `classify_inactive_sf_user`) against the live SF `User` entity (`get_users`, which spans every known status incl. `active_external_suite`) on every mint tick, BEFORE any coordinator hook runs, on BOTH mint paths (main Day-1 loop + the account backfill that reaches `it_info_sent_at`-stamped hires — the 王静文 P10012 shape) via a shared per-run memo (one SF query). Confirmed-gone hires get the standard soft tombstone (same fields / `hris_purged` lifecycle event / `hris.absent_person_detected` audit action as the daily check; `hris_purged_reason='absent_at_hire_day_recheck'` keeps forensics separable) so every downstream consumer — #1905 purged gates, admin badge, login blocking, sync-side un-purge on re-appearance — behaves identically. Failure semantics: SF query error = fail-CLOSED for that instance's candidates (defer this tick, no tombstone; the hourly cron retries well within the 24h lead); unresolvable instance / non-SF source = fail-OPEN (legacy behaviour preserved); >10 absentees in one tick trips a brake that defers everyone instead of tombstoning (an SF auth/scope regression or a NEW `User.status` value invisible to `_USER_STATUS_ANY_FILTER` — the 2026-07-12 `active_external_suite` trap shape — must not mass-tombstone a cohort). Regression: [`test_hire_day_sf_presence_recheck_2026_07_16.py`](../../server/tests/test_hr_lifecycle/test_hire_day_sf_presence_recheck_2026_07_16.py) (9 cases incl. gate-consistency: the re-check's tombstone trips all three coordinator purged gates).

> **Pre-hire welcome completeness — effective-dated HRIS data at the 24h-lead build + hire-day fallback** (2026-07-20; 0 migration / 0 cron / 0 RBAC / 0 MCP; yihan.mo P10014 post-mortem). The 24h-lead build (above) makes accounts + welcome/password mail ready ~a day early — but a hire's **department (EmpJob), HRBP (SF 2448), HR Ops (SF 2450)** are effective-dated and only become SF's current slice ON the start date, so at build time all three Helix reads (`list_current_jobs` `effectiveLatestChange eq true`; `get_jobs_for_users`/`list_job_relationships` no date param = as-of-today) return nothing. Symptoms: Feishu account falls back to root dept `"0"` (no dept/group — #1987 self-heals on the start-date re-push), the welcome email CC's nobody (当地 Ops = the hire's HR Ops 2450 never gets it), and the Feishu welcome card gets DM'd ~a day early to a not-yet-activated account (never seen, then stamped so never re-sent). **Source pull**: [`SuccessFactorsClient`](../../server/app/identity/successfactors_client.py) `get_jobs_for_users`/`list_job_relationships` gain `effective_as_of` → SF EC `fromDate=toDate=<hire_date>` navigation params (`_effective_asof_params`) read the slice effective on day-1; wired **for pre-start hires only** (they have no current row → fills empty, never front-runs a live employee's future transfer) via `apply_hris_role_relationships(effective_as_of=…)` on the webhook onboard path + new [`HireDayNotifyService._enrich_prestart_hire_relationships`](../../server/app/hr_lifecycle/hire_day_service.py) before the welcome email. `effective_as_of` is passed ONLY when set, so every existing caller/test-fake signature is byte-identical. HR contract for pre-populating the future-dated rows: [`docs/integrations/successfactors-future-hire-fields.md`](../integrations/successfactors-future-hire-fields.md). **Fallback (correct even if HR never pre-enters)**: `send_feishu_welcome_card` now gates on the local start date (`is_future_hire(lead_hours=0)`) so the card fires on day-1 to a live account; `run_feishu_welcome_catchup` (30-min, 3-day window) does double duty — Feishu card + the HRBP/HR Ops CC copy (new `_send_welcome_hr_copy`, reusing the extracted `_render_welcome_payload` so HR sees byte-identical content). `notify_person` stamps `hr_cc` only when the CC actually lands, else the catchup delivers it once the relationship syncs; a hire with no HR relationship is never a perpetual candidate. **Department at build (follow-up, same day)**: the source pull above enriched only the RELATIONSHIPS at build; the **department** still fell to `"0"` at build and self-healed on the hire date. Closed by [`HireDayNotifyService._enrich_prestart_hire_department`](../../server/app/hr_lifecycle/hire_day_service.py) — runs BEFORE the Feishu-account mint in `run_for_today`, pulls each pre-start hire's `EmpJob` `effective_as_of=hire_date`, and writes the department (primary `IdentityOrgMembership.org_node_source_id`, the sole source of Feishu `department_ids`) + manager by reusing `SuccessFactorsLifecycleService._ensure_master_data_loaded` / `_upsert_dept_membership` / `_assign_manager` (no re-implementation). So the Feishu account lands in the right department AT the 24h-lead build, not on the hire date. **Live-SF validated** (P10015, Aug-1 hire, EU tenant): as-of-today `EmpJob=[]`; `effective_as_of` returns `department=2181699 / manager=P0337` + `2448=P01269 / 2450=P01146` — the SF `fromDate/toDate` param works and the future-dated rows are already entered ahead. Regression: `tests/test_hr/test_prehire_welcome_completeness_2026_07_20.py` (9 total: card gate 2 + HR CC 3 + department-at-build 3 + …) + `tests/test_identity/test_successfactors_client.py` (+2).

> **Cancelled-future-hire cleanup — daily directory analogue of the fourth gate** (2026-07-17; 0 migration / 0 cron / 0 RBAC / 0 MCP; P10019 post-mortem). The fourth gate above re-checks pre-start hires against live SF, but only inside the **24h-lead mint window** and it only **tombstones** (`purged` doesn't hide a row from the directory — it just blocks login/provisioning/sync). So a future hire SF cancels weeks ahead (P10019: displayName "Unknown", hire_date 2026-07-26, ingested & cancelled the same day 2026-07-17) sits `present`/`active`/visible for the whole gap, invisible to the daily [`run_hris_absent_person_check`](../../server/app/scheduled_jobs/handlers.py) because its roster diff EXEMPTS `hire_date >= today` (the 静文/亦寒 lesson). Two-part fix: **(1) ingest guard** — module-level [`is_shell_sf_record`](../../server/app/domains/hr/service.py) (`displayName` empty/placeholder AND no first/last name; three-way AND → zero false-positives for CJK-only names) blocks `_stage_event` from CREATING a person from a stripped shell SF emits for a withdrawn requisition (UPDATE to an existing person still flows). **(2) daily cross-check** — the absent-check no longer skips future hires; it collects them and verifies each against SF by userId via `get_users` (ANY-status, returns `active_external_suite` too). A userId SF no longer returns is a cancelled hire and — having nothing downstream to offboard — is **soft-deleted** (`deleted_at` + `purged` tombstone + SF `identity_external_identities` soft-deleted in lockstep + `hris_purged` lifecycle event + `hris.cancelled_future_hire_removed` audit), so it disappears from every view. Reversible: `_upsert_external_identity`/`_upsert_person` are soft-delete-tolerant (revive on re-import, no 1062). Guards: roster non-empty already proves SF is up, plus "get_users empty for a ≥2 set = SF scope/outage, delete nobody" and the roster's >50%-with-≥4 ceiling. **Boundary (documented, not a gap for the reported case):** the cross-check only reprocesses `hris_record_state='present'` future hires — a hire the fourth gate already tombstoned to `purged` at hire-day stays purged-but-visible until start; the reported P10019 shape (present, weeks out) is fully closed. Regression: `tests/test_hr/test_absent_person_check.py` (+3) + `tests/test_hr/test_hidden_status_future_hires_2026_07_12.py` (+2). **Revive gap closed (2026-07-20, P10019 陈建华 follow-up):** the "Reversible … `_upsert_person` soft-delete-tolerant (revive on re-import)" claim above was only HALF true — `_upsert_person`'s re-appearance handler cleared the **purge** half (`hris_record_state`/`hris_purged_at`) but NEVER `person.deleted_at`. P10019 arrived nameless → cleaned up (both markers) → SF re-sent an onboard 3 days later WITH a real name, but the row stayed soft-deleted (purge got reset, `deleted_at` did not) → skipped by every `deleted_at IS NULL` query incl. the 建号 flow → no accounts on the 07-23 start. Fix: `_upsert_person` now clears `deleted_at` on an **onboard** re-appearance (offboard never resurrects a leaver), dedup-guarding the shared `hris_unpurged` lifecycle row via a pre-captured `_was_purged`. Regression: `tests/test_hr/test_soft_delete_onboard_revive_2026_07_20.py` (3). Data-fixed P10019 in prod (cleared `deleted_at`) so hire_day provisions him for 07-23. **Upstream debounce (2026-07-20, migration 421):** the daily cross-check's `(2)` soft-deleted a future hire on a SINGLE absent `get_users` run (a lone absence passed straight through) — but SF-Onboarding pre-hires flicker in/out of `get_users` while their requisition is in flux, so a transient one-day absence is exactly what tombstoned the real pre-hire P10019. Destroying a row on one failed poll is the anti-pattern. New nullable `identity_persons.hris_absent_since` records first-seen-absent; the cross-check now only soft-deletes after the absence persists past `_FUTURE_HIRE_ABSENCE_GRACE` (20h → ≥2 consecutive daily runs), and a return clears the marker. The mass-blip brake still runs first. With the onboard revive above, even a wrong delete self-heals — belt + suspenders. Regression: `test_absent_person_check.py` (cancelled-hire test → two-phase grace→delete; +1 return-clears-grace).

> **Termination-reason exclusion — 内部调动/人员分类转换/兼岗结束不关号** (2026-07-30; 0 migration / 0 cron / 0 RBAC / 0 MCP; HRIS 对齐). SF writes a REAL `EmpEmploymentTermination` row + flips `User.status='f'` for **D03 调往中国数据中心 / D04 兼岗结束 / D07 人员分类转换 / D08 调往德国数据中心** — but the human keeps working, so no path may close their accounts. HRIS filters these push-side, but that's an implicit rule Helix can't audit and the nightly full-sync backstop reads raw OData — so Helix re-derives the verdict itself on **every** offboard ingest: webhook (`process_webhook_events` — skips + writes a first-class **`offboard_skipped`** ledger row with the reason, own dedup key so a later REAL offboard on the same date still applies), full sync + incremental + single-person refresh (`classify_inactive_sf_user` new `termination_reason_code`/`excluded_reason_codes` params → `LIFECYCLE_REASON_EXCLUDED`), and the manual window import (`preview_import` skip). Reason source: `EmpEmploymentTermination.customString85` (picklist `EcEvenReason2`) — the RAW value is the **option id** (2567), NOT the externalCode HR quotes; [`SuccessFactorsClient.get_termination_reasons_for_users`](../../server/app/identity/successfactors_client.py) resolves via `$expand=customString85Nav` (verified live on both tenants; ids+codes identical CN/EU) and degrades to raw option ids on tenants that reject the expand. Config: SF driver field **`excluded_termination_reason_codes`** on each hris integration (`config_public`; externalCodes and/or option ids; **empty = feature off = pre-change behaviour** — ⚠️ must be configured on BOTH prod integrations post-deploy). Cost gates: no config → zero SF calls; already-`left` persons skipped (`_termination_reasons_for`); lookup failure **fails open to offboard** (a broken lookup must never keep a real leaver's access alive). Webhook events may also carry `termination_reason` inline (trusted, skips the pull). `hris_status_divergence` findings for `'f'` persons are annotated with the reason (+"已配置为不关号——预期差异" when excluded). Cross-DC transfer's second half: the target tenant's onboard for a person owned by the other instance is classified update/reactivate (never `create` → never a second mailbox/Google account) when `owned_company_codes` says the legal entity is ours — webhook pre-lookup now mirrors `_upsert_person`'s reclaim verdict. Regression: `tests/test_hr/test_offboard_reason_exclusion_2026_07_30.py` + classifier/client tests. Contract doc: [successfactors-webhook-setup.md](../integrations/successfactors-webhook-setup.md) §4. **Cross-tenant offboard adoption follow-up** (2026-07-31; live repro = HRIS's first `_diagnose` push, P01550, `resolved_persons: 0`): PingCAP enters overseas terminations in the **EU** tenant at approval, but prod probe showed ALL 20 recent EU-tenant terminations belong to **CN-pinned** Helix rows (the CN tenant mirrors the people; the termination mirrors into CN only later — sometimes after the last working day), so the EU webhook is the only timely offboard signal and `_upsert_person`'s empId cross-instance branch used to reject it with ConflictError (onboard was the only reclaim shape). Now an offboard with a substantiated `endDate` **adopts** the foreign row — re-pinning is load-bearing: the CN tenant still reports the person active until the mirror lands, and the CN nightly sync would otherwise stage a reactivate for a leaver_pending row it owns; once re-pinned, the CN sync's cross-instance guard skips it. The excluded-reason skip ledger + the slug-scoped `_diagnose` person resolution use the same global fallback (diagnose flags it `cross_instance: true`, read-only). Regression: `test_cross_tenant_offboard_adoption_2026_07_31.py`. **Round 2 same day (#2209)** — the five REAL pushes were still dropped, three stacked defects: ① `_overlay_event_fields` used `setdefault`, but SF's `get_users` returns every `$select` column so the EU tenant's `empId: null` counts as key-present and the event's empId carry field was discarded → every carry field is now fill-if-FALSY; ② the adoption was empId-keyed only → added a **userId-keyed adoption** requiring ONE corroborating identity fact (global 工号 / corporate email / exact display name — bare userId is not proof across tenants); ③ the apply-side stale-endDate guard (孟凡生 rehire-artifact fix) was unscoped — SF keeps `status='t'` until the LWD passes, so EVERY approval-time offboard push was silently reverted to active/None (ledger + process existed, person never turned `leaver_pending`, offboarding cockpit stayed empty) → the guard now ignores only PAST endDates; today-or-future = scheduled termination, applies. Dropped events were replayed from the audit-log payloads (full webhook bodies are always preserved there — the recovery path when a background apply is lost).

> **一个人两段并行雇佣 —— `identity_hris_user_aliases`（HRIS userId 别名）** (2026-08-18, migration 514 / 0 cron / 0 RBAC / 0 MCP). **SF 用 `personIdExternal` 标识「人」，用 `userId` 标识「一段雇佣」；Helix 用 `userId` 当人的主键（`identity_persons.hris_person_id`）。两者一致，直到某人同时持有两段雇佣** —— 外派 / 第二法人主体。这时组织线挂在「带团队的那段雇佣」上，于是 HRIS 里所有对这个人的引用（`EmpJob.managerId`、`EmpJobRelationships.relUserId`）指向的是 Helix **故意不建行**的那个 userId。生产实测 2026-06-26：孙若曦 = `P0050`（美国主体，2018 起）+ `80100061`（带 Efficiency & Quality 的 CN assignment），两段 `endDate` 均为 NULL，`personIdExternal` 都是 `P0050`；朱明 = `P0809` + `80100062`；Terry Purcell = `P01134` + `80100057`。后果:6 个在职员工的 `manager_person_id` 被 clear-on-miss 规则置 NULL（用户详情页显示「无上级」），而 `backfill_manager_links` 追的是同一个列所以**每次同步都再漏一次**；`80100061`/`80100062` 每 8 小时被重新 stage 成 `create`（入职页幻影新人卡）；早年两条 ghost person 行（`80100057`/`100113`，`@successfactors.local` 合成邮箱）**2026-05-07 的清理脚本删不掉** —— `_upsert_person` 的 onboard 分支会清 `deleted_at`/`purged`，下一轮同步把它们复活。新表 `identity_hris_user_aliases`(`alias_user_id` → 规范人;UNIQUE `(source_system, alias_user_id)`,编码的是主键列同样的不变量:一个 userId 最多对应一个人)就是缺失的那层间接。**解析顺序在一个地方讲清:先主键列、再别名表** —— `identity_reconcile.resolve_person_by_hris_user_id` / `resolve_persons_by_hris_user_ids`(批量,两条 SQL)/ `hris_user_alias_owner`(「这是谁的重复?」,不按在职过滤 —— 离职者的重复雇佣仍是重复)/ `record_hris_user_alias`。消费方:`_assign_manager`、`backfill_manager_links`、`_resolve_managers`、`apply_hris_role_relationships`(HRBP/HR Ops/调薪负责人 —— `relUserId` 同样是雇佣键,而它的 clear-on-miss 让这个盲区**无声**)、role-change 快照(不改就会每次同步误报「上级被移除」)。**写入方两条,都零额外 API 调用**:①（权威）create 路径上 `EmpEmployment.personIdExternal` 已被一个**在职 active** 的人持有 ⇒ 这是那个人的第二段雇佣,记别名并跳过建人（`HRIS_CONCURRENT_EMPLOYMENT_ALIASED`）—— **故意只认 active**:`left`/`leaver_pending` 持有者 + 新 userId 是**再入职**,给它记别名会把再入职的行冻在离职态;②（兜底,SF 没给 personIdExternal 时）原有的 `HRIS_CANONICAL_EMAIL_COLLISION_SKIPPED` 分支现在顺手记下「这是谁的重复」。**两个护栏**（别名表会短路 person upsert,所以给真人的主键记别名会让那个人彻底停止同步）:拒绝自别名;拒绝把**活人**的主 `hris_person_id` 记成别名 —— 软删的 ghost 行不受此限,「退休 ghost + 把它的 userId 指向真人」正是预期的清理路径。`_stage_event` 和 `_upsert_person` 双侧短路（webhook 不走 stage,两边都要挂）。**已知边界（记录在案,非本 PR 范围）**:Helix 仍是一人一行,所以「规范那段雇佣结束、次要那段继续」会像离职一样处理 —— 与改动前同行为。一次性回填脚本 [`fix_sf_duplicate_userid_aliases_2026_08_18.py`](../../server/scripts/fix_sf_duplicate_userid_aliases_2026_08_18.py)（候选全部从 Helix 状态发现、逐个用**实时 SF** `EmpEmployment.personIdExternal` 复核,SF 不背书就拒绝;先退休 ghost 再记别名 —— 顺序由上面第二个护栏决定）。回归:[`test_hris_user_alias_2026_08_18.py`](../../server/tests/test_identity/test_hris_user_alias_2026_08_18.py)(17)。

> **兼岗闸失效的续集 —— 自指的 `personIdExternal` 不是人的键** (2026-08-25; 0 migration / 0 cron / 0 RBAC / 0 MCP). 上面那道闸的唯一判据是 `personIdExternal != userId`，而**两个生产者都把 `userId` 回填进了这个字段**，于是判据恒假：① **SF 出站 iflow** —— 生产推送体实测 `person_id_external: "80100063"`，而 `process_webhook_events` 取数是「带了就信、没带才回查」，跳过了那次**本会返回 `P01008`** 的 `EmpEmployment` 查询；② **Helix 自己的全量同步合成 employment**，把 `personIdExternal: user_id` 当主数据缓存键用 —— 这就是 80100057/61/62 当年只能靠一次性脚本补别名、而不是被闸挡住的原因。后果：Hiroki Shinagawa 的兼岗（`80100063` → `P01008`）建成第二个人 + `hiroki.shinagawa01@pingcap.com` + 真 Google 账号 + 开放的入职流程。webhook 事件体**没有 email 字段**（SF 侧 `User.email` 实测在推送后 8 分钟才写上），所以邮箱撞车兜底也用不上 —— 同族的 80100058 之所以被挡住，只是因为它走的是读得到 `User.email` 的定时同步。**修法**：新原语 `person_id_external_is_usable(pid, user_id)`（空 / 自指 = **不构成证据**，注意不等于「错」——单雇佣的人本来就 `pid == userId`）+ `_resolve_person_id_external(uid, carried, *, force)`（`force` = 「这个值是我自己编的」：webhook 与两处合成点传 `True`，来自真 `EmpEmployment` 行的值不传，否则 bootstrap 建 500 人就是 500 次白跑的往返；查失败降级回原值 = 改动前行为；按 userId 记忆化）+ 全量同步合成时优先带真 `personIdExternal`（那行本来就在手里，零额外调用）+ `confirm_batch` 的**规范雇佣优先稳定排序**（那条 SELECT 原本**没有 ORDER BY**，主岗与兼岗同批首次入库时谁先落地谁就成了这个人）。**判据只有这一个，其余全部验伪**（生产双租户真清单）：`assignmentClass` 主岗与 5 个兼岗**全是 `ST`**；`isPrimaryAssignment`/`isPrimary`/`EmpJob.assignmentClass` 在两租户 OData 字典里**都不存在**（400 `COE_PROPERTY_NOT_FOUND`）；`EmpJob.event`/`eventReason` 只返回当前生效切片，兼岗 80100057/61/58 与**主岗 P01134** 同为 `1725`/`G06` —— 按事件码做闸会拒掉真人。**🔴 更正(2026-09-04,HR 在战捷这单兼岗上真勾了「否」之后探到)**：HR 界面上的【是否主岗】**在 OData 上是 `EmpJob.customString15`**(不是 `isPrimaryAssignment`/`assignmentClass` —— 那两个确实不存在)。option id `2059`=Yes(主岗) / `2061`=No(兼岗),`$expand=customString15Nav` 取 `externalCode` 可读。**生产双租户全量核过:22/22 完美分离** —— 10 条兼岗行(CN 100113/80100057/58/61/62/63/64 + EU 100074/100075/80100057)**无一例外**是 `2061/No`,12 条主岗行(P0050/P01008/P01115/P01134/P01412/P0809 × 两租户)**无一例外**是 `2059/Yes`。⚠️ **`customString15`(是否主岗)与 `customString24`(是否推送下游)是两个不同的字段,共用同一个 Yes/No picklist** —— 所以 `2059`/`2061` 这对 id 在两处都出现,**看到 2061 先问「哪个字段的 2061」**;本文档别处「`customString24=2061` 不推送」讲的是后者,与主岗无关。该 picklist 允许为空(实测有兼岗行 `customString24` 为 `None`),所以**空值只能当「没有证据」,不能当「是主岗」**;判据的权威仍是 `personIdExternal`,这个字段的价值是一条**不依赖 pid 解析**的独立佐证(尚未接入代码)。回归：[`test_concurrent_employment_no_account_2026_08_25.py`](../../server/tests/test_hr/test_concurrent_employment_no_account_2026_08_25.py)（9，含照抄生产推送体的复现 + 四个变异验证）。生产清理脚本 [`fix_kengang_ghost_accounts_2026_08_25.py`](../../server/scripts/fix_kengang_ghost_accounts_2026_08_25.py)（全量花名册 → SF `EmpEmployment` → 「这个人的 personIdExternal 是不是另一个在职人的?」；Google 删除单独挂 `--delete-google` 且三重拒绝：不能是规范人的邮箱、不能被其他在册行持有、`lastLoginTime` 必须是 1970）——全库扫出**唯一一个**，已清理。
>
> **兼岗闸第三集 —— 闸没坏，是被一块墓碑挡在了上游** (2026-09-04; 0 migration / 0 cron / 0 RBAC / 0 MCP). HR 给 **P01412 战捷** 加了**一个**兼岗；SF 双租户镜像同一套组织，于是这一个岗位生成**两个 userId**：CN `80100064`(闸正常识别 → 记别名 → 09:40:55，上面那道闸如约生效)、EU `100075`(**出事**)。差别只有一位数字：`100075` 早就是**另一个人**的 `hris_person_id` —— 「jac ye」(`jac.ye@pingcap.com`，巴西联络处)，2026-04-30 已 purge，且那条 2026-03-10 bootstrap 行的 `hris_instance_id` **仍是 NULL**。**SF 的 userId 跨 CN/EU 租户不唯一**，而按 userId 查人的语句为兼容遗留行故意放行 `hris_instance_id IS NULL`，于是 EU 的同步认领了这块墓碑。**`person is not None` ⇒ 兼岗闸整段不可达**(它写在 `_upsert_person` 的 `if person is None:` 里面)，onboard 分支清掉 `deleted_at`(`revived_soft_deleted_on_onboard_reappearance`)，复活出来的行**还带着死者的 `work_email`**，接着被当 Day-1 新人开了下去：真 Google 邮箱 `jac.ye@pingcap.com`(+ 一个付费坐席)、首登密码信发到**战捷的个人邮箱**、该地址作为 **P01412 的主企业邮箱回写进 SF EU 租户**(`PerEmail` 1046 / isPrimary，个人邮箱被降级)、以及那张「新员工公司邮箱已创建」卡片。飞书通讯录本来要建**第二个**账号，被 `residence_country` 缺失的闸挡住；其余 9 个下游目标全 no-op。**逃脱有多窄**：品川的 EU 兼岗号是 `100074`，闸 2026-08-25 正常识别；战捷的是下一个号。**判据一直都对，只是位置错了**：「这个 userId 以前是谁的」永远不是「它现在是谁的」的证据，只有 `personIdExternal` 是 —— 而它当时就写在事件携带的 `EmpEmployment` 行上(`P01412`)。**修法**：新原语 `_tombstone_belongs_to_live_person(user_id, employment, *, matched)` + `_claim_tombstoned_user_id(...)`，在 **`_stage_event` 与 `_upsert_person` 双侧**、**按 userId 查到行之后立刻**判一次：命中行是墓碑/非在职 **且** `personIdExternal` 指向**另一个在职 active** 的人 ⇒ 记别名(`concurrent_employment`)+ 跳过事件。别名是让修复**站得住**的那一半 —— 别名短路排在 userId 查人**之前**，以后每一 tick 都直接解析到规范人，不再重新审判墓碑。**口径刻意收窄，四重零回归**：① 命中行是**健康 active** ⇒ 立即返回、**零 SF 往返**(否则一次 confirm 批就是几百次串行 EmpEmployment 调用)；② `personIdExternal` 自指 ⇒ 不构成证据、不判(全量同步的合成 shape 才带 `force` 去回查)；③ 真**再入职**：`personIdExternal` 指回同一条非 active 行 ⇒ 判定 `None`，复活照旧发生 —— 这条路径闸必须不能吃掉；④ **只有真墓碑付得起那次 SF 回查**(全量同步刷新的每条离职行都是非 active，全都回查 = 夜间同步加约 130 次串行调用；departed-but-live 只按免费证据判)。新增两个 safe-logging 事件 `HRIS_TOMBSTONE_USERID_CLAIMED` / `HRIS_TOMBSTONE_USERID_UNALIASABLE` —— **两个 code 而不是一个布尔字段**，因为 safe-logging 把每个字段值都哈希掉，「别名记上没有」只能从 code 本身读出来(`record_hris_user_alias` 会拒绝活人的主键)。**生产处置**(trace `5bd69afc-f096-4907-ad05-ad0addf57d32`，pre-image 已存档)：SF EU `PerEmail(P01412,1046)` 改回 `zhan.jie@pingcap.com`(读回确认；CN 租户一直是对的，没被写脏) / Google 号 `jac.ye@pingcap.com` **已删除**(`lastLoginTime=1970` 从未登录，删前四重断言：地址、未登录、已停用、今日创建) / 坐席 revoked / 墓碑行复原为 jac ye 并重新 tombstone(战捷的数据全部清出) / `100075` 记成 P01412 的别名 / 今日新建的 google external identity 删除 / 部门归属 tombstone / 入职流程与**会重试建号的 blocked 飞书 sync state** 删除 / 岗变复核包置 `superseded`。**残留地雷已排**：同形状(`hris_instance_id IS NULL` + 数字号段 + 已 tombstone)的行全库共 3 条(`100059` / `100084` / `100075` 自己)，已全部钉到 CN 主实例 —— 任一租户的 userId 查人都再也认领不到它们；其余 4 条 NULL 行是 gaiaworks `ECadmin*` 系统账号，SF 不可能发出这种 userId。**HR 侧无需改动** —— HR 确认本意就是「多一个岗位」，SF 里的数据是对的。回归：[`test_tombstone_userid_collision_2026_09_04.py`](../../server/tests/test_hr/test_tombstone_userid_collision_2026_09_04.py)(9，含照抄生产 EmpEmployment 的复现；变异验证：把 `_claim_tombstoned_user_id` 短接成 `return False` ⇒ 3 条行为断言转红)。
>
> **PRIMITIVE — 「这是不是别人的第二段雇佣?」搬到开号那一刻 `app/identity/sf_assignment.py`** (2026-09-04; 0 migration / 0 cron / 0 RBAC / 0 MCP). **两次兼岗事故的止点是同一个:开出了一个真邮箱** (#3228 `hiroki.shinagawa01@`;#3434 按一个已 purge 离职员工的地址开号 + 回写 SF)。两次的修复都对、**都在入库侧** —— 而入库侧有五条路(webhook / 全量 / 增量 / 手工导入 / 单人刷新),闸只覆盖它被接进去的那几条;**`create_account_for` 从来没问过一句「这个人是不是我们已经认识的某个人的第二段雇佣」**。现在问了 —— 而且挂在**两个**不可逆入口上:`create_account_for`(开邮箱)与 `reserve_work_email_for`(预占地址 + **回写 SF PerEmail**;后者按 `personIdExternal` 解析写入目标,对兼岗会把地址盖到**规范那个人**的档案上,正是 #3434 里战捷企业邮箱被换掉的那一步,只挡邮箱不挡它洞还开着)。三个**互相独立**的信号(由便宜到贵):① `registered_alias`(纯 DB:userId 已被登记成别人的重复雇佣 —— 入库侧在行是墓碑时记了别名、后来某条路把行复活,是真会出现的状态);② `person_id_external`(**权威**,1 次 SF:SF 自己的人键指向另一个在册的人 ⇒ 这段雇佣是那个人的,不依赖 HR 勾没勾);③ `not_primary_assignment`(1 次 SF:`EmpJob.customString15` = `2061`,HR 的【是否主岗】= 否 —— 字段见上文 #3435 那条)。**三条设计判据**:③ **单向**(picklist 可为空,空值 = 「没有证据」,**绝不能读成「这是主岗」**);任何失败一律 **fail-OPEN**(SF 挂了 / 集成没配 ⇒ 无裁决照常开号 —— 在这个位置聪明过头有前科,#191 静默吃掉两个真新人);**这个模块不写库**,尤其**不记别名** —— 被它拒的那一行正握着这个 userId 当自己的主键且是活的,那正是 `record_hris_user_alias` 明确拒绝的形状(给活人主键记别名 = 那个人彻底停止同步);整理身份是入库侧的活。**与入库闸的刻意差异**:入库侧只认**在职**持有者(离职持有者 + 新 userId 是**再入职**,记别名会把再入职冻在离职态),开号侧不要求在职(给离职者的第二段雇佣开邮箱任何情况下都不对)。`person_id_external_is_usable` 移入本模块,`SuccessFactorsLifecycleService` 改为**委托**(两处依赖同一个谓词,两份拷贝是净负债);`EmpJob` 的两处 `$select` 都加 `customString15`。回归:[`test_duplicate_employment_mint_gate_2026_09_04.py`](../../server/tests/test_google_workspace/test_duplicate_employment_mint_gate_2026_09_04.py)(12;两轮变异验证 —— 裁决恒 `None` ⇒ 4 红,只短接【是否主岗】⇒ 恰好 2 红,证明第三个信号被独立钉住)。
>
> **PRIMITIVE — single-source lifecycle classifier `classify_inactive_sf_user`** (2026-07-16; 0 migration / 0 cron / 0 RBAC / 0 MCP). The ONE function that decides whether an SF `status='f'`/inactive user is a leaver vs a not-yet-started hire, in [`hr/service.py`](../../server/app/domains/hr/service.py) (+ `sf_status_is_active`/`SF_ACTIVE_STATUSES`). **Both** `preview_full_employee_sync` **and** `preview_incremental_sync` call it — previously each re-decided inline and drifted (full used strict `>`, incremental `>=`; the Robert Wood P10013 2026-07-15 false-offboard). Rules: `hire_date >= as_of` → future/day-one hire, never a leaver (`as_of` = HQ Asia/Shanghai date = easternmost tz, rolls over first, so `>=` is tz-safe everywhere — no per-person tz needed); real `endDate >= hire_date` → offboard; else (no real endDate, or `endDate < hire_date`) → NOT a leaver, **never fabricate** a termination_date (the old full-sync `lastModifiedDateTime` fallback is deleted) — surfaced via `hris_status_divergence` for review. Paired with a **universal impossible-state guard** (twin): any offboard with `termination_date < hire_date` is refused — at preview in `_stage_event` (full/incremental/window-import) AND at apply in `_upsert_person`'s offboard UPDATE branch (the **webhook push** path bypasses staging, so `_stage_event` alone misses it). Confidence grading lives at the classifier (destructive offboard requires a substantiated termination) — no separate per-event review queue. ADR: DECISIONS.md 2026-07-16. **Change lifecycle/future-hire logic in the classifier, never inline in a sync path.** Regression: `tests/test_hr/test_lifecycle_classifier.py` + `test_full_sync_forfeited.py`.
>
> **安全问卷 · 乙方侧客户问卷 + 答案库 + 证据文档库 — PR3/4** (2026-07-02; migration **376** = 5 tables / **+1 cron** / **+6 MCP → hint 404** / 0 new RBAC role). Second half of the 安全问卷 feature, **auditor domain** — when a CUSTOMER sends US a questionnaire to fill. New files: [`answer_library_service.py`](../../server/app/domains/auditor/answer_library_service.py), [`evidence_doc_service.py`](../../server/app/domains/auditor/evidence_doc_service.py), [`inbound_service.py`](../../server/app/domains/auditor/inbound_service.py), [`inbound_ai.py`](../../server/app/domains/auditor/inbound_ai.py) (Bailian match+draft, gated on the shared `ai_spending.llm.*` key → 503 without it), [`inbound_router.py`](../../server/app/domains/auditor/inbound_router.py) (3 routers: `/api/v1/auditor/answer-library`, `/evidence-documents`, `/inbound-questionnaires`), [`inbound_schemas.py`](../../server/app/domains/auditor/inbound_schemas.py). Tables: `auditor_answer_library_entries` (reusable bilingual Q&A, owner + usage_count + review cadence + linked `source_doc_ids`; answered inbound questions BACKFILL here), `auditor_evidence_documents` (our certs/policies/reports — S3 presign, `is_shareable`, `valid_until` expiry), `auditor_inbound_questionnaires` / `auditor_inbound_questions` / `auditor_inbound_versions`. Inbound lifecycle: received→answering→review→completed→archived/cancelled; add questions (manual / paste / xlsx import with header sniffing) → **AI drafts by matching the answer library** (per-question `matched_library_entry_id` + confidence, usage++; drafts stay unreviewed until a human confirms) → **export filled xlsx + archive the exact sent bytes as a version** (`put_object_bytes`, version_no++, accountability trail). Cron `auditor.inbound_questionnaire_due_reminder` (daily 05:00 UTC; staged 7/3/due/overdue, once per stage; **internal owner only**) — handler in [`auditor_handlers.py`](../../server/app/scheduled_jobs/auditor_handlers.py). MCP: `search_answer_library`, `list_inbound_questionnaires`, `get_inbound_questionnaire`, `list_evidence_documents`, `create_inbound_questionnaire`, `ai_draft_inbound_answers`. RBAC via `can_view_security` / `can_manage_security`. **乙方侧前端 (PR4, 2026-07-03)**: the 「安全问卷」page gains 3 tabs — 客户问卷 ([`InboundTab`](../../web/src/pages/auditor/securityQuestionnaire/InboundTab.tsx)), 答案库 ([`AnswerLibraryTab`](../../web/src/pages/auditor/securityQuestionnaire/AnswerLibraryTab.tsx)), 证据库 ([`EvidenceDocTab`](../../web/src/pages/auditor/securityQuestionnaire/EvidenceDocTab.tsx)) — plus the inbound detail page [`InboundQuestionnaireDetailPage`](../../web/src/pages/auditor/InboundQuestionnaireDetailPage.tsx) and [`api/inboundQuestionnaire.ts`](../../web/src/api/inboundQuestionnaire.ts). **安全问卷 4-PR theme complete.** **答案库的种子语料**: migration **381** (55 条双语标准问答, 17 分类) + migration **528** (+15 条 DLP / 数据保护: `data_loss_prevention` ×8、`email_security`、`ai_governance` 等新分类)。两个种子都以 `question_zh` 为幂等键、downgrade 只删未被认领的行。**528 引入的约定**: 一条说法如果超出 IT 已确认或既有种子已断言的范围, 就以 `last_reviewed_at = NULL` 种入 —— `auditor.answer_library_freshness` 会把它标成 `review_interval_elapsed`, UI 显示「待复核」, 也就是把「没人确认过的对客说法」变成可见信号, 而不是让它混在已确认答案里被发出去。**注意**: [`list_entries`](../../server/app/domains/auditor/answer_library_service.py) 的搜索只匹配 question/answer 的中英文正文, **不匹配 `tags`** —— 客户会直接搜的缩写(DLP、MFA、SSO…)必须出现在正文里, 否则搜不到。
>
> **安全问卷 · 供应商安全评估 (甲方侧 TPRM) — PR1/4** (2026-07-02; migration **374** = 5 tables + 2 seed templates / **+1 cron** / **+6 MCP → hint 398** / 0 new RBAC role). First half of the two-sided 安全问卷 feature, in the **auditor domain**. New files: [`vendor_assessment_service.py`](../../server/app/domains/auditor/vendor_assessment_service.py), [`vendor_assessment_router.py`](../../server/app/domains/auditor/vendor_assessment_router.py) (mounts `/api/v1/auditor/vendor-assessments`), [`vendor_assessment_schemas.py`](../../server/app/domains/auditor/vendor_assessment_schemas.py). The ISO 27001:2022 + 27701 bilingual seed (2 templates, 39/20 questions) is **inlined in migration 374** — a migration must stay self-contained (the alembic chain guard imports every migration env-less, so it must not import a live domain module that pulls in `Settings()`). Tables (prefix `auditor_sq_` / `auditor_vendor_`): `auditor_sq_templates`, `auditor_sq_template_questions`, `auditor_vendor_assessments`, `auditor_vendor_assessment_answers` (materialised + snapshotted per template at create-time), `auditor_vendor_assessment_attachments` (S3 presign, reuses `app/core/storage`). Lifecycle: draft→sent→answered→completed→archived/cancelled; blank bilingual questionnaire **xlsx export** (with data-validation dropdowns + question-code round-trip column) → vendor fills OUT OF BAND (Helix never emails externals) → **openpyxl import** re-binds by code → **weighted auto-score** → **risk rating** (low/medium/high/critical, thresholds 90/75/60, auditor-overridable) → per-answer review flags → **finding promotion** into `auditor_findings` (`control_key='vendor_security_assessment'`, `finding_type='assessment:<id>'` so reconcile auto-resolves un-flagged answers). Cron `auditor.vendor_assessment_review_reminder` (daily 04:00 UTC; staged 30/14/due/overdue reminders, once per stage via `review_reminder_stage`; **internal owner only**, honours the no-disturb rule) — handler in [`auditor_handlers.py`](../../server/app/scheduled_jobs/auditor_handlers.py). MCP: `vendor_assessment_overview`, `list_vendor_assessments`, `get_vendor_assessment`, `list_vendor_questionnaire_templates`, `create_vendor_assessment`, `complete_vendor_assessment`. RBAC via `can_view_security` / `can_manage_security`. **Remaining PRs**: PR2 甲方侧前端, PR3 乙方侧后端 (客户问卷 + 答案库 + 证据文档库 + AI 辅助作答), PR4 乙方侧前端 + 管理页.
>
> **PRIMITIVE — terminal-delete data-handover gate + successor resolver** (2026-08-13; 0 migration / 0 cron / 0 RBAC / 0 MCP). A leaver's vendor **DATA** (Google Drive/Calendar, Zoom cloud recordings) dies at the hard delete, and until this landed both delete sweeps ran on the assumption that "the blocker cascade already transferred every owned resource". **It does not.** The ONLY path that transferred Drive/Calendar was the Google Workspace *seat* revoke (`operation=user_suspend`), and that seat row is routinely gone by delete time — `google.seat_sync` auto-revokes it the morning after the account stops being active in the tenant, and `[stale_after_rescan]` skips the blocker when the seat vanishes mid-flight. Two prod leavers (`alison.u@` 2026-08-12, `hice@` 2026-08-02) were hard-deleted with **zero** transfer. Zoom was worse: its seat binding is `action_on_offboard=disassociate` (transfers nothing) and its deletes are PERMANENT. **Before adding any new terminal/irreversible vendor disposition, route it through this gate** — do not re-derive "the cascade already handled it". New [`app/domains/offboarding/successor_resolver.py`](../../server/app/domains/offboarding/successor_resolver.py) `resolve_terminal_successor()` returns a `SuccessorResolution` (person + tier + `as_detail()` for audit/ledger) over a fixed chain: process `default_handover_person_id` → direct manager → manager chain upward (≤5, cycle-guarded) → platform setting `offboarding.default_handover_fallback_person_id`; candidates must be active, non-deleted, have a work_email, and are **never the leaver** (a self-referential manager link would "transfer" data to the account being deleted and log it as success). `OffboardingService._ensure_google_handover` accepts prior evidence (ledger `session_enforcement.google_data_transfer`, or a completed `gw-user:<email>` provisioning task carrying `data_transfer_status=completed`) else performs the transfer itself via the new `account_lifecycle_coordinator.ensure_google_data_transfer` (initiate + poll, and **persists `transfer_id` so the next tick resumes the same transfer instead of queueing a duplicate** — a poll-budget timeout is `in_progress`, never permission to delete). `delete_zoom_account(successor_email=…)` → `delete_user_unconditional(transfer_email=…)` adds `transfer_meeting/webinar/recording`. No successor ⇒ **refuse to delete**, non-terminal ledger (`skipped_no_successor` / `skipped_transfer_incomplete`), audit, and ONE IT page-out deduped via `offboarding_reminder_log` kinds `del_blocked_google` / `del_blocked_zoom` (not per-tick spam). The optional page-out is one `best_effort` savepoint: a failed delivery rolls back only its outbox and receipt, so the refusal ledger/audit can commit and the next tick can retry without a false idempotency receipt. Refusals get their own sweep counters (`blocked_no_successor` / `transfer_incomplete`) and are deliberately kept OUT of `errors` — a safe refusal must not turn the cron red and drown real failures. ⚠️ **Gmail message content is NOT transferable by any API** (Data Transfer moves Drive/Calendar only) — it dies with the delete; surfaced as an explicit warning, not silently. Tests: `test_offboarding/test_terminal_delete_handover_gate_2026_08_13.py`. Tests also cover `test_offboarding/test_handover_reconcile_2026_08_13.py`. See DECISIONS 2026-08-13. **(2026-08-13 follow-on — the handover person is now guaranteed, not best-effort)** `start_process` prefills `default_handover_person_id` from the HRIS manager ONCE at creation; a manager link that resolves later leaves it NULL forever (prod: 12 completed + 2 in-flight processes, incl. `hice@`). New daily cron `hr.offboarding_handover_reconcile` (migration 467, 02:40 UTC — deliberately ahead of the 03:00/03:10 delete sweeps) re-resolves and backfills NULLs over the same chain, scoped by `_handover_still_matters()` to processes where a handover can still change an outcome (open, or completed with a non-terminal delete ledger). Org-wide last tier is the platform setting `offboarding.default_handover_fallback_person_id`, editable via `GET/PUT /offboarding/settings/fallback-handover` (global_admin for write — it decides where ANY orphaned leaver's data lands) and the `FallbackHandoverCard` on the offboarding cockpit, mounted under the 「缺少交接人」 KPI. The read surface reports `is_stale` when the configured person has since left — a configured-but-dead fallback is worse than none, because it reads as solved while every orphaned leaver still blocks. **(2026-08-13 follow-on — the handover person is told, and can hand it on)** Being named a handover person was silent: data started routing to a colleague (Drive/Calendar at delete time, email-group ownership, API keys) with no push, so a *wrong* pick had no way to self-correct. `OffboardingService.notify_handover_assigned` now fires on all three assignment paths (admin set / reconciler backfill / reassign), deduped per (process, recipient) via `offboarding_reminder_log` kind `hoa_<person-hex>` so a daily reconciler and a re-save stay quiet while a genuinely new owner is always told. Template `offboarding.handover_assigned` is registered in `categories.py` as an **exact** entry on `CAT_ASSETS` — the `offboarding.` prefix would file it as a lifecycle FYI (info bucket) and hide it from the red dot the recipient actually watches; it is an action item (confirm or reassign). Self-service: `GET /offboarding/my-handovers` + `PUT /offboarding/my-handovers/{id}/reassign` (+ 1:1 MCP `list_my_handovers` / `reassign_my_handover`, hint 458 → 460). **These routes are NOT admin-gated** — every authenticated employee can reach them, so the service-layer ownership check (must BE the current handover person, or global_admin) IS the gate; without it any employee could redirect a departing colleague's Drive content to themselves. Reassign notifies both sides (new owner gets the assignment, previous owner gets a receipt). All three notification helpers run inside `best_effort` savepoints — they write outbox rows + receipts in the CALLER's session, and a bare try/except would poison it and roll back the very assignment being announced. **(2026-08-13 follow-on — the upstream leaks are closed too)** The delete gate is the backstop; these are the two SILENT paths that removed the evidence in the first place. (1) `google.seat_sync`'s base-seat drift cleanup now calls `_classify_drift_revoke_candidates`: a person with a live offboarding whose handover still matters is **not** drift-revoked (the blocker cascade keeps ownership of the transfer), and a person Helix still lists as ACTIVE whose account went dark in the tenant IS revoked but raises `google.seat_sync.active_employee_account_dark` + an IT push, deduped 30 days per person — that mismatch is the earliest possible warning of this whole failure mode (ten days early for `alison.u@`). (2) `OffboardingService._blocker_carries_data_handover` classifies whether a skipped blocker's revoke would have MOVED data (Google `operation=user_suspend`, Zoom `action_on_offboard=delete`) rather than merely removed access (`group_member`, Zoom `disassociate`); only the data-moving kind raises `offboarding.data_handover_blocker_went_stale` + an IT push when `[stale_after_rescan]` drops it, deduped per process. Both classifiers degrade to the pre-fix behaviour on error — a guard that throws must never wedge the sweep it guards.
>
> **Account lifecycle coordinator — single entry for vendor ACCOUNT lifecycle** (2026-07-01, PR1/5 of the ADR「厂商账号生命周期收归入离职事件」; 0 migration / 0 cron / 0 MCP / 0 RBAC). New [`app/core/coordinators/account_lifecycle_coordinator.py`](../../server/app/core/coordinators/account_lifecycle_coordinator.py) is the single orchestration entry for Google/Feishu **account-level** lifecycle — today: `provision_google_account_for` / `reserve_google_email_for` (delegate to `account_provisioner.create_account_for` / `reserve_work_email_for`), `google_force_logout` (integration lookup + `force_logout_user`), `feishu_force_logout` (honest no-op). `hr_sync_coordinator` (onboarding create/reserve) and `OffboardingService._push_google_logout`/`_push_feishu_logout` now route through it instead of touching `account_provisioner`/drivers directly; connector/driver layer + provisioner internals unchanged. Lazy domain imports keep `app/core` a leaf (no cycle). **Staged migration** (see DECISIONS 2026-07-01): **PR2 (shipped 2026-07-02)** added `deactivate_account` + the daily `identity_sync.account_backstop` cron (migration 373) — freezes leavers whose Feishu account the event path missed, riding the existing `feishu_contact` target's enablement (no separate switch; no-op while that target is off), idempotent, batch-capped, raises on failure; the `cutover_ready` rename was dropped (rides `enabled` instead) and `identity_sync_managed` removal moved to PR3. **PR3 (shipped 2026-07-02)** added `suspend_google_account` (account-level, no-successor, idempotent) + hooked it into `OffboardingService._push_force_logout` (same post-`leaver_date` gate as force-logout) — leavers' Google accounts now **auto-suspend** by default (reversing "never auto-suspend"), **retention-exempt** for `external_account_registry` keep-classes (`shared_service`/`external_guest`/`contractor`); code-only activation, no migration. `identity_sync_managed` **kept** (re-review: it doubles as the provisioning `manual_notify` safety route; removal = live-data behavior change for cosmetic gain — see DECISIONS 2026-07-02). **PR4 (shipped 2026-07-02)** added the offboarding-disposition (销号) view: `onboarding_account_overview` now returns an `offboarding` block (Google auto-suspend/force-logout/retention-exempt-classes + Feishu seat-revoke op + backstop-enabled), and the onboarding-strategy page renders `OffboardingDispositionSection` beside the 建号 section (reusing `BuildStatusCard`) so 建号+销号 = one account-lifecycle picture. **Epic complete.** Until then the Feishu seat-freeze (via the provisioning dispatcher) keeps its current home — headed into the coordinator per the plan. **(2026-07-08 follow-on — terminal DELETE)** the coordinator gained `unsuspend_google_account` (cancel-path reverse of suspend, 2026-07-08 review #1750) and `delete_google_account` (account-level `users().delete`, no-successor, idempotent 404). The suspend is no longer a permanent end-state: the new daily cron `hr.offboarding_google_account_delete` (migration 395) hard-DELETEs a leaver's suspended Google account at `leaver_date + delete_lead_days` (config, default 2, employee-local TZ), for confirmed-suspended + non-retention-exempt accounts, with Google's ~20-day undelete window as the mistake safety net (no data transfer). Owner: `OffboardingService.sweep_google_account_deletions` / `_delete_google_account_if_due`; ledger `session_enforcement.google_account_deletion` (deleted/already_absent/retained terminal). See DECISIONS 2026-07-08. **(2026-07-14 follow-on — Feishu terminal DELETE on the offboarding event path)** the coordinator gained `delete_feishu_account` and `OffboardingService._push_force_logout` now DELETEs the leaver's Feishu account (→ recycle bin, restorable in-window) at the SAME last-working-day gate as the Google suspend — parity with the predecessor Authing 飞书离职删除工作流 (chosen over freeze). Before this, Feishu had NO account disposition on the offboarding chain (only the nightly `feishu_contact` sync / `account_backstop` froze leavers → ~24h+ window where a leaver kept Feishu access); Google was cut in real time but Feishu was not. Resolution: `feishu_contact` `IdentitySyncState.external_id` → email lookup. Guards: retention-exempt (`_is_feishu_account_retained`, same `external_account_registry` keep-classes as Google) never auto-deleted; idempotent (user-gone → already_absent); business-refusal (4xx) → FREEZE fallback (`frozen_fallback`); systemic failure (bad token / rate-limit / 5xx) → `failed` WITHOUT freeze so the next run retries the DELETE; never raises. New `session_enforcement.feishu_account` channel. The out-of-scope deactivate on the DATA-SYNC chain stays freeze (safe backstop) — delete belongs to the offboarding chain (the two chains are kept separate). Feishu *session* revoke is still an honest no-op (no public per-user API); the delete cuts access at the root. Tests: `test_coordinators/test_feishu_account_delete_2026_07_14.py`, `test_offboarding/test_feishu_account_delete_2026_07_14.py`. **(2026-07-16 follow-on — delete DEFERRED while the leaver has open self-node approval instances)** the 回购 template's second step parks on the SUBMITTER'S OWN Feishu node, and the Helix supplement fallback (asset supplement-uploads endpoint) resolves the requester's Feishu user_id by email and approves the task in their name — BOTH need the submitter's account to EXIST, so the leaver_date+1 delete would strand an in-flight instance terminally (restore = recycle-bin recovery). `_push_force_logout` now checks the leaver's PENDING `employee_requests` with a linked `detail.feishu.instance_code`; if any, the delete is deferred (`session_enforcement.feishu_account.status='delete_deferred_open_approvals'`) and the account is FROZEN instead via `deactivate_account` (access still cut, node still operable). The daily `hr.offboarding_feishu_deferred_delete` cron (migration 407) retries: instances closed → delete; still open past `defer_max_days` (default 30, leaver-local days) → abandoned, deleted anyway with audited `deferral_expired`; transient delete failures keep the deferred state for next-tick retry. Tests: `test_offboarding/test_feishu_delete_defer_open_approvals_2026_07_16.py`. **(2026-08-04 follow-on — Zoom two-stage lifecycle, parity with Google)** the coordinator gained `suspend_zoom_account` / `unsuspend_zoom_account` / `delete_zoom_account` (mirroring the Google trio; `set_user_status(activate=False/True)` + `delete_user_unconditional`). `_push_force_logout` now auto-DEACTIVATES the leaver's Zoom account at the same last-working-day gate as the Google suspend (retention-exempt via `_is_zoom_account_retained`, same `external_account_registry` keep-classes), and the new daily cron `hr.offboarding_zoom_account_delete` (migration 451) hard-DELETEs it at `leaver_date + delete_lead_days` (default 2) for confirmed-deactivated + non-retention-exempt accounts. ⚠️ Unlike Google, Zoom `action=delete` is PERMANENT (no undelete) — the deactivate→delete gap is the only mistake window; product decision 2026-08-04 is hard-delete-all (no successor / no data transfer). **Addresses the account by `work_email`, NOT the identity_sync `external_id`** — deliberately sidestepping the shared-sweep `skipped`-branch blind spot that leaves `external_id` NULL for ~all baseline `no_meetings` accounts (so the identity_sync-driven deactivate silently no-ops for them). Cancel-path reversal: `cancel_process` folds `zoom_account=suspended` into the `already_advanced` confirm gate, and `unsuspend_zoom_after_cancel` (post-commit, router-invoked after the Google one) restores a mis-flagged rehire. Owner: `OffboardingService.sweep_zoom_account_deletions` / `_delete_zoom_account_if_due`; ledger `session_enforcement.zoom_account_deletion`. Tests: `test_offboarding/test_offboarding_zoom_account_delete.py`, `test_offboarding/test_offboarding_zoom_account_suspend_2026_08_04.py`. NOTE the underlying shared-sweep `skipped`→`external_id`-NULL bug is NOT fixed here (needs its own all-runner evaluation); this path just doesn't depend on it.
>
> **Session-cutoff enforcement proof + signOut scope fix** (2026-07-04, migration 383; 0 cron / 0 RBAC / 0 MCP). P0051 follow-up layer 2: "status is right — is access actually cut?" **(1)** `GoogleWorkspaceDriver.force_logout_user` requested only `admin.directory.user`, but `users.signOut` needs the narrower `admin.directory.user.security` — so signOut 403'd `insufficient authentication scopes` for weeks while suspend/CRUD worked, and `_push_force_logout` swallowed it into a bool. A read-only signOut probe against a non-existent userKey confirmed the DWD SA is **already** authorised for the security scope (security-only → 404 user-not-found = scope OK; CRUD-only → 403; both → 404), so this was a pure code fix — now requests `[_USER_SCOPE, _USER_SECURITY_SCOPE]`. Also fixed a latent `WorkspaceOpResult` bug: it had no `already_absent` field, so the signOut 404-idempotent branch always raised `TypeError` (that path was dead). **(2)** New `OffboardingProcess.session_enforcement` JSON column (migration 383) records each identity-plane channel's outcome — `google_session` / `google_account` / `feishu_account` (terminal DELETE, 2026-07-14) / `feishu_session`, each `{status, at, error?}` — written by `_push_force_logout`; a failed session cutoff also writes `offboarding.google_force_logout_failed` audit. Coordinator gained `google_force_logout_detailed` (structured; bool `google_force_logout` kept as façade). **(3)** Surfaced on the offboarding process detail page (「访问切断」 panel, [`SessionEnforcementPanel`](../../web/src/components/SessionEnforcementPanel.tsx), failed = red accent) + retryable via `POST /offboarding/processes/{id}/reenforce-session` (`reenforce_session_cutoff`; idempotent, merges without clobbering a prior success, refuses PROC_CANCELLED). Authoritative access-cutoff console = the offboarding process detail; proactive drift alerting on `session_enforcement.google_session=failed` is owned by the HRIS↔Helix reconciliation cron (next PR) to avoid duplicate alert logic. Regressions: `test_google_force_logout_scope_2026_07_04` + `test_session_enforcement_recording_2026_07_04`.
>
> **Data warehouse 一级菜单 — employee guide + admin governance overview** (2026-06-30; 0 migration / 0 cron / 0 MCP / 0 RBAC). New top-level 数据仓库 menu on both the employee and admin sides; clicking the 一级 menu title lands on a page (AntD SubMenu `onTitleClick`), and the first child is the same landing. **Employee** [`DataWarehouseGuidePage`](../../web/src/pages/dataWarehouse/DataWarehouseGuidePage.tsx) at `/my/data-warehouse` — a rich BigQuery-data-access explainer: hero with a live requestable-dataset count (reuses `GET /data-access/requestable-resources` `meta.total`), a 5-step 申请流程 (AntD `Steps`: pick group+datasets → reason+submit → Feishu owner approval → auto-apply dataViewer → track on Group数据访问), 使用逻辑 cards (Group-based grant, read-only `roles/bigquery.dataViewer`, owner approval, auto-reclaim on leave/revoke), a what-data + role card, and CTAs into the existing 申请/Group访问 pages. **Admin** [`DataWarehouseOverviewPage`](../../web/src/pages/dataWarehouse/DataWarehouseOverviewPage.tsx) at `/admin/data-warehouse` (global_admin) — a governance overview backed by a NEW read endpoint `GET /api/v1/data-access/admin/overview` ([`router.py::_build_data_access_admin_overview`](../../server/app/domains/data_access/router.py), `_require_data_access_global_admin`): KPI strip (active datasets / active bindings = "permissions" / people-reach = Σ `email_groups.direct_members_count` over distinct active-binding groups / pending-approval / applied / failed+retryable), an apply-mode banner (true iff any `data_access/bigquery` integration has `apply_mode_enabled`), an ECharts donut (binding-state mix) + horizontal bar (top datasets by active-group count), and top-groups + recent-requests tables. The two existing employee 数据访问 leaves (Group数据访问 `/my/data-access/group-access`, 数据访问申请 `/my/data-access/requests`) and the merged admin 数仓访问历史 (`/admin/data-warehouse/access-history`) now live under this menu. New pages live in `web/src/pages/dataWarehouse/` (outside the strict `pages/dataAccess` i18n guard so technical tokens like BigQuery render cleanly); `dataWarehouse.*` i18n × zh/en/ja + 2 PageHelp registrations. Read-only dashboard — **no MCP tool** (not a self-service mutation flow). Regression test `test_admin_overview_aggregates_governance_counts_for_admin` asserts exact aggregation across seeded scenarios + 403 for non-admin.
>
> **New-hire emails — what a hire receives** (2026-06-29 consolidation). Exactly TWO automated emails: (1) **Google account + first-login password** → the hire's **personal_email**, from `account_provisioner.send_welcome_with_password_email`, when the mailbox is minted (confirm if on/after hire_date, else the hire_day cron). The work mailbox is empty until they log in, so the password MUST go to personal email. (2) **Onboarding IT-info** (work email + SSO + provisioned software/hardware lists + portal) → the hire's **work_email** + Feishu, template `hire_day_it_info` — the SINGLE per-language onboarding email (was previously two overlapping work-inbox emails: a thin `welcome` + this; merged 2026-06-29). Fired once via `HireDayNotifyService.notify_person`, deduped by `identity_persons.it_info_sent_at`, from BOTH the confirm path (`hr_sync_coordinator.send_onboarding_it_info`) and the `hr_lifecycle.hire_day` cron (so even late-confirmed hires get it). Template lives in `_DEFAULT_TEMPLATES` (zh + `_en`), admin-overridable via the 邮件模板 page. NONE of these send until a primary `kind='email'` integration exists (see the Gmail-API / SMTP email-driver entry). **Three render surfaces stay in lockstep** — the rich HTML (`hire_day_service._render_onboarding_html`, survives as `body_html`), the Feishu card (`_build_welcome_card_fields`), and the `hire_day_it_info` **text template body_text (which OVERRIDES the envelope's body_text at dispatch — service.py `_send_email`, so it's the real text/plain)**; edit all three together. **HR Ops content pass (2026-07-15)**: label 「HRBP」(was 「入职 HRBP」); 「HRIS (SuccessFactors)」(was 「HR 系统」); dropped the 新人培训/OKR/PID-BootCamp integration-plan timeline (kept only the TiLearning course entry); laptop line is static "contact local HR Ops" with **no asset model shown** (`_hardware_list` still computed for the `{hardware_list}` context key but not rendered); fixed HR-authored welcome note signed 「PingCAP」(replaced the per-hire random blessing pool); reimbursement tool is **entity-based** — `is_china_employee` → 汇联易(Feishu app「Helios」) else Navan — passed as `is_china` into both renderers (the text template can't branch on entity, so it degrades to zh→汇联易 / en→Navan); people names render language-matched via `localized_name`; the 网络负责人 now resolves from the `network.owner_person_id` person record (localizable) with the free-text `it.network_contact` as fallback; and the email **CCs the hire's HRBP (SF 2448) + HR Ops (SF 2450)** (`_welcome_cc_emails`). **CC audience widened 2026-08-24** — the same helper now also CCs **every active asset admin of the hire's local office** (via the shared `core/coordinators/new_hire_recipient_resolver.resolve_new_hire_operational_recipients`, the canonical asset-region rule) and the **IT contact** (孟凡生 by default, read from the same `google.account_provisioning.created_notify_emails` setting the credential mail uses), so both new-hire mails reach one audience; every address is gated on the fail-open corporate-domain guard and the list is capped at `_WELCOME_CC_CAP` (10, logged when it bites). **Trap**: the `hr_cc` stamp now gates on `_WelcomeCc.hr_covered` (did the HRBP/HR Ops leg resolve?), NOT on "CC list non-empty" — the IT contact defaults on, so the old non-empty test would permanently disarm the `run_feishu_welcome_catchup` HR-copy leg. **IT block REPLACED by HR's wiki doc (2026-08-25)** — the 「Onboarding-IT」 wiki page (`YhU2wOSS7iPNm3kbzQJc00ZRnog`, 中文版=中国区 / 英文版=海外区域) is now the content authority for the IT section on all three surfaces. It carries exactly three items — the region's must-read guide, laptop pickup, and the **IT Help Desk** with its three entry points (Feishu search「IT Help Desk」/ 工作台→服务台→IT Help Desk / regional applink) — and everything the block used to hold (Helix + Feishu sign-in steps, the “already set up for you” account & seat list, the Helix self-service request pitch, the Feishu-vs-Google docs rule, the new-hire quick guides, and the network-owner / HR-desk “who to reach” lines) is GONE, not merged. **Both the guide and the helpdesk link are now REGIONAL**, picked by `is_china` (the labour-contract entity, same axis as the HRIS manual and the reimbursement tool) via `_day1_handbook_url` / `_it_helpdesk_applink`: China → `wikcnKhelzg1rdNVKFUmMXzfvoW` + `applink.feishu.cn/T8TQpyK5mVGj`, Global → `Tu9AwCr2ui4fRtkQCdScG99Unaf` + `applink.feishu.cn/T8UNQO6Y5TAl`. Before this every hire — Chinese readers included — was handed the overseas-only handbook and the China helpdesk applink. The plain-text template has no entity axis, so it degrades zh→中国区 / en+ja→Global; the Feishu card omits the laptop line (it already sits in the「今天要做」 checklist) and its「遇到问题找谁」section dropped the IT line, which now lives one section up. Regression: [`test_day1_it_section_regional_2026_08_25.py`](../../server/tests/test_hr/test_day1_it_section_regional_2026_08_25.py) (12).
>
> **Email look — branded HTML shell for EVERY email** (2026-06-30). All transactional emails render in one brand-consistent HTML shell (the Day-1 onboarding visual language: navy header, rounded white card, accent-bordered content card, CTA button, quiet footer — inline styles, table layout, no external assets), via [`app/notifications/email_layout.py::render_email_shell`](../../server/app/notifications/email_layout.py). `NotificationService._send_email` calls it ONCE, just before building the `EmailMessage`: when the envelope has **no explicit `body_html`**, it wraps the resolved plain-text body (paragraphs/bullets/`[r][b]` inline markup, CTA URL de-duped into the button) in the shell. Envelopes that already carry `body_html` (the bespoke `hire_day_it_info` onboarding mail, admin-authored HTML) are left untouched, and the plain-text alternative is always still sent — the shell only *adds* the `text/html` part, the send contract is unchanged. So no per-template HTML authoring is needed; new templates look good automatically.
> **Email-template management — single home** (2026-06-30). Every email template is a `platform_settings` row keyed `email_template.<key>` (override) over an in-code default in `_DEFAULT_TEMPLATES`. They are managed ONLY on the 邮件模板 page (`/admin/notifications/email-templates`): the generic 平台参数 page now hides the `email_template` namespace (added to `HIDDEN_NAMESPACES`, links out). The editor drives off [`template_catalog.build_registry`](../../server/app/notifications/template_catalog.py) (`GET /api/v1/notifications/email-templates`) — all `_DEFAULT_TEMPLATES` keys ∪ migration-only `email_template.*` keys, minus the dead `welcome` (see migration 367), grouped by domain, each with auto-extracted `{variables}` and effective (override ∪ builtin) content + a live HTML preview (`POST …/email-templates/preview` → `render_email_shell`). `hire_day_it_info` is flagged `html_generated` (its rich HTML is code-built; the editor hides the body_html field so an admin can't clobber it).
>
> **Builtin-only templates — the editor may not offer an override it can't honour** (2026-08-17). The two plaintext-credential mails (`template_catalog.BUILTIN_ONLY_KEYS` = `service.SECRET_TEMPLATE_KEYS`) are sent OUTSIDE `NotificationService.dispatch` and read the in-code copy directly: `account_provisioner._send_password_email` formats the module-level `_PASSWORD_MAIL_TEMPLATE` snapshot, and `cmdb.lifecycle_service.grant_access` formats the module-level `_CAM_MAIL_TEMPLATE` snapshot. Both snapshots are `dict(...)` copies taken at import time from the registered builtin (`templates/email_group.py` / `templates/cloud.py`), so a renamed key fails CI's smoke-import rather than someone's first morning. **Both keys now reach the registry** — the CAM copy was inline and Chinese-only until #2900 registered it, which is what made the flag load-bearing rather than pre-emptive. Because that key is flat (no `cloud.` prefix), `_GROUP_OF` carries an explicit `cloud` entry; without it the row lands in the editor's "other" group. Builtin-only is deliberate: the one mail carrying a live temporary password must not be breakable by an admin edit. But the catalog listed `google_account_password` as an ordinary editable row, so an admin could reword it, get a "saved" toast and ship nothing — a config entry pointing at something other than the real switch. Rows now carry `builtin_only`; the editor opens them **read-only** (orange 代码直投 tag, 查看 instead of 编辑, warning alert, `<Form disabled>`, save button disabled) and `PUT /platform-settings/email_template.<builtin-only key>` returns 422 so curl / MCP / a stale tab can't store the dead override either. DELETE stays allowed — clearing a stale override is always safe. Pinned by [`test_email_template_builtin_only_2026_08_17.py`](../../server/tests/test_notifications/test_email_template_builtin_only_2026_08_17.py), including a drift guard that every `SECRET_TEMPLATE_KEYS` entry reaching the registry is flagged. **When adding a send path that resolves a template itself instead of going through `dispatch`, add its key to `BUILTIN_ONLY_KEYS` in the same PR** — otherwise the editor starts lying about it the moment someone registers a builtin for it.
> **Email sending — two drivers (`kind='email'`)** (2026-06-29). (1) `smtp` — generic SMTP ([`smtp_email.py`](../../server/app/integrations/drivers/smtp_email.py)). (2) `google` — **Gmail API via Workspace domain-wide delegation** ([`gmail_api_email.py`](../../server/app/integrations/drivers/gmail_api_email.py)): reuses the **same service account + DWD** Helix already uses for provisioning (no new secret), impersonates a dedicated sender mailbox (`impersonate_subject_email`, must be a real Workspace user) and calls `users.messages.send` with the **`gmail.send` scope only** (least privilege — can send, not read/alter mail). Sends from our own domain (SPF/DKIM correct), scope revocable. The primary email integration is whichever `kind='email'` row is `is_primary=true`; `NotificationService._send_email` resolves it via `build_primary_driver("email")` and a missing one is why a Helix-minted Google account's welcome email fails → auto-suspend (the account is created then rolled back to suspended). Prereq for `google`: grant the SA client ID `gmail.send` in Google Admin DWD + create the sender mailbox + add the `email`/`google` integration row.
>
> **work_email corporate-domain invariant — SF primary email (often personal) never becomes work_email** (2026-06-29; 0 migration / 0 cron / 0 MCP / 0 RBAC). New core abstraction [`app/core/corporate_domains.py`](../../server/app/core/corporate_domains.py) = the single source of truth for company-owned email domains: `DEFAULT_CORPORATE_EMAIL_DOMAINS` (pingcap.com, pingcap-c.com, pingcap-utc.com, pingcap.co.jp, capastro.com, tidb.io), `get_corporate_email_domains(db)` (reuses the existing `google.mail_group.suffix_whitelist` platform setting; **fails OPEN** to the default so a seed glitch can't misclassify a real address), and the pure `email_is_corporate(email, domains)` (exact + subdomain; a bare local part has no `@` → False). `email_group._DEFAULT_SUFFIX_WHITELIST` now points at it (de-duped). **Why:** SF `User.email` is the person's *primary* email, which for a fresh hire is usually a personal address (个人/`emailType=1047`) because the corporate mailbox doesn't exist yet. Trusting it as the Google 邮箱名 minted `1030263320@pingcap.com` (a QQ number) for P10008 黄浚源 instead of `junyuan.huang@pingcap.com`; and once it leaks into work_email it also feeds Google/Feishu SSO matching + welcome-email + HRIS write-back + the `UNIQUE` index. **Two guard layers:** (1) provisioner derivation (the primary fix) — `account_provisioner._local_part_from_hris_email(work_email, *, max_length, corporate_domains)` returns "" for a full address on a non-corporate domain (falls through to `given.family` pinyin), while a bare 邮箱名 and real corporate addresses are honoured; (2) sync write (sticky protection) — `HrSyncService._resolve_unique_work_email` (+ dormant `successfactors_sync.py`) never lets an incoming non-corporate email OVERWRITE an *existing* real corporate work_email (SF keeps returning the personal primary even after Helix mints the corporate mailbox; without this the next sync flips work_email back to e.g. @qq.com). **It deliberately does NOT force a personal email to a placeholder on the create path** — that would trip the existing "resolved work_email in HR_SKIP_EMAIL_DOMAINS (incl. successfactors.local) ⇒ SF dup-userId ⇒ refuse to create" guard (service.py ~3873) and block legitimate new hires; when there's no corporate value to protect the address flows through and the provisioner layer derives the real mailbox. `personal_email` is unaffected (sourced independently from SF `PerEmail(1047)`). **HRIS write-back (`hris_email_writeback.notify_hris_email_assigned`)** makes the corporate email the SF **primary**: SF allows one primary and a new hire's personal email (1047) is usually already primary, so the write-back does an **atomic `PerEmail` batch upsert** — promote corporate (1046, isPrimary=true) + demote personal (1047, isPrimary=false) in one call (single-row `upsert_per_email` is now a wrapper over the batch `upsert_per_emails`). Set `is_primary=false` in the `hris.email_writeback` setting to only record the business email without touching the primary.
>
> **CFO leadership overview 大屏 + Vendor 360 — finance platform theme complete** (2026-06-28, finance PR6/6; 0 migration / 0 cron / +2 MCP→hint 374 / 0 RBAC). `read_service.leadership_dashboard(period)` composes the existing reads into one company-wide snapshot (totals + cost-type mix + MoM, top dept movers, top vendors, per-BU cost vs budget, budget health, unallocated %, cost-per-headcount). `read_service.vendor_360(period, vendor)` splits ONE vendor's spend across channels (cloud/software/ai/hardware/procurement/expense — what a plain vendor pivot hides) + by department + 12-month trend + concentration % of company spend; `top_vendors` for the picker. Endpoints `GET /finance/{leadership-dashboard,vendors/top,vendor-360}` (can_view_finance incl. management) + MCP `finance_leadership_dashboard` / `finance_vendor_360`. Frontend: `/admin/finance/leadership` 财务总览大屏 (neutral name; equal-height GradientKpi tiles + Donut mix + labelled 口径-footnoted rank cards, no bare-number grids — leadership-page-bar) + a Vendor 360 drawer on FinanceVendorsPage. **The 6-PR finance platform theme (NS GL inbound → reconciliation/close cockpit → consolidated tie-out → expense intelligence → commitment layer → CFO 大屏/Vendor 360) is complete.** Preview screenshots of the 大屏 + new pages are batched into the theme-closeout review (isolated-port stack + seed data). No migration.
>
> **Employee "my money" + BU-owner self-service** (2026-06-28, finance PR5/6; 0 migration / 0 cron / +2 MCP→hint 372 / 0 RBAC). The admin-side full views existed (bu_fiscal_year_costs, finance role); this adds self-scoped surfaces anyone can use. `read_service.my_attributed_cost(person_id, period)` = unified ledger sliced to owner=me (cloud/software/ai/hardware, consumption) + my 汇联易 expenses → total + by-cost-type + a 12-month trend. `read_service.my_bu_costs(person_id, fy)` resolves the caller to a BU via `_build_bu_resolver` and returns only their BU's slice of `bu_fiscal_year_costs` (empty when no BU — no forbidden error). Endpoints `GET /finance/me/{attributed-cost,bu-costs}` (self-scope, no finance role) + MCP `finance_my_attributed_cost` / `finance_my_bu_costs`. Frontend `/me/my-money` page (hero cost + breakdown + 12-mo trend bars + a BU-owner section that renders only if the caller leads a BU + cross-links to /me/expenses, /me/budget, /me?tab=seats) + nav item. No migration.
>
> **Leave-health insights — utilisation governance by BU / team / lead (PR1 backend)** (2026-06-29, HR; migration **359** / **+1 cron** / **+1 MCP→hint 384** / 0 RBAC). New HR analytics surface that reframes **low annual-leave utilisation by a BU / team / lead as a team-health / leadership risk signal** (a team that doesn't rest = pressure/overtime culture), not diligence. Read-only governance view, gated by `require_hr_read()` (global_admin/hr/auditor/management); **no pushes, no employee disturbance**. **Metric (verified against prod 2026-06)**: CN annual leave is granted as a single upfront `ACCRUAL` at fiscal-year start, so `hr_leave_accounts.entitled_days` IS the full-year quota (`entitled ≈ used + balance`) → utilisation = `used/entitled` over the *current* annual buckets (法定 `CHN_AnnualLeave` + 福利 `CHN_PL` + `MY_AnnuL`). The **primary health classifier is RELATIVE** — group utilisation ÷ company utilisation (leave is seasonal, so a flat pace line unfairly flags everyone in spring); thresholds (`healthy_ratio` 1.0 / `watch_ratio` 0.6 / `low_utiliser_ratio` 0.5 / `min_group_size` 3) live in the `hr.leave_insights.thresholds` platform setting; account-type classification overridable via `hr.leave_insights.account_type_overrides`. 调休 (`CHN_COMPL`) is surfaced **separately** as banked-overtime; sick/paternity excluded; people with no quota are "no data" (never 0%); groups under the min-size guard are `insufficient_data` and kept out of the ranking (privacy). The aggregation engine [`leave_insights_service.py`](../../server/app/domains/hr/leave_insights_service.py) reuses `bu_helpers` (BU = top org_path segment), `fiscal_year.py` (day-level pace), and resolves the **lead's full reporting subtree** via an in-memory `manager_person_id` BFS (no per-lead query). **HTTP** (5 endpoints under `/api/v1/hr/leave/insights/`): `overview` (company baseline + health distribution + top "needs attention" groups), `groups` (ranked BU/department/lead table), `members` (drill-down), `trend` (daily series), `export.xlsx`. **Trend**: daily snapshot table `hr_leave_insight_snapshots` (migration 359) filled by cron **`hr.leave_insight_snapshot`** (07:10 Asia/Shanghai, after the 06:30 leave sync; one idempotent same-day wholesale-replace per day) — **no historical backfill** (SF leave is current-state), the series accumulates from launch. **MCP** `get_leave_health_insights` (read, 1:1 with overview/groups; hint 384). **PR2 (frontend, 2026-06-29)**: [`LeaveHealthInsightsPage`](../../web/src/pages/admin/LeaveHealthInsightsPage.tsx) at `/admin/hr-tools/leave-health` (HR-system menu; `RoleRoute` global_admin/hr/auditor/management) — KPI strip + reverse-intuition explainer + By-lead/BU/team ranking (health-coloured utilisation bars + vs-company + low-utilisers + forfeit + comp) + health-distribution donut + drill-in `Drawer` (KPI tiles + `LineTrend` trend + member detail); ECharts via `reportsShared`; auth-aware xlsx download; `leaveInsights.*` i18n × zh/en/ja. Preview-verified (en + zh, dimension switch, drawer, trend, donut). **Review hardening (2026-06-29)**: a `uncovered` health state (group with 0 covered members — mostly non-CN teams, since SF EC Time-Off only carries the CN entity's leave: 237/419 active staff covered; the DE/EU SF instance is synced but its APAC/US staff have ~no Time-Off accounts) is split out from `insufficient_data` (small-but-has-data privacy guard) and hidden from the ranking by default (`uncovered_count` surfaced); both `uncovered` + `insufficient_data` groups have their metrics redacted in `groups`/export, and the `members`/`trend` drill-down 4xx/empties for sub-min groups so an individual's leave can't be reconstructed; the reporting fiscal year is derived from the actual `is_current` annual accounts (not the wall clock) and the pace index returns null in the first ~2% of the FY (kills an FY-boundary divide-by-~0 artifact).
>
> **Budget commitment layer — open-PO forward exposure in budget-vs-actual** (2026-06-28, finance PR4/6; 0 migration / 0 cron / +1 MCP→hint 370 / 0 RBAC). Budget/actual/burn/run-rate-forecast/alerts were already shipped (budget_service, migration 304); this adds the missing "committed but not yet spent" leg. `finance_governance_coordinator.open_commitments` = approved/received POs not fully paid (paid → already actual, excluded), summed per department/cost-centre in USD (total_cny→CNY else total_amount+currency, current-month rate). `budget_service.budget_vs_actual` now adds per-budget `committed_usd` + `remaining_usd` (annual − ytd_actual − committed) + commitment-aware forecast (year-end exposure floored at ytd+committed) + `commit_burn_pct`; totals gain `total_committed_usd`/`total_remaining_usd`. Commitments match a budget on department/cost_center (POs carry no clean cost_type — documented limitation). `GET /finance/governance/commitments` + MCP `finance_open_commitments`; `finance_budget_status` returns the new fields. FinanceBudgetPage gains Committed + Remaining columns. No migration.
>
> **Month-end book-close orchestration — calendar engine + period lifecycle** (2026-06-29, book-close PR1/6; migration **355** / 0 cron / 0 MCP / 0 RBAC). New finance sub-domain that absorbs the PingCAP finance team's hand-maintained month-end close checklist (the `finance-close-checklist.vercel.app` localStorage app: 129 tasks, owners, workday-anchored deadlines, status tracking) into Helix. **Orthogonal to `finance_period_close`** (which freezes the spend-ledger numbers) — this is the operational close *checklist / calendar / period* orchestration. PR1 is the foundation: a **pure, DB-free, fully unit-tested calendar engine** [`close_calendar.py`](../../server/app/domains/finance/close_calendar.py) turning a closing period (YYYY-MM) + calendar config (holidays / 补班日 makeup days / weekend-work rule / target workday) into the **book-close target date** (Nth business day of the following month, WD5 default), the **close window** (Day+1 → close, each day classified workday/weekend_work/makeup/holiday/weekend_off + WD-numbered), and the **deadline** for any task anchor. Anchoring (reverse-engineered + day-by-day tested against the live tool): **Day-N** = calendar countdown from the Day-1 anchor (may land on a weekend); **Day+N** = date of the Nth business day (skips weekends/holidays); **weekend-work promotion** = when the close window overlaps a full Sat+Sun weekend, the configured work day (Sat default) counts as a business day (monotone-fixpoint solve). Validated against the two real cases (June close = Mon Jul 6 / Sat Jul 4 = WD4; rolled-forward July close = Thu Aug 6 / Sat Aug 1 = WD1) + holiday∩makeup interaction (Sep close lands in Oct golden week, Sat Oct 10 makeup = WD3). **4 tables (no FKs; person ids CHAR(36) per the finance TiDB FK-avoidance convention)**: `finance_close_calendar_config` (singleton: target workday + weekend rule), `finance_close_holidays` (day×country unique), `finance_close_makeup_days`, `finance_close_runs` (per period: editable Day±1 anchors + manual close-date override〔audit reason required〕 + lifecycle open→in_progress→soft_closed→closed〔reopenable〕 + **frozen snapshot** locking the close date/target/weekend at soft/hard close so a later config change can't retroactively move a closed period). Migration 355 idempotently seeds CN holidays 2024–2027 (120) + PingCAP makeup days (6) + the singleton config, from the pure-data [`close_calendar_defaults.py`](../../server/app/domains/finance/close_calendar_defaults.py) (safe to import from the migration); the authoritative 129-task templates land in [`close_seed/close_checklist_seed.json`](../../server/app/domains/finance/close_seed/close_checklist_seed.json) for PR2's task model. **HTTP** [`close_router.py`](../../server/app/domains/finance/close_router.py) at `/api/v1/finance/close/*` (runs list/get〔preview when unmaterialised〕/create, anchors PATCH, override, status, config GET/PUT, holidays + makeup CRUD, restore-defaults). RBAC reads = `can_view_finance`, writes = `can_operate_finance` (finance team's own workflow) + leaver-gate + rate-limit + full audit. PR2 adds the task model + templates + roll-forward.
>
> **Month-end book-close orchestration — task model + templates + roll-forward** (2026-06-29, book-close PR2/6; migration **356** / 0 cron / 0 MCP / 0 RBAC). The operational task layer on PR1's calendar. 3 tables (no FKs): `finance_close_task_templates` (the canonical **129-task library** — process/sub-process/description/owner/frequency/calendar-day anchor/est-hours/submitToFPA/ai-work/link/entity-codes/sort; `source_key` for idempotent upsert), `finance_close_tasks` (**per-period instances** snapshotting the template then tracked independently: status not_started/in_progress/completed/na + computed deadline + comment + completion; unique `(period, instance_key)`), `finance_close_entities` (the checklist's entity shorthands USP/TEJ/… best-effort mapped to `asset_legal_entities`). Template + instance share columns via a SQLAlchemy declarative **mixin** (each table owns independent Columns). Cross-domain reads go through [`finance_close_coordinator.py`](../../server/app/core/coordinators/finance_close_coordinator.py): **owner resolution is email-hint-only** (the 3 seeded `ownerEmailHints` resolve to `identity_persons`; first-name labels like "Grace" are never name-guessed — proof not pattern — they keep `owner_label` for manual mapping); entity codes map to legal entities by code/name (unmatched → NULL, admin-editable). `ensure_seeded` idempotently upserts 129 templates + 9 entities + re-resolves owners/links (never NULL-clobbers an admin-set owner); seeded lazily on first template view. `instantiate_from_templates` populates a period (ongoing freq → in_progress, else not_started; deadlines from the run anchors); `roll_forward` clones the *source period's* tasks (carrying owner reassignments + ad-hoc tasks) with status reset + deadlines recomputed (idempotent per `(period, instance_key)`; falls back to template instantiation). Editing a run's anchors recomputes every task's deadline. `aggregate` returns overall + per-owner + per-process status counts + completion %. HTTP (same `/api/v1/finance/close/*`, [`close_task_router.py`](../../server/app/domains/finance/close_task_router.py)): task list〔filters+aggregate〕/create/update/status/comment/delete, bulk status + reassign, instantiate, roll-forward, templates〔+seed+owner map〕, entities〔+map〕, legal-entities picker. Reads `can_view_finance`, writes `can_operate_finance` + leaver + rate-limit + audit. PR3 adds the frontend console.
>
> **Month-end book-close orchestration — frontend console** (2026-06-29, book-close PR3/6; frontend-only, 0 migration). [`FinanceClosePage`](../../web/src/pages/finance/FinanceClosePage.tsx) at `/admin/finance/close` (menu 「月结关账」, RoleRoute global_admin/finance/auditor): month switcher + close-target card (target date + lifecycle status + weekend-work/override badges + edit-override) + Day±1 anchor editors (save → recompute) + close-window strip (`WindowStrip`: WD1…WD5 colour-coded by day-kind, CLOSE marker) + progress overview (completion gauge + per-owner cards) + multi-select filters + per-process grouped task table (`Collapse` per process with completion bar; inline status `Select`, inline comment, overdue-red deadline, edit/delete). Modals: `CloseConfigModal` (4 tabs: holidays/makeup CRUD, weekend-work rule, settings + restore-defaults), `CloseTaskModal` (full fields + UserSelect owner + entity multi-select), override (date + mandatory reason), roll-forward (preview next period + create). Client-side CSV export of the filtered tasks. New API client [`financeClose.ts`](../../web/src/api/financeClose.ts) (typed, mirrors the backend serializers); i18n 125 keys × zh/en/ja (`financeClose.*`, no hardcoded CJK). Write controls gated by `<PermissionGuard roles={['global_admin','finance']}>` (read-only roles see tags, not selects). Verified via Preview (global_admin, 2026-05 with 129 tasks): empty→generate, populated grouped table, inline status + overdue-red, close-window WD1→WD5 CLOSE, per-owner cards matching the source distribution exactly; tsc/eslint/build all pass. PR4 adds MCP + the employee/manager surface.
>
> **Month-end book-close orchestration — MCP + employee self-service** (2026-06-29, book-close PR4/6; 0 migration / 0 cron / **+3 MCP→hint 380** / 0 RBAC). Connects close tasks to AI clients + gives owners a no-console self-service surface. Key fact: `AuthUser` is an alias for `IdentityPerson`, so `user.id == identity_persons.id == owner_person_id` — self-service scopes by owner with no extra mapping. **Self-service** [`close_self_service.py`](../../server/app/domains/finance/close_self_service.py) + [`close_self_router.py`](../../server/app/domains/finance/close_self_router.py) at `/api/v1/me/close-tasks` (GET my tasks for a period〔default: my latest〕 + status + comment) — strictly owner-scoped (service enforces `owner_person_id == caller`, non-owner 403; no finance role needed, owning the task is the authority) + leaver-gate + rate-limit + audit. **MCP +3** (hint 377→380): `finance_close_status` (close progress = run + window + aggregate, finance-view), `finance_my_close_tasks` (caller's own tasks, self, read), `finance_close_set_task_status` (status write, **task owner or finance operator**, mutating) — 1:1 with the `/me/close-tasks` flow + the console status write (MCP-sync rule). **Mobile**: new action-inbox provider [`_provider_close_tasks`](../../server/app/mobile/action_inbox.py) surfaces one aggregate "Close tasks" todo for owners with open tasks (pull-only, never a push; a single indexed MAX query for the 99% who own none; severity escalates if overdue; deep-links `/m/close-tasks`) + mobile page [`MCloseTasksPage`](../../web/src/mobile/pages/MCloseTasksPage.tsx) (`/m/close-tasks`, antd-mobile: period card + my-tasks list + tap-to-set-status ActionSheet + PullToRefresh + four-state). Frontend API client gains `me` methods; i18n +4 keys × 3 langs incl. `mobile.inbox.type.close_tasks`. Verified via Preview (a dev user owning 6 June tasks): mobile page renders the period card + task list, and the unified to-do inbox shows the warm "Close tasks · June 2026" item with a severity dot + deep link. 194 backend tests pass; MCP surface test (hint 380) green. PR5 adds the Helix-native auto-signals.
>
> **Month-end book-close orchestration — Helix-native auto-signals** (2026-06-29, book-close PR5/6; migration **357** / 0 cron / **+1 MCP→hint 381** / 0 RBAC). Generalises the controller close-cockpit's go/no-go checks into a named **signal registry** and binds close tasks to live green/amber/red decision lights — the differentiator only Helix can do (the source app can't). [`close_signals.py`](../../server/app/domains/finance/close_signals.py) `evaluate_signals(db, period)` → 8 signals, each `{key, status: ok/warn/fail/na, detail, evidence_link}`: the 5 cockpit checks (reused from `read_service.close_cockpit` — single source of truth) + `fx_frozen` (`finance_fx_monthly` frozen for the period), `cloud_cost_materialized` (`finance_spend_facts` cost_type=cloud), `huilianyi_synced` (`finance_external_statements`). All finance-domain reads (no cross-domain import); each carries an evidence deep-link to the relevant finance page. **Task↔signal binding**: migration 357 adds nullable `signal_key` to `finance_close_task_templates` + `finance_close_tasks` and backfills 15 curated default bindings (templates by `source_key`, instances by `template_source_key`, NULL-only — existing periods light up without a re-instantiate); the bindings live in the pure [`close_signal_keys.py`](../../server/app/domains/finance/close_signal_keys.py) (`DEFAULT_SIGNAL_BINDINGS`, migration-safe to import). `_SHARED_FIELDS` gains `signal_key` so instantiate/roll-forward copy it; the seed loader sources it from the bindings. **Signals are decision support only — never auto-complete a task** (data green is necessary, not sufficient; completion stays a human judgement). HTTP: `GET /runs/{period}/tasks` now returns a `signals` map alongside tasks+aggregate; new `GET /runs/{period}/signals`. MCP `finance_close_signals` (finance-view, read). Frontend: a per-task signal dot (tooltip with signal name · detail · evidence link) + a "Data signals (decision support)" card (8 colour-coded signals + evidence links + an advisory note). Verified via Preview: the signals card renders all 8 with correct colours on empty dev data (data/gl/ns/recon fail-red, restatements ok-green, fx/cloud/汇联易 warn-amber). PR6 closes the theme with deadline reminders + auto roll-forward.
>
> **Month-end book-close orchestration — automation (theme finale)** (2026-06-29, book-close PR6/6; migration **358** / **+2 cron (disabled)** / 0 MCP / 0 RBAC). Two scheduled jobs in [`finance_handlers.py`](../../server/app/scheduled_jobs/finance_handlers.py), both **seeded DISABLED** (`enabled=0`, controller enables when ready — same posture as the NetSuite GL sync cron; worker._run() + main.py already import the module so they auto-register). **`finance.close_auto_roll_forward`** (month-start): clones the previous period's checklist into the current period (status reset, ongoing→in_progress, deadlines recomputed for the new period), idempotent (skips if the current period already has tasks; no-op if there's no source period), with the month boundary computed in **Asia/Shanghai** (`_china_period` — a UTC boundary would roll a month 8h early/late at the edge). **`finance.close_deadline_reminder`** (daily): for the current *open* period's overdue / due-soon (default within 2 days, config `within_days`) un-done tasks, sends one digest reminder per owner — **pull-first** (the to-do inbox from PR4 is the always-on surface), with an **opt-in push** (`push_feishu`, default false = in-app bell only) that goes **only to close-task owners (the finance team), never to general employees/managers** (honours the platform no-disturb rule); reuses `NotificationService.push_inbox_and_feishu`. Both handlers **raise on failure** (so a failed run lands `runs.status` instead of silently swallowing). Migration 358 idempotently seeds both jobs (Asia/Shanghai tz, handler_key = the registered key). **The "month-end book close" 6-PR theme is fully shipped** — calendar engine → task model + roll-forward → console → MCP + employee self-service → Helix-native signals → automation.
>
> **Month-end book-close orchestration — post-theme refinements (real owners / real AI / UX)** (2026-06-29, book-close PR7–9; 0 migration / 0 cron / **+1 MCP→hint 383** / 0 RBAC). User-feedback batch on top of the shipped theme. **PR7 (owners are real people)**: `finance_close_coordinator.resolve_owner_person_ids` gains a second pass beyond email-hints — a **finance-role-scoped unique name match** (`_finance_role_person_ids` + match on display_name/first_name/"first last"; 0 or >1 candidates stay unresolved — proof not pattern) so first-name labels (Grace/Eric/Kevin Sun…) auto-resolve in prod where the real roster exists; `ensure_seeded` backfills existing instances' `owner_person_id` from the resolved templates (NULL-only, idempotent); the task-list endpoint resolves ids → real names at read-time (`resolve_person_names`). Frontend owner column shows the real name + an inline real-people reassign (`UserSelect` in a popover). Reminders now reach real owners. **PR8 (real AI)**: the `ai_work` assist/perform flag drives a genuine 百炼 call ([`close_ai_service.py`](../../server/app/domains/finance/close_ai_service.py), platform `ai_spending.llm.*` key, deterministic fallback, advisory-only — never completes/posts) grounded in the task context + its live signal; `POST /finance/close/tasks/{id}/ai-assist` (operate + RL + audit, read-only generation) + **MCP `finance_close_ai_assist`** (hint 382→383); frontend AI tag → clickable button → an AI drawer (mode + source 百炼/template + editable text + regenerate + adopt-as-comment). **PR9 (UX)**: real server-built **`.xlsx` export** `GET /finance/close/runs/{period}/tasks/export.xlsx` (view-scoped, audited via `xlsx_export_response`, `?ids=` narrows to the on-screen rows) replacing the client CSV; a Day(calendar-day) multi-select filter; an **entity-matrix** toggle (one ✓/· column per legal entity); inline editing of calendar-day anchor + frequency (day change recomputes the deadline server-side); Est-hrs column + FP&A tag; search broadened to description + sub-process + owner. All three merged + auto-deployed from main.
>
> **Expense intelligence + employee 我的报销 — 汇联易 owner attribution unblocked** (2026-06-28, finance PR3/6; 0 migration / 0 cron / +2 MCP→hint 369 / 0 RBAC). 汇联易 reconciliation/hygiene/cost-center coverage were already shipped (2026-06-17); cron activation is an EXTERNAL 开放平台 read-grant (not code; prod returns 0 docs until granted). This PR adds the genuine gaps: (1) **OID→Helix-person resolver** in `finance_huilianyi_coordinator` — `_build_person_email_index` (work_email/personal_email → id) + `_resolve_owner` (tolerant email match on the doc); `map_documents_to_statements` now attributes each statement to a Helix person by email (was always NULL — the documented TODO), unblocking per-employee analytics + self-service. A miss stays NULL (still reconciles by cost-centre). (2) **Employee `/me/expenses` 我的报销** self-service page + `GET /finance/me/expenses` (self-scope) + MCP `finance_my_expenses` — own reimbursement docs + status summary, warm empty state. (3) **`read_service.expense_analytics`** (by category/dept/payment-status + top spenders) + `GET /finance/governance/expense-analytics` + MCP `finance_expense_analytics`. NOT done: cron stays disabled (needs the external grant); the analytics admin UI is deferred until data flows (backend+MCP queryable; no speculative empty UI).
>
> **Finance-flux consolidated-from-GL — re-test of the "manual .xls only" rule + tie-out probe** (2026-06-28; 0 migration / 0 cron / +1 MCP→hint 367 / 0 RBAC). The earlier finance-flux conclusion ("SuiteQL can't reproduce NetSuite consolidation → the .xls export is the only faithful source", DECISIONS 2026-06-27) was RE-TESTED at the user's request and found too strong: the "raw GL ≈ 6× the report" symptom was from summing each subsidiary's base-currency amount WITHOUT consolidated exchange rates. New `app/core/coordinators/finance_flux_gl_coordinator.py` computes consolidated statement lines via `transactionaccountingline` ⋈ `consolidatedexchangerate` on `(postingperiod, fromsubsidiary, tosubsidiary=root)`, applying the account-nature rate (assets/liabs=currentrate, P&L=averagerate, equity=historicalrate — the CTA source), mapped to flux line_keys via `finance_flux_account_map`. `tie_out(period, statement)` diffs the API-computed lines against the `.xls` figures in `finance_flux_lines` (the decisive evidence); `import_from_gl` is the opt-in convergence (populate flux lines from GL). Endpoints `POST /finance-flux/{gl-tieout,import-from-gl}` (can_operate_finance) + MCP `finance_flux_gl_tieout`. **The live numeric tie-out runs post-deploy** (no NS creds in CI); until it confirms, the .xls flow is unchanged and the DECISIONS entry stands. If it ties out, Flux drops the manual upload and DECISIONS is amended. NOTE: this consolidated path (NS consolidated rates) is distinct from PR1's `finance_gl_actuals` (per-subsidiary GL at a generic monthly FX, for cost-type reconciliation) — different precision for different uses.
>
> **NS GL reconciliation engine + Controller close cockpit** (2026-06-28, finance PR2/6; migration **351** / 0 cron / +2 MCP→hint 366 / 0 RBAC). Closes the loop on top of PR1's GL inbound. `finance_ns_accounts` gains `mapped_cost_type` (account → Helix cost_type), seeded NULL-only by a name/type heuristic on master sync (`infer_cost_type`, never overrides a human/`__none__` mapping) + admin-editable. `read_service.gl_reconcile(period)` compares Helix bottom-up `finance_spend_facts` (per cost_type, on the booked-matching basis) vs NetSuite GL aggregated by `mapped_cost_type` → internal/gl/diff + tolerant match (max $1k/5%) + the booked-but-unclassified exception list. `read_service.close_cockpit(period)` = a 5-item go/no-go checklist (data complete / GL synced / NS period closed / recon clean / no open restatements) + close state. Endpoints `GET /finance/netsuite/reconcile`, `GET /finance/close-cockpit`, `PATCH /finance/netsuite/accounts/{id}/cost-type`. MCP `finance_gl_reconcile` / `finance_close_cockpit`. Frontend: **关账驾驶舱** (`/admin/finance/close-cockpit`) + **NetSuite 总账接入** (`/admin/finance/netsuite-gl`) pages, 2 menu items, 3-lang i18n. **Reconciliation compares COST streams only, never consolidated statements** — flux's .xls report stays the only faithful consolidated source (DECISIONS 2026-06-27); the planned "flux→GL convergence" was CANCELLED for that reason. Reconciliation only compares specific expense/cost accounts, so consolidation elimination/CTA doesn't apply.
>
> **NetSuite GL inbound — book of record as a reconcilable sub-ledger** (2026-06-28, finance PR1/6; migration **350** / +1 cron 默认禁用 / +2 MCP→hint 364 / 0 RBAC). NetSuite goes from outbound-only (employee push) to **also reading GL** — the "actual" leg of the finance spine (预算→承诺→消耗→**实际(NS GL)**→差异). New cross-domain coordinator [`finance_netsuite_coordinator`](../../server/app/core/coordinators/finance_netsuite_coordinator.py) reuses the identity-sync `NetSuiteClient.suiteql` transport (same TBA creds; driver gains public `build_suiteql_client()`) to pull **summarized** GL via server-side `GROUP BY period×subsidiary×account×department` — **never raw transaction lines** (volume bounded; ~3 orders of magnitude smaller than `cmdb_cost_facts`). 4 new tables (all standalone, no FK, TiDB CHAR/VARCHAR FK-avoidance): `finance_gl_actuals` (signed GL movement, USD via month-frozen FX, `is_closed` freeze), `finance_ns_subsidiaries` (+human-set `legal_entity_id`→`asset_legal_entities`, preserved across re-sync), `finance_ns_accounts`, `finance_ns_accounting_periods` (closed flag + YYYY-MM derive, quarter/year roll-ups flagged so GL pull excludes them). **Volume discipline**: closed periods pulled once then frozen, open period idempotent-replace per (source,period). Admin read-only probe `POST /finance/netsuite/gl-probe` runs a minimal SuiteQL and returns raw rows to confirm the live column shape **before enabling** the cron (`finance.netsuite_gl_actuals_sync`, daily, ships disabled — same activation pattern as 汇联易; handler refreshes masters inline then pulls prev+current). Read surface `/finance/netsuite/{gl/summary,sync-status,periods,subsidiaries}` + `PATCH .../subsidiaries/{id}/legal-entity` (write=can_manage_finance). MCP `finance_netsuite_gl_summary` / `finance_netsuite_sync_status`. **Live GL column validation is post-deploy via the probe** (no NS creds in CI; never test prod). Reconciliation (NS GL ↔ `finance_spend_facts` ↔ flux lines) + Flux convergence to the API source come in PR2.
>
> **Finance-flux — sub-account drill-down** (2026-06-27; migration **349** / 0 cron / 0 new MCP / 0 RBAC). New table `finance_flux_line_details` (period_id FK→periods, statement, parent_line_key, label, value, sort_order) stores the GL/sub-account rows the parser previously discarded, so a flux line expands to its breakdown — parity+ with the legacy portal (which had one-level account→sub-account expand but **no Δ**; we carry current/prior/Δ per sub-account by matching labels across periods). `report_parser` buffers leaf GL rows (digit-prefix, non-`总计`) and attaches them to the following **line** row only (subtotals/totals carry none); `import_service._replace_lines` wipes+restores details per (period, statement) alongside lines; `flux_service.compute_statement` nests `details[]` on each line; the `/statement` endpoint + `get_finance_flux_statement` MCP tool return them unchanged. Frontend `StatementTab` renders details as a third tree level (line → sub-accounts), collapsed by default in flat view so a large breakdown (e.g. cash ≈ 80 GL accounts across entities) doesn't flood. Both **single-period (BS)** and **multi-period columnar (P&L)** capture details — the columnar parser buffers leaf GL rows with their per-period values and attaches them to each period's category line.
>
> **GitHub ID → Feishu (reverses the "no SF source" ADR)** (2026-07-01, migration **369** / 0 cron / 0 new MCP / 0 RBAC). The 2026-06-26 ADR shelved GitHub ID as "no clean SF source" (the field tested empty then). It has since been backfilled: SF custom MDF entity **`cust_Other`** (keyed by `externalCode`=employee_no) carries the GitHub handle in **`cust_id`** (verified live 2026-07-01 — real handles like `KennethLeeJE8`). Wired end-to-end: migration 369 adds `identity_persons.github_id` (String(128), nullable); `SuccessFactorsClient.list_github_ids()` pulls `cust_Other`; `HrSyncService._ensure_master_data_loaded` caches it (keyed by employee_no, **junk `无`/`none`/`N/A`/empty filtered**) and `_apply_event` writes `person.github_id` **write-when-present, never NULL-out** (gender's pattern — only the hr/service path, not successfactors_sync, since cust_Other is its own entity not an EmpJob column); `mapping.py` registers the `github_id` source field; `FEISHU_CONTACT_USER_MAPPING_RULES` maps `{github_id}` → `custom_attrs.github_id` (resolves to the live "GitHub ID" schema `C-6934211695879389211` by normalised i18n_name). **Coverage is sparse today** (~23 `cust_Other` records, ~10 real handles) — write-when-present means those sync and everyone else keeps Feishu's existing Authing-era value. ADR reversal in DECISIONS.md (2026-07-01).
>
> **Feishu sync cutover-hardening — sibling-scoped dept matching + mobile country code** (2026-06-30, 0 migration / 0 cron / 0 new MCP / 0 RBAC). Two gap-fills before Helix takes over Feishu's contact sync from Authing, mirroring Authing's mature live config (verified 2026-06-30: dept compare keys on `open_department_id`+`fullPathName`; user mobile = `(phoneCountryCode||'+86')+phone`). **(1)** `FeishuDepartmentRunner` adoption is now **sibling-scoped** — `prime_catalog` builds a `(parent_od, normalised_name) → od` index + a `name → distinct-od count`; `upsert_dept` adopts by `(parent_od, name)` first (parent already adopted via the topo-sorted sweep), falling back to a **globally-unique** flat-name match only. This kills the flat-index collision where two same-named depts under different parents (two "Sales") aliased to one od and thrashed; `prior_external_id` (state-table od) stays the first-priority path so renames PATCH in place (members + 部门群 preserved, never re-created). The match logic is the shared `match_existing`, used by both `upsert_dept` and the read-only `dry_run_feishu` preview (now topo-ordered with a running parent-od map) so preview and real run can't drift. **(2)** `feishu_contact._build_user_body` formats `mobile` to `+<calling-code><number>` via `_format_mobile_e164`, resolving the code from the person's ISO-3 `country` (default +86), stripping the national trunk 0, idempotent on already-`+`/`00` numbers. (GitHub ID was subsequently MAPPED — its source turned out to be backfilled in SF `cust_Other.cust_id`; see the 2026-07-01 entry above + migration 369.) Sync switch remains OFF; cut over after `dry_run_feishu` shows zero unexpected creates.
>
> **HRIS → Helix → Feishu identity sync — replacing Authing as Feishu's upstream** (2026-06-26, PR1 of 3; migration 344 / 0 cron / 0 new MCP / 0 RBAC). Legacy chain was 北森→Authing→飞书; Authing's *only* downstream is Feishu, so Helix (already the SF golden record) becomes Feishu's direct upstream and Authing retires. The `feishu_contact` identity-sync target stays the projection surface. **PR1 (golden-record ingestion)**: migration 344 adds three `identity_persons` columns — `gender` (SF `PerPersonal.gender`), `employee_type_code` (SF `EmpJob.customString25` 1070正式/1072实习生/…), `office_code` (SF `EmpJob.customString7` 2659 sh-office/2653 sv-office/…) — all `String(16)`, nullable, no FK, **stored RAW** (vendor/enum translation lives in the feishu target value-maps, same convention as `country` holding ISO-3). SF client: `EmpJob.$select` (both `list_current_jobs` + `get_jobs_for_users`) gains `customString7`/`customString25`; the PerPersonal master pull (`list_personal_birthdays`) gains `gender` (shares the existing fetch). Written in `hr/service.py::_apply_event` (+`_personal_genders` cache in `_ensure_master_data_loaded`) and mirrored in `successfactors_sync.py::sync_users`; all "write-when-present, never NULL-out". Tenant `$select` acceptance verified against prod SF read-only. **Not ingested** (no clean SF source — left to Feishu's existing values): GitHub ID (`cust_Other.cust_id` empty in tenant), Job Function, Job Family. **Sync switch remains OFF** — these PRs only enrich the golden record + the (disabled) feishu target config. **PR2 (feishu_contact projection — shipped)**: `FEISHU_CONTACT_USER_MAPPING_RULES` now projects the SF fields to Feishu. (a) **Location custom-attr fixed** — was mis-sourced from `{location}`=work_location_name ("上海市"); now `{office_code}` → `OFFICE_CODE_TO_FEISHU_LOCATION` (2659→"SH Office" etc., 7/11 labels verified live). (b) **gender + employee_type** standard fields added with **live-verified value-maps** (M→1/F→2; 1070/1071/1073→1, 1072→2, 1074→3, 1075→5); the runner `_build_user_body` int-coerces both (Feishu wire = int) like `join_time`. (c) **Contact Email** custom-attr (=work_email). (d) **job_function removed** — the old `{business_unit}`→job_function rule pushed "Business Operations" (org L1), not the "IT" Feishu carries; Job Function/Job Family have no faithful SF source so they're left unmapped (Feishu keeps its values). `mapping.py` registers `gender`/`employee_type_code`/`office_code` source fields. A value-map miss renders empty → field dropped → Feishu unchanged. **PR3 (closure — no code)**: the Authing doc's remaining sync-logic was verified (prod SF + Feishu read-only) to need NO code on Helix — eventReason D06/B01 leaver-filter is already equivalent to the `employment_status` filter; the DP00001-04 CN/overseas entity partition is an Authing **dual-SF-instance** dedup artifact (Helix pulls everyone once from the single `api30.sapsf.cn` tenant, so it doesn't apply — DP00001-04 are real overseas employees that *should* be in Feishu); `enterprise_email` (.com.cn) is Feishu-Mail-managed + sparse (None for most users in a 5-country sample) so it's left untouched. PR3 ships a reverse-guard test (`test_feishu_preset_omits_no_source_and_feishu_managed_fields` pins enterprise_email/job_function/job_family/github as NOT mapped) + a DECISIONS.md ADR. **3-PR theme complete** — Helix carries every clean-sourced field Authing fed Feishu; the sync switch stays OFF until the user enables it.
>
> **software_monthly_bills addon idempotency** (2026-07-03; migration **382** = `UNIQUE(period, charge_id)` / 0 cron / 0 MCP / 0 RBAC). Closes an addon-line double-billing hole: `UNIQUE(period, allocation_id)` never protected `line_type=addon` rows (allocation_id is NULL; MySQL UNIQUE treats NULLs as distinct). `charge_id` is non-NULL only for addon lines, so the new key is the addon twin. Paired code fix: `close_month` now commits before releasing its per-period advisory lock (releasing with uncommitted rows re-opened the race). See CHANGELOG 2026-07-03 audit-round-7.

> **Intern attendance — HR-maintainable roster overlay** (2026-07-03; migration 380 / 0 cron / 0 new MCP / 0 new RBAC — HR-gated). The intern roster is still auto-resolved live from the Feishu 通讯录 department each generate, but HR can now **patch** it without losing that automation. New table `attendance_roster_overrides` (`id`/`person_id`/`created_by` via `id_type_for('identity_persons')`, no FK per the attendance-domain convention, `UNIQUE(person_id)` — one override per person, mode switch upserts in place): `mode='include'` drags an active Helix person into the report even when they are NOT in the Feishu department (the 许晴 case — onboarded in Helix but never added to the Feishu group, so she showed up in neither the roster nor 「待补」), `mode='exclude'` keeps a Feishu member out. Effective roster = `(Feishu members ∪ includes) − excludes`, applied in [`attendance/roster.py`](../../server/app/domains/attendance/roster.py)::`resolve_intern_roster` (`load_roster_overrides` helper; excludes win). HR endpoints `GET/PUT/DELETE /api/v1/attendance/roster/overrides` (HR-gated + audited `attendance.roster.override.set|delete`; upsert validates the person exists). Frontend: `/admin/attendance` page-header 「花名册维护」 drawer ([`web/src/components/AttendanceRosterDrawer.tsx`](../../web/src/components/AttendanceRosterDrawer.tsx) — UserSelect + include/exclude Segmented + two PersonTag lists, "regenerate to apply" hint); trilingual i18n (+21 keys). Overlay is **global** (applies to every subsequent generate, not per-cycle). No MCP tool (HR management op, same class as send/resend/withdraw).
>
> **Intern attendance monthly-report — frontend (theme complete)** (2026-06-25, PR-C of 3; 0 migration / 0 cron / 0 new MCP). HR desktop console at `/admin/attendance` ([`web/src/pages/admin/AttendancePage.tsx`](../../web/src/pages/admin/AttendancePage.tsx), menu under both the `hr` and `global_admin` identity groups): cycle picker + 生成本周期 (year/month modal) + status/keyword filter bar (client-side over the loaded page) + per-intern status board (PersonTag, status badge via `attendanceStatusColor`, attendance summary, "unmapped-to-Feishu" data-quality flag) + row checkboxes + row actions (send / resend / **withdraw** / **reinitiate** / detail) + send-all-pending **and send-selected** (shared `sendBatch`) + a detail drawer (abnormal-day list + event timeline). (HR-action enhancements 2026-06-26: filter + multi-select send + withdraw + reinitiate.) Intern/leader **mobile H5**: `/m/attendance` (my reports, intern/leader dual perspective) + `/m/attendance/:id` ([`MAttendanceDetailPage`](../../web/src/mobile/pages/MAttendanceDetailPage.tsx) — summary grid + abnormal detail + timeline + confirm / return-with-reason / undo gated on the server's `can_*` flags) — **this is where the Feishu card's 查看明细/退回 button lands** (`cards.py` link changed from `/me/attendance` to `/m/attendance/{id}`). New [`web/src/api/attendance.ts`](../../web/src/api/attendance.ts) domain client + types; trilingual i18n (81 keys). The **3-PR theme is complete**: [#1410](https://github.com/pingcap-inc/Helix/pull/1410) backend foundation + [#1411](https://github.com/pingcap-inc/Helix/pull/1411) lifecycle + this frontend PR.
>
> **Intern attendance monthly-report — confirmation lifecycle** (2026-06-25, PR-B of 3; migration 340 / +1 cron / +4 MCP→hint 352 / 0 new RBAC role). Two-stage state machine in [`attendance/lifecycle.py`](../../server/app/domains/attendance/lifecycle.py): `generated ─send→ pending_intern ─intern_confirm→ pending_leader ─leader_confirm→ completed`; intern **and** leader can return-to-HR with a reason (`returned`); the intern can **undo** their confirmation while it's still with the leader (`pending_leader→pending_intern`); HR **resend** re-pulls the latest Feishu data (reflecting 补卡) then sends. **HR withdraw** (2026-06-26) pulls a sent report (`pending_intern`/`pending_leader`) back to `generated`, cancelling the in-flight confirmation (event `withdrawn`, notifies the current holder best-effort); **HR reinitiate** (2026-06-26) re-opens a `completed` report — re-pulls the latest data (bumps version), clears both sign-offs, and sends to the intern again for a fresh round (event `reinitiated`). `POST /reports/{id}/withdraw|reinitiate` are HR-gated + audited (HR management ops, no MCP tool — same as send/resend). Every transition is idempotent-guarded, writes an append-only `attendance_report_events` row by `report_id` (never via the lazy relationship — that caused a MissingGreenlet on autoflush, fixed in PR-A), and dispatches **best-effort** notifications (a Feishu failure never rolls back a committed transition). Confirmation cards are built in [`attendance/cards.py`](../../server/app/domains/attendance/cards.py) and rendered by `NotificationService.dispatch` (stats + abnormal-day highlights + 确认 / 查看明细·退回 buttons). **One-click, no-login confirm**: `GET/POST /api/v1/attendance/confirm?token=…` decodes an `attendance_confirm` action-token (capability JWT bound to subject=person, resource=report) and confirms the right stage by whether the subject is the intern or the leader (GET shows a POST-form page; GET never mutates). **Self-service H5**: `GET /api/v1/attendance/me/reports[/{id}]` + `POST …/{id}/confirm|return|undo` — all logic centralised in [`attendance/self_service.py`](../../server/app/domains/attendance/self_service.py) so the H5 endpoints and the MCP tools share one authorization + state path. **HR**: `POST /reports/{id}/send` and `/resend`. **Reminder cron** `attendance.pending_reminder` (migration 340 adds `attendance_reports.last_reminded_at` + seeds a daily 10:00 job): only nudges already-sent in-flight reports stale beyond `remind_after_hours` (default 48h), capped per run, never re-nags inside the window, and is globally switch-off-able via `attendance.reminder_enabled`. **MCP** +4 self-service tools (`list_my_attendance_reports` + `confirm_/return_/undo_attendance…`, 1:1 with `/me/*`). The HR console + intern/leader H5 pages + i18n + help center land in **PR-C**.
>
> **Finance-flux — theme complete (PR7)** (2026-06-27, web/docs-only). `finance-flux` PageHelp hint registered (`pageHelp.finance-flux.*`, zh/en/ja) + [`docs/architecture/finance-flux-parity.md`](finance-flux-parity.md): line-by-line legacy-portal↔Helix parity, numerical-correctness record (Cash $32.23M to the cent, Total assets balances), and the legacy-Vercel-site retirement steps (outside Helix infra — finance/ops execute after cutover; Helix doesn't delete external infra). **All 7 PRs merged — the module is end-to-end.** Open follow-ups (backend complete): highlight/insight-card + follow-up authoring widgets; scheduled-report→email→Helix auto-delivery (needs a mailbox).
>
> **Finance-flux — frontend (PR6)** (2026-06-27, web-only). `web/src/api/financeFlux.ts` (typed client for every endpoint) + `web/src/pages/finance-flux/`: **FinanceFluxPage** (period select + MoM/YoY toggle + report Upload→`/import` + 4 tabs) — Summary (equal-height KPI cards with tone top-border + variance-flag banner), Balance Sheet & P&L (`StatementTab`, reused by `statement` prop: per-line prior/current/Δ/Δ%/flag table, **inline-editable commentary + recon link**, per-line **AI-generate** button → 百炼 narrative → "use as commentary" adopt, bold totals, `scroll x:max-content`), Insights (Top BS/PL variance rankings). **FinanceFluxConfigPage** (`/admin/finance-flux/config`, 3 tabs: general config / line catalog / account map — all finance-editable inline). Routed at `/admin/finance-flux{,/config}`, menu entry under the Finance Hub group. Full zh/en/ja i18n (~70 keys each, no hardcoded CJK — passes the i18n ESLint guard). Loaders use the `useCallback`+`void load()` pattern to satisfy `react-hooks/set-state-in-effect`. Verified: `tsc --noEmit`, eslint, and `pnpm build` all pass. Remaining UI polish (backend already supports it): authoring widgets for highlight/insight cards + follow-ups (PR4 CRUD exists; only the inline editor widgets are pending).
>
> **Finance-flux — finance self-serve config (PR5)** (2026-06-27, 0 migration / 0 cron / 0 new MCP / 0 new RBAC role). [`config_service.py`](../../server/app/domains/finance_flux/config_service.py) + 8 endpoints make every flux setting finance-editable (`can_operate_finance` — the finance team, **not** reserved to global admin; reads `can_view_finance`): the `finance_flux_config` singleton (`GET/PUT /config` — amount/pct thresholds, currency, NS consolidation scope, `comparison_modes`, `closed_periods_only`, AI on/off + overridable prompt; PUT audited), the statement-line catalog (`GET /line-defs` + `PATCH /line-defs/{id}` — label/section/order/owner/per-line threshold override/active), the KPI catalog (`GET /kpi-defs` + `PATCH` — label/source-line/order/higher_is_better/active), and the NS account→line map (`GET /account-map` with `unmapped_only` filter + `PATCH /account-map/{id}` — line_key/sign/active). NS API *credentials* stay admin-managed encrypted integration config (secrets, not finance settings). Threshold serialization is quantized to 2dp (`_dec2`) so a freshly-set value and a DB-loaded one render identically.
>
> **Finance-flux — editorial layer + 百炼 AI narratives + MCP (PR4)** (2026-06-27, 0 migration / 0 cron / +3 MCP→hint 361 / 0 new RBAC role). [`ai_service.py`](../../server/app/domains/finance_flux/ai_service.py) `generate_narrative` reuses `flux_service` for the line's Δ/flag, prompts **百炼** (`BailianLLMClient.chat_text`, key via `ai_spending.llm.*`, system prompt overridable via `finance_flux_config.ai_prompt_template`) and caches the result on `commentary.ai_narrative`; **any no-key / LLM error degrades to a deterministic numbers-grounded narrative** (never raises — the legacy portal's behaviour, but now a real upgrade when keyed); `ai_enabled` flag gates it. [`editorial_service.py`](../../server/app/domains/finance_flux/editorial_service.py): commentary/recon upsert, `adopt_narrative` (AI text → editable commentary), KPI override set/clear, insight-card (highlight+insight) CRUD, follow-up CRUD — all in tables separate from `finance_flux_lines`, so a re-import never clobbers analyst input (test-pinned). Router +11 endpoints (writes `can_operate_finance`, audited; `GET /editorial` `can_view_finance`): `POST /commentary`, `POST /ai/{generate,adopt}`, `PUT|DELETE /kpi-override`, `/cards` + `/followups` CRUD. **MCP +3 read tools** (`get_finance_flux_{summary,statement}` / `list_finance_flux_periods`, `_require_finance_view`-gated). Read MCP only — the import is file-upload (no MCP) and the editorial writes are finance-console operations.
>
> **Finance-flux — flux compute + read API (PR3)** (2026-06-27, 0 migration / 0 cron / 0 new MCP / 0 new RBAC role). [`flux_service.py`](../../server/app/domains/finance_flux/flux_service.py) diffs a period against its baseline over the imported (report-exact) values — it never recomputes consolidation. `compute_statement` walks `line_defs` in order → current/prior/Δ/Δ% + flag (none/amount/pct/both; per-line threshold override > `finance_flux_config` default; only `row_type=line` flags) + attached commentary/recon/AI narrative; `compute_summary` → KPI cards (value = `kpi_def.source_line_key`'s line value, per-period override wins; tone from `higher_is_better`×Δ) + BS+PL variance-flag banner counts; `compute_variance_rankings` → top movers by |Δ| (excludes totals); `prior_period_key` resolves mom (prior month) / yoy (same month −1yr) with year rollover. Missing prior → 0, Δ%→null. Read API (`can_view_finance`, standard envelope): `GET /api/v1/finance-flux/{statement,summary,insights}` (`period` + `mode=mom|yoy`). Read MCP tools land with PR4.
>
> **Finance-flux — NS report import engine (PR2)** (2026-06-27, 0 migration / 0 cron / 0 new MCP / 0 new RBAC role). **Data-source decision, evidence-backed**: NetSuite's consolidation (intercompany elimination + cumulative translation adjustment) CANNOT be reproduced outside NS. Verified on live prod data — a raw `SUM(transactionaccountingline.amount)` over the FINANCE:LINE classification gives Cash $185.9M vs the report's $32.23M (6×), because $30M-in/$30M-out intercompany transfers don't net the way consolidation does (US CITI acct: NS `account.balance`=$1.07M, raw GL sum=$115.6M). Granting the integration role transaction + consolidated-rate read access (done) lets SuiteQL read everything but does NOT make consolidation reproducible. **So the report's own export is the source of truth.** The NetSuite "Excel" export is **SpreadsheetML 2003 XML**; [`report_parser.py`](../../server/app/domains/finance_flux/report_parser.py) parses `ss:Row/ss:Cell/ss:Data`, skips GL detail (digit-prefixed), `总计-` GL roll-ups, and `Elim-`/`S\d+-` consolidation sub-rows, keeping only clean finance-line rows (line/subtotal/total) with section grouping + period (`May,2026 末`→`2026-05`) + BS/PL auto-detection. [`import_service.py`](../../server/app/domains/finance_flux/import_service.py) idempotently replaces a period+statement's `finance_flux_lines` and reconciles `finance_flux_line_defs` to the report (label/section/order/role = report-truth; preserves finance's owner + thresholds), resolving line_keys by **matching the report label to an existing def's key** (so the PR1 seed catalog / KPI source refs stay valid) and slugging only genuinely new lines. HTTP: `POST /api/v1/finance-flux/import` (`can_operate_finance`, multipart ≤20 MB, audited) + `GET /periods` (`can_view_finance`). Offline-verified against the live May-2026 consolidated BS export: Cash $32,228,212.53 (= legacy portal to the cent), Total assets $187.35M balances. 5 unit tests use a synthetic SpreadsheetML fixture — **real exports are never committed (sensitive financials)**. Full auto-delivery (NS scheduled report → email → Helix intake) needs a mailbox (deferred); until then finance self-service uploads the export (UI in PR6). Import is file-upload so it has no MCP tool; read/query MCP lands in PR4.
>
> **Software one-time add-ons (软件增值项)** (2026-06-27, migration 348 / 0 cron / +4 MCP→hint 356 / 0 new RBAC role). A software can carry one-time *consumption* charges beyond recurring seats — AI credit packs, vendor person-days, one-off fees. Two new tables in the asset domain, parallel to seats but explicitly NOT seats: `software_addon_items` (admin-configured definition next to `software_license_plans` — `unit_label` + `unit_price` + `currency` + a quantity envelope `min_quantity`/`step`/`max_quantity`, i.e. "configure a minimal unit, employee picks quantity") and `software_addon_charges` (the employee's request: `pending`→`approved`/`rejected`/`cancelled`, `amount = unit_price × quantity` frozen at request time, `department` snapshotted at approval, `period` pinned to the open billing month at approval). Self-service flows through the SAME request→approval rails as seats (`request_type=software_addon` via `ApprovalService` + Feishu card; no-Feishu fallback = in-app admin approve/reject at `/assets/addon-charges/{id}/approve|reject`) but is deliberately kept OUT of seat reconciliation / idle-reclaim / offboarding-revoke (a consumed add-on has no vendor seat to reconcile and nothing to revoke), and has NO per-(user,item) dedup (repeat purchases are legitimate). **Money lands on the single cost spine**: `billing_service.close_month` materialises each period's approved charges into `software_monthly_bills` as `line_type='addon'` rows (the table gained `line_type` (`seat`|`addon`, default `seat`) + nullable `charge_id`/`quantity`, and `allocation_id`/`plan_id` were relaxed to nullable; `software_monthly_bill_history` mirrors the columns for reopen snapshots). Because every cost rollup (`finance_spend_coordinator`, software-cost views, my-cost) already `SUM(amount)`s that table, add-on spend flows into the software total AND the requester's department/BU view with zero per-query changes. Approval callback (`ApprovalService._approve_software_addon_charge`) does ownership cross-check + leaver re-check (never bill a leaver's former department) + is idempotent. Service: [`addon_service.py`](../../server/app/domains/asset/addon_service.py). MCP +4 (`list_software_addon_items` / `request_software_addon` / `list_my_software_addon_charges` / `cancel_my_software_addon_charge`, 1:1 with `/assets/addon-*`). Frontend: catalog "申请增值项" modal, `/admin/assets/software-addons` (items config + charge review tabs), `/my/software-addons`. See DECISIONS.md (2026-06-27).
>
> **Finance-flux domain — schema foundation (PR1 of the Vercel-tool replacement)** (2026-06-26, migration 347 / 0 cron / 0 new MCP / 0 new RBAC role yet). New `finance_flux` domain ([`server/app/domains/finance_flux/`](../../server/app/domains/finance_flux/)) replicates the external "PingCAP Financial Flux" portal (Next.js/Vercel — month-over-month + year-over-year variance analysis over BS & P&L) into the finance module. PR1 lands the **complete 10-table schema** (no API/UI yet). Machine layer: `finance_flux_config` (singleton, finance-owned — thresholds default $10K / 20%, NS consolidation scope **seeded `PingCap Ltd. (Consolidated)` / `Primary Accounting Book` / USD**, `comparison_modes`, `closed_periods_only`, AI toggle + prompt), `finance_flux_line_defs` (statement-layout catalog, seeded 43 rows 1:1 with the portal's BS/PL layout; `row_type` line/subtotal/total + section + sort + owner + per-line threshold override), `finance_flux_account_map` (NS GL-account→line mapping, auto-seeded in PR2 from the NS account-level "FINANCE:LINE" classification / account-number prefix then finance-adjusted), `finance_flux_periods` (one row per accounting period + `sync_status`), `finance_flux_lines` (machine-pulled figures, overwritten each re-sync). Editorial layer (survives re-sync): `finance_flux_commentary` (commentary/recon/AI narrative), `finance_flux_kpi_defs` (Summary KPI catalog, seeded 5: total assets/revenue/gross profit/operating income/net income) + `finance_flux_kpi_override` (per-period manual override), `finance_flux_insight_cards` (highlights + insight cards), `finance_flux_followups` (Item/Owner/Action/Priority rows). Figures will be pulled from NetSuite via the existing `NetSuiteClient.suiteql` (PR2) — NOT uploaded — consolidated USD, closed periods, Primary Accounting Book. NS authoritative sources confirmed: consolidated BS report `cr=472`, P&L `cr=513`, BS detail `cr=489`, voucher list `cr=322`. Person columns are GUID-without-FK (TiDB 3780-safe); only lines/commentary→periods are real intra-domain FKs. Locked product calls: MoM+YoY, KPI auto-computed-with-override, closed periods only, consolidated (no entity split), AI via 百炼. Remaining PR2-7: NS pull engine, flux calc + API, AI + persistence + MCP, finance self-serve config + RBAC, frontend, old-site decommission.
>
> **CI guard — TiDB-parity kind (1d): raw `CREATE TABLE` + `FOREIGN KEY` in `op.execute`** (2026-09-06). Kinds (1)/(1b)/(1c) all inspect `sa.Column(...)` calls, so a table built from a SQL STRING is structurally invisible to them — and a SQL string is exactly where the author must get the child column's type AND collation right by hand with nothing checking it. Migration 621 shipped that shape: it read the parent's `column_type` out of `information_schema` and interpolated it, which looks careful and is still wrong, because `column_type` carries no collation and a column with no explicit `COLLATE` inherits the TABLE default. It applied cleanly on a scratch DB created in one statement (uniform collation) and failed with errno 3780 on a DB built through the real chain (`utf8mb4_unicode_ci`) — the same local-vs-prod asymmetry `id_type_for`'s docstring describes, and the third variant of one error after 126 and the 2026-07-29 break. The guard now rejects any `op.execute` whose SQL creates a table AND declares a FOREIGN KEY; a raw `CREATE TABLE` with no FK cannot hit 3780 and is left alone. **The fix is never "read the type more carefully" — it is `op.create_table` + `id_type_for(parent)` + `table_kw_for(parent)`, the one place this lesson is encoded.** Mutation-verified by restoring 621's original body (guard goes red naming the line) and reverting.

> 🔴 **A self-scoped decide endpoint needs a campaign-type ALLOWLIST, not just an ownership check** (2026-09-06, security review of PR5). `AccessCertificationItem.assigned_reviewer_person_id` does not mean "somebody other than the subject": `_snapshot_identity_consumer` sets **both** `person_id` and `assigned_reviewer_person_id` to the consumer's own owner, so for that campaign type the reviewer IS the reviewed party. PR5's first `decide_assigned_review` authorized purely on `assigned_reviewer_person_id == caller` with no role gate, which let a plain employee `keep` their own lapsed consumer — routing to `mark_identity_consumer_certified`, which clears `recert_lapsed_at` and flips `status` back to ACTIVE, undoing what `lapse_disable_identity_consumers` had enforced. `SELF_SERVICE_REVIEWABLE_CAMPAIGN_TYPES` is now the allowlist, `assigned_reviews_for` filters on the same set (a list wider than the decide path renders rows that 403 and leaks other campaigns' holder data), self-review is refused, and `_followup_reviewer_for` never assigns a self-managed holder their own non-response. **Rule for the next self-scoped reviewer surface: "named as reviewer" is not an authorization claim about WHOSE access it is.**

> **Self-attestation advice + the no-response consequence** (2026-09-06, migration 621, 访问对象闭环 PR5). [`auditor/self_attestation_ai.py`](../../server/app/domains/auditor/self_attestation_ai.py) mirrors `mover_review_ai`'s three safety properties (cannot invent a number, cannot invent a category, decides nothing) over a holder's own footprint, grounded in PR4's usage signals; advice lands in `auditor_self_attestation_advice` beside the holder's `decision`, never in it. **It adds one rule the mover packet does not need**: `unknown` / `unsupported` are published as themselves and `_validate` REJECTS a narrative that attaches an idleness word to the unmeasurable count — the system prompt's ban is a request, not a gate, and flattening PR4's four states into "5 unused seats" would undo the whole point. The check carries an inverse condition so it cannot reject a correct narrative. **The consequence half**: `complete_campaign` used to stamp un-answered items `no_response` and stop, leaving the access in place — so the flow evidenced a review while a non-responder kept everything. Those items now open a `no_response_followup` campaign whose reviewer is the holder's manager, riding the existing `decide_item` + revocation pipeline; policy setting `access_review.self_attestation.no_response_policy` defaults to `report_only`. **Assigning a reviewer is not delivery**: both pre-existing decide paths gate on `can_manage_security`, which a manager lacks, so PR5 added the self-scoped `/api/v1/me/access-review/assigned` pair, a notification template, and the 我的待办 tab — without them the consequence would have been a row with no door. Leavers' items are excluded (offboarding owns them), managerless items stay unassigned rather than misrouted, and an empty follow-up is deleted rather than left as a zero-item shell.

> **Multi-vendor seat usage signals** (2026-09-05, migration 612, 访问对象闭环 PR4). `asset_seat_usage_signals` persists `(person x provider x product) -> last active`, written by per-vendor collectors in [`integrations/usage_collectors.py`](../../server/app/integrations/usage_collectors.py) and read by `software_seat.idle_reclaim`. The reclaim state machine is UNCHANGED — only its evidence source moved, so adding a vendor is adding a collector, not forking the ladder. Four states carried all the way to the UI: `active` / `never` (vendor affirmatively says never signed in — reclaimable, but the idle clock runs from the SEAT GRANT) / `unknown` (vendor declined to answer) / `unsupported` (vendor cannot answer at all). **`unknown` and `unsupported` are never "idle"** — the same conflation produced 36 of 48 false findings on the identity-sync assurance first run and made daily users look two-years-idle pre-2026-06-11. Every row carries `collected_at` and `resolve_signal()` downgrades a stale row to `unknown`, so a broken collector STOPS reclaims instead of feeding them. The admin guardrail requires a POSITIVE determination: a provider that cannot say whether someone is an admin is treated as one. `NO_USAGE_SIGNAL` declares the six providers that cannot supply a signal with the reason each (checked against this repo's drivers), and `test_every_seat_provider_has_a_usage_decision` enumerates `providers_for_kind("software_provisioning")` from the driver registry so a new vendor cannot land without a decision — same shape as the T+1 residual registry gate (#3447).

> **Terminal-process blocker rows are FROZEN — every reader must scope to open processes** (2026-09-05). `cancel_process` flips the process to `PROC_CANCELLED` and deliberately leaves `OffboardingBlocker` rows untouched, while `reconcile_blockers` refuses terminal processes with 409 `PROCESS_TERMINAL`. Those `pending` rows are therefore unresolvable by construction: no cron writes them and no operator action clears them. **Cancellation is the only source** — `complete_process` refuses while blockers are pending. The established filter is `OffboardingProcess.status.notin_([PROC_COMPLETED, PROC_CANCELLED])`, used by `leaver_blocked/dashboard_service`, `health/service` and (as the three open statuses) `hr_lifecycle/digest_service`, so the totals line up across surfaces; `provisioning/service` instead guards its write with `proc.status == PROC_BLOCKED`. The two mobile admin surfaces that lacked it (`mobile/action_inbox._provider_leaver_blockers`, `mobile/aggregate.admin_offboarding_blocked`) were fixed the same day. **When you add a reader of `OffboardingBlocker`, the question is not "is it pending?" but "is its process still open?"** — the identical shape exists on the onboarding side, where `reconcile_blockers` returns 0 instead of raising.

> **Leaver T+1 safety net, registry-driven** (2026-09-05, migration 609, 访问对象闭环 PR3). [`hr_lifecycle/leaver_residuals.py`](../../server/app/hr_lifecycle/leaver_residuals.py) replaces `PostLeaverVerifyService._scan_person`'s hand-written enumeration (14 of the registry's 29 `RES_*` types, and four of them under private aliases `cloud_domain` / `cloud_dns_record` / `cloud_cdn_domain` / `cloud_load_balancer` that could never dedupe against anything else). Residual = every registry type x a predicate, defaulting to the mover packet's loader (`mover_holdings._LOADERS`) because both consumers ask the same question. Divergence is declared, never accidental: `_T1_OVERRIDES` (a leaver's `pending` AND MDM-managed seats are residual) and `NOT_RESIDUAL` (`software_account` — the Jira blocker is created off integration existence, so there is no per-person row to detect; the account-assurance sweep answers that system from Atlassian's own roster). `test_leaver_residual_registry_covers_every_type` fails the build when a new resource type arrives without a T+1 decision. Hardware's predicate is now shared as well (`assigned_to` OR `borrowed_by`).

> **Three offboarding blocker families the flow could not see** (2026-09-05, PR3). `RES_OBJECT_STORAGE` (`cmdb_object_storage.owner_person_id`) and `RES_CERTIFICATE` (`cmdb_certificates.owner_person_id`, **`owner_is_manual` only** — a probed / cloud-synced certificate resolves its owner live from the domain chain, so moving that FK would fight the resolver) are owner-FK transfers like LB / CDN. `RES_EMAIL_GROUP_MEMBER` is static Google-group MEMBERSHIP — people-bound, so it joins `IMMEDIATE_REVOKE_RESOURCE_TYPES` and is `members.delete`d at T+0. Membership and ownership are different questions: ownership (`RES_EMAIL_GROUP`) transfers, membership is removed, and the removal must wait for the ownership transfer (Google requires >=1 owner per group — the scanner skips groups the person owns and [`email_group/membership_scan.py`](../../server/app/domains/email_group/membership_scan.py) refuses with a 409 as a second gate). Google is the source of truth for membership (Helix keeps no member mirror), so an unreadable roster is recorded as a skip / `failed_types`, never as "no memberships".

> **Account assurance covers every identity-sync target** (2026-09-05, migration 609). Google and Atlassian each got a sweep after their own incident; every other system Helix pushes offboards to had exactly one reader — `identity_sync.leaver_assurance`, which raises so IT gets a failure notification and nothing reaches a page. `assurance_service.upsert_from_identity_sync_targets` reads the same nightly roster snapshots and writes them into the same per-person table (`system='isync:<slug>'`, `identity_sync_assurance_system()` returns `None` rather than truncating a slug that would not fit `identity_system` — truncation would merge two targets' verdicts). Verdicts keep the two lookalikes apart: a snapshot taken on or before the departure is `unknown` (36 of the first run's 48 findings were this artifact), and no snapshot at all is `unknown`, not `absent`. The page's enforce button routes `isync:*` through `IdentitySyncService.revoke_leavers_on_target(..., person_id=)` — the same Signal-B population the nightly reconcile drives to zero, scoped to one person so the operator closes exactly what they were shown.

> **DAX grant lifecycle — expiry / renewal / service-account owner confirmation** (2026-09-05, migration 606, 访问对象闭环 PR2). [`data_access/lifecycle.py`](../../server/app/domains/data_access/lifecycle.py) owns the `expires_at` / `owner_confirmation_*` half of `DataAccessBinding`: personal (`google_user`) grants are stamped at creation (`stamp_new_binding` in the Feishu approval callback; default 180 days via `platform_settings.data_access.grant_lifecycle.personal_ttl_days`), holders renew with a reason a bounded number of times (`self_renewals_before_reapproval`, default 2 → then a fresh Feishu request), admins extend / revoke; service-account grants never expire but their `DataAccessServiceAccountProfile.technical_owner_person_id` confirms every 182 days (confirm moves the clock, decline revokes, lapse past the 30-day grace revokes by policy). **Every revoke goes through `DataAccessService.revoke_data_access_binding` (new `system_context=True` for policy callers) → absent effect → apply-absent reconciler — BigQuery still has exactly one writer.** Transitions are appended to `data_access_lifecycle_events`. Four switches with two gates: `employee_push_enabled` / `owner_confirmation_push_enabled` default OFF (withheld reminders do not advance the ladder; the IT digest carries the rows), and `auto_revoke_on_expiry` / `auto_revoke_on_confirmation_lapse` refuse to turn on while the matching push is off (`LifecycleSettings.validate`). The predicate "this person's personal DAX grants" lives once in `select_personal_active_bindings` and is read by the offboarding scanner (`RES_DATA_ACCESS_GRANT`, in `IMMEDIATE_REVOKE_RESOURCE_TYPES`), `mover_holdings._LOADERS`, `post_leaver._scan_person` and the self-attestation snapshot (`AT_DATA_ACCESS_GRANT`, self-removable → release path). Endpoints in [`data_access/lifecycle_router.py`](../../server/app/domains/data_access/lifecycle_router.py): admin `GET /data-access/admin/grants` (lifecycle filter) / `…/summary` / `GET|PUT …/lifecycle-settings` / `POST …/{id}/extend|revoke` / `GET …/{id}/events`; employee `GET /data-access/me/lifecycle-policy`, `POST /data-access/me/grants/{id}/renew|release`, `GET /data-access/me/owner-confirmations`, `POST …/{id}/respond`; `GET /data-access/me/grants` now carries the lifecycle fields. UI: `/admin/data-warehouse/grants` (KPI strip + inventory + policy drawer + timeline), 我的数据集权限 (validity column, renew / release / confirm, deep links `?renew= ?release= ?confirm=`), 我的待办 「数据集服务账号确认」. MCP: `admin_list_data_access_grants`, `get_data_access_grant_lifecycle_summary`, `get/update_data_access_grant_lifecycle_settings`, `extend_data_access_grant`, `revoke_data_access_grant`, `renew_my_data_access_grant`, `release_my_data_access_grant`, `list_my_data_access_owner_confirmations`, `respond_data_access_owner_confirmation`.

> **Permission catalog in-app approval + validity** (2026-09-05, migration 605). [`permission/lifecycle.py`](../../server/app/domains/permission/lifecycle.py) + `PermissionService.approve_request / reject_request / list_pending_approvals / can_decide`. A `requires_approval=True` request lands on the NEW status `pending_approval` (distinct from `pending` = approved, vendor grant in flight — the provisioning hook only ever sees `pending`), creates no task, and notifies the software's `owner_person_id` / `it_contact_person_id` (global admins when the software names nobody — `resolve_approvers` returns `routed_to_it`). Approve → `pending` + GRANT task + optional `expires_at` (`valid_days` or `permission.approval.default_validity_days`); reject / requester cancel / admin revoke-on-pending = rejection; `admin_grant` on a pending row converts it. Daily `permission.lifecycle_sweep` reminds approvers (3/7 days), escalates to IT (7 days), and issues REVOKE tasks for expired grants with `context_data.terminal_status='expired'`, which the success hook lands on `PERM_EXPIRED`. Holder expiry notice is gated (`notify_holder_on_expiry`, default OFF). Endpoints `GET /permissions/approvals?scope=mine|all`, `POST /permissions/approvals/{id}/approve|reject`, `GET|PUT /permissions/approval-settings`; MCP `list_my_permission_approvals`, `approve_permission_request`, `reject_permission_request`. UI: 我的待办 「软件权限审批」 tab, 权限管理台 grants tab (approve / reject inline, validity column, 审批策略 modal), software catalog request modal label 「需负责人审批」.

> **Data access BigQuery scheduled apply reconciler** (2026-06-25, migration 341 / +1 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`server/app/scheduled_jobs/data_access_handlers.py`](../../server/app/scheduled_jobs/data_access_handlers.py) registers `data_access.bigquery_apply_reconcile`, imported by the worker at boot and seeded as a 60-second interval job by migration 341. The job enumerates enabled `data_access/bigquery` integrations, skips integrations without `apply_mode_enabled`, and drains pending/retryable DAX compiled effects through the existing audited `apply_present_reconcile_compiled_effects` and `apply_absent_reconcile_compiled_effects` paths. Provider write errors or conflicts raise the scheduled run as failed so normal scheduled-job alerting sees apply breakage; successful runs update `data_access_effect_runs` plus request/binding/effect reconcile rollups. This is the only automatic BigQuery mutation path for DAX; request submission and Feishu callbacks still only create semantic state.
>
> **Data access requester-owned grant ledger** (2026-08-19, 0 migration / 0 cron / +1 internal API / +1 MCP / 0 RBAC role). [`my_grants.py`](../../server/app/domains/data_access/my_grants.py) is the single read path for grants created by one employee's own DAX requests. It joins `data_access_requests.requester_person_id` to `data_access_bindings.source_request_id`, then projects the exact principal snapshot (`google_user`, `google_group`, or `gcp_service_account`), dataset, binding/effect lifecycle, provider member/role, source request, and catalog description. `GET /api/v1/data-access/me/grants` is JWT self-scoped and paginated (search plus principal-type filter); MCP `list_my_data_access_grants` calls the same query; [`DataAccessMyGrantsPage`](../../web/src/pages/dataAccess/DataAccessMyGrantsPage.tsx) exposes it at `/my/data-access/grants`. Only approved requests that formed bindings appear. Live Google Group membership inherited from a request submitted by someone else is deliberately excluded because it is effective access, not requester-owned grant lifecycle; the existing Group access page remains the Group lifecycle view. This read model calls no BigQuery API and never mutates requests, bindings, effects, IAM, or provider state.
>
> **Intern attendance — HRBP-routed notices + honest punch times + legacy-snapshot calendar** (2026-06-26; 0 migration / 0 cron / 0 new MCP). ① Completion/return notices go to the intern's **HRBP** (`lifecycle._notify_hr` → `resolve_hrbp_for_person`), never the cycle generator / admin pool; no HRBP → skip the push (status still visible in the console), never fall back to admin. ② Actual punch times (实到) are NOT in `user_tasks/query` (only 应到 shift schedule) — verified empty in prod; `AttendanceCalendar` shows 实到 only when present (else the category label / 应到), never a misleading "—". Real punch times would need Feishu 打卡流水 `user_flows/query`, deferred until interns are confirmed to actually punch. ③ `AttendanceCalendar.resolveCategory` derives a category from legacy fields when a pre-PR-E snapshot lacks `category`, so old reports render correctly until re-generated.

> **Data access member-authored group requests** (2026-06-26, DAX authoring fix; 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). DAX request authoring accepts either a synced Google Group owner, a live Google Group member, or a global admin. Requestable-principal reads expose `caller_is_member` so the UI can show member-visible groups, and submit-time authorization rechecks live membership for the selected principal before creating the semantic request. When a non-owner member submits for a group, Helix creates the normal Feishu approval instance and then CCs synced group owner(s) via Feishu's instance CC API, resolving owners in the same Feishu ID namespace as the requester and storing the submitted CC payload/result in approval source metadata. Approval routing, callback handling, and BigQuery apply semantics are unchanged: request submission and callbacks still do not mutate provider IAM.
>
> **Intern attendance — classification rigor + leave from approvals** (2026-06-26, PR-E; 0 migration / 0 cron / 0 new MCP). Each day now carries ONE authoritative `category` from `facts.py`: `normal / late / early / lack_in (缺上班) / lack_out (缺下班) / absent (全天旷工) / leave / trip / out / rest`. Leave/trip/out are sourced from Feishu **假勤审批** (`FeishuAttendanceClient.query_user_approvals` → `user_approvals/query`, same `attendance:task:readonly` scope, 50-user × ≤30-day chunks) overlaid by date (`AttendanceService._spans_to_overlay`); the打卡-result `supplement` is only a degrade fallback, and an approvals-fetch failure never blocks generation. 法定节假日 is NOT distinguished from weekend rest (both `rest`/休 — Feishu exposes no reliable holiday flag here; never inferred from weekday — user-confirmed correct). `AttendanceCalendar` renders by `day.category` (no client re-derivation) with a dynamic legend; HR + self-service detail add 旷工/请假 metric cards. The optional 「我的考勤」 menu is gated by `/auth/me.is_attendance_subject` (`AuthService.is_attendance_subject` — raw EXISTS on `attendance_reports` by person OR leader, no auth→attendance import) so it stays hidden from staff who are neither interns nor intern-leaders. Snapshots are immutable — re-generate a cycle to apply the new classification.
>
> **Intern attendance monthly-report — Feishu-sourced roster + platform-grade calendar UI** (2026-06-25, PR-D; migration 342 / 0 cron / 0 new MCP). Roster source corrected: interns are the **live members of the Feishu 通讯录 department** (`FeishuAttendanceClient.list_intern_members` → `/contact/v3/departments` by name → `find_by_department`, matched to Helix persons by email), NOT Helix's HRIS org tree — production showed the `PingCAP Intern` grouping is Feishu-only (Helix org is 100% SuccessFactors), so the `attendance.intern_department` setting is now a *Feishu* department name. Feishu members with no active Helix person are surfaced as 「待补」 (persisted on `attendance_cycles.pending_not_in_helix_json`, migration 342), never silently dropped. Snapshot segments are enriched with actual (`records.check_time`) + scheduled (`check_in_shift_time`) punch times. UI is now platform-grade: reusable [`web/src/components/AttendanceCalendar`](../../web/src/components/AttendanceCalendar/AttendanceCalendar.tsx) (continuous 21→20 month grid, colour-coded normal/late/early/缺卡/补卡中/请假/休, click-day 实到vs应到 detail, dark-mode via AntD token) powers the HR console detail drawer + a new **desktop self-service page `/me/attendance`** (intern/leader confirm/return/undo). The HR menu entry moved into the 「HR 系统」 group in both admin + HR views (was mis-filed under 身份源数据与认证). Supersedes the PR-A "dept membership" roster note below.
>
> **Intern attendance monthly-report — backend foundation** (2026-06-25, PR-A of 3; migration 339 / 0 cron / 0 new MCP yet / 0 new RBAC role — HR-gated). New domain [`server/app/domains/attendance`](../../server/app/domains/attendance) pulls per-day 打卡结果 from Feishu 考勤 (read-only, scope `attendance:task:readonly`, reusing the shared `chat_approval` Feishu app credentials + `FeishuApprovalClient` token cache via `feishu_attendance.FeishuAttendanceClient`) and builds one **versioned report per intern per cycle**. **Interns = department membership** — the configurable `attendance.intern_department` platform setting (default `PingCAP Intern`, resolved through `core/org_scope` subtree), so there is **no `employment_type` schema column**; people moving in/out of the dept change the roster automatically. **Cycle = 21→20** (configurable `attendance.cycle_start_day`), computed on local calendar dates (`attendance/cycle.py`). Tables (migration 339): `attendance_cycles`, `attendance_reports` (immutable versioned `snapshot_json` + denormalised summary cols + `data_quality_json`), `attendance_report_events` (append-only state-transition audit). Person id columns carry **no FK to identity_persons** (TiDB-FK-safe, migration-324 convention); only intra-feature FKs are real. `POST /api/v1/attendance/cycles/generate` is idempotent create-or-refresh (per-person SAVEPOINT isolation; Feishu pull happens *before* any DB write so a missing scope aborts clean with a 503), plus `GET /cycles|/reports|/reports/{id}` — all gated to `hr`/`global_admin`. Fact classification (`attendance/facts.py`) maps Feishu `Lack`/`Late`/`Early` + `*_supplement` (CardReplacement=已补卡, CardReplacementApplication=补卡申请中) into per-day issues + a rolled-up summary. The two-stage confirmation lifecycle (intern→leader, return/resend/intern-undo) + Feishu one-click cards + H5 + reminder cron land in **PR-B**; the HR console + intern/leader H5 + MCP tools in **PR-C**.
>
> **Role-based landing replaces the retired `/dashboard`** (2026-06-25, 0 migration / 0 cron / +1 self read endpoint / 0 new MCP). **登录落地是新原语**——[`web/src/lib/roleLanding.ts`](../../web/src/lib/roleLanding.ts) 把「默认视角」与「落地路径」统一成一份契约:`defaultViewRoleForUser(roles)`(主角色优先级 global_admin>management>asset_admin>auditor>hr>finance>cloud_admin>employee,镜像 chip 顺序)+ `homePathForViewRole(role)`(员工→`/me`、global_admin/management→`/admin/insights`、finance→`/admin/finance/overview`、auditor→`/compliance`、asset_admin→`/admin/assets`、cloud_admin→`/admin/cloud/overview`、hr→`/admin/users`)。`RootLanding`(`/`)、`postLoginTarget`(非移动端改返 `/`)、角色 chip 切换、`stores/auth.deriveDefaultViewRole` 全部走它。**通用 `/dashboard` 已退役**:`DashboardPage.tsx`/`.module.css` 删除、路由改 `Navigate→/`、导航无「仪表盘」项;**但保留** `web/src/pages/dashboard/reportsShared.tsx`(35 处图表原语依赖,勿删整个 dashboard 目录)与后端 `GET /api/v1/dashboard/summary`(移动端 `MMyBillsPage` 仍用)。默认视角翻转的安全推理见 DECISIONS 2026-06-25(推翻 2026-05-07)。**新端点** `GET /api/v1/me/team-summary`([`me_universe/team_summary.py`](../../server/app/domains/me_universe/team_summary.py)):自助、≤3 索引查询,返回 `{manager, peer_count, direct_report_count}` 供 IT 宇宙欢迎横幅团队行;只读非自助变更流故无 1:1 MCP。**IT 宇宙员工页**([`UserItUniverseTab.tsx`](../../web/src/pages/admin/UserItUniverseTab.tsx) self 视图):欢迎横幅(头像+你好+部门·团队+本月软件成本)、六张指标卡 `onClick` 跳 tab、IT 资源地图 `Tree onSelect` 按 key 前缀跳 tab、资产按状态出操作(`borrowed`→借用延期[复用 `BorrowExtensionModal`]+申请归还;`claimed/assigned`→申请归还,归还导向 `/my/requests`)。**2026-07-17 小修**: self 视图的「未完成任务」卡额外纳入资产盘点待确认数；有盘点任务时卡片转红并优先跳到 `/me/inventory` / `/me/inventory/:cycleId`，让员工从首页直接进入盘点确认。
>
> **Data access catalog business descriptions** (2026-08-19, requestability refreshed 2026-08-20; 0 migration / 0 cron / 0 RBAC / 0 MCP). `data_access_resources.notes` is the durable business-description field for a dataset. Catalog `description` input projects into `notes`; the requestable-resource API exposes that value as nullable `description`, search matches `notes`, the request page renders it for available and selected datasets, and the Feishu approval payload reads the same column. The original business scope contained 23 requestable datasets: 22 dbt-derived identities plus user-approved `gcp-digital-bi.metadb_new`, which is assigned to active owner `xukai@pingcap.com` and was confirmed to exist in `us-west2`. The later source-discoverability decision unions that set with all 32 referenced dbt source datasets; after seven overlaps and the Provider-missing `usage_us_west2` suspension, this produces 47 active/requestable locators. Generated source rows use a concise source-dataset description and default owner Xiaopeng Zhu, while pre-existing source owners/descriptions stay authoritative. Under explicit user authorization, a guarded direct Production insert previously added exactly the MetaDB resource to `dax-bigquery-prod`, increasing the active resolved requestable count from 19 to 20 with bindings/effects/effect-runs unchanged. That direct insert made no BigQuery call; the current 47-row requestable repo projection and Terraform scope application remain separate Production operations.
>
> **Requestable dataset schema browser** (2026-08-19, 0 migration / 0 cron / 0 RBAC / +2 read APIs / 0 MCP). The request page makes each active/requestable dataset name an explicit schema-inspection entry. A lazy drawer calls `GET /api/v1/data-access/requestable-resources/{resource_id}/tables`, then fetches only the selected table through `GET .../tables/{table_id}/schema`; nested BigQuery RECORD fields render as full paths with type, mode, and provider-authored field description. The backend re-resolves the resource id against the requestable catalog before every provider call and uses the existing dedicated `data_access/bigquery` driver, never frontend credentials. `DataAccessDriver` adds exact-locator `list_dataset_tables` / `get_dataset_table_schema`; the BigQuery implementation uses only `tables.list/get`, never table data or query jobs. `HelixDaxDatasetIamReconciler` therefore gains those two metadata permissions at the same dataset-level Terraform bindings while continuing to exclude `tables.getData`, `bigquery.jobs.create`, dataset listing, project IAM, and broad roles. This explicitly supersedes DAX-029's historical statement that all table permissions were out of scope; table-data access remains out of scope.
>
> **Provider-independent lineage browse fallback** (2026-08-20, 0 migration / 0 new route / 0 MCP). If the primary `data_access/bigquery` integration or its metadata call is unavailable, the existing paginated table-list route returns tracked model and source relations from the deployed lineage catalog and marks every row `metadata_source=lineage_catalog`, `selectable=false`, and `schema_available=false`. The single deployed dbt catalog is registered for both its primary `gcp-digital-bi` project and the explicitly reviewed external source project `gcp-digital-bi-dev`, so cross-project source datasets use the same fallback instead of leaking the integration error. The drawer labels this mode, disables request selection/schema/owner mutation, and opens Lineage directly. A healthy provider path remains unchanged. The fallback is read-only availability for published lineage; it is not current BigQuery existence/schema evidence and never feeds request submission or IAM effects.

> **Data access production wiring** (2026-06-24, inventory refreshed 2026-08-25; DAX Feishu definition deploy + dataset seed + Terraform BigQuery scope + apply-gate operations; 0 migration / 0 cron / 0 RBAC role / 0 MCP-tool-count). `.github/workflows/deploy-feishu-dax-approval.yml` is the manual production workflow for DAX Feishu approval definitions: dry-run the repo artifact payload, deploy through Feishu OpenAPI using existing SSM-backed Feishu app credentials, store `FEISHU_APPROVAL_DATA_ACCESS_GROUP_GRANT_CODE`, and optionally run `deploy/scripts/upsert-dax-approval-flow.sh` on EC2. The backend image carries `scripts/upsert_data_access_approval_flow.py` for dry-run-safe `integration_approval_flows` upserts, `scripts/seed_data_access_resources.py` for JSON dataset inventories that resolve `owner_email` to `identity_persons.id`, and `scripts/upsert_data_access_bigquery_integration.py` for dry-run-safe `data_access/bigquery` credential projection. [`deploy/data-access/datasets/gcp-digital-bi-dbt.json`](../../deploy/data-access/datasets/gcp-digital-bi-dbt.json) is the repo-managed SSOT seed for 67 catalog identities. Its prior 23 business-requestable locators union with all 32 referenced source datasets; after seven overlaps and the Provider-missing `usage_us_west2` suspension, 47 locators are active/requestable. Twenty-three missing sources were added with `dbt_generated=false` and default owner `xiaopeng.zhu@pingcap.com`, while existing source owners remain authoritative. Unused source declarations, parentless models, seeds, snapshots, and intermediate datasets are excluded. `metadb_new` resolves to `xukai@pingcap.com`; `alibaba_cloud` is retained as active owner metadata with `requestable=false`. `.github/workflows/seed-dax-dataset-catalog.yml` projects that seed into Helix catalog state. [`infra/gcp-dax`](../../infra/gcp-dax) reads the same seed, creates the DAX BigQuery service account/custom role/key, and grants its dataset-level controller role on every active seed row whose project is explicitly allowed (`gcp-digital-bi` or `gcp-digital-bi-dev`). This active scope is deliberately wider than new-request admission so historical grants remain observable and revocable after `requestable=false`; it does not make those rows requestable. `.github/workflows/deploy-dax-bigquery-integration.yml` reads the Terraform-generated service-account JSON from SSM and writes the encrypted `service_account_json` plus public `apply_mode_enabled`. Manual workflows dry-run by default and block committed writes unless run from `main`. Reseeding is authoritative for the target integration, so omitted catalog rows are suspended by the feed semantics; Terraform apply is authoritative for the service account's provider mutation scope. Source merge alone performs neither Production mutation.
>
> **Data access Feishu approval definition artifacts** (2026-06-12, DAX approval operations artifact; 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`deploy/feishu/approval-definitions/data_access_group_grant`](../../deploy/feishu/approval-definitions/data_access_group_grant) is the reviewed source for the manually deployed Feishu approval definition used by DAX group grants. The artifact contains `definition.json`, the Helix `integration_approval_flows.payload_schema` mapping, and a sample form using custom `dax_*` widget ids. The verified v4 definition API shape uses `@i18n@...` keys for approval names/descriptions/widget labels/node names plus matching `i18n_resources`; Feishu rejects update payloads without those resources. Feishu admins deploy the definition and copy the published approval code into Helix; runtime Helix only submits instances against that code and handles callbacks. It does not author Feishu approval definitions, accept direct Feishu-originated DAX requests, or mutate BigQuery from request/callback paths.
>

> **Data access DAX-050 cutover validation report** (2026-06-11, M5 catalog-federation cutover gate for [#861](https://github.com/pingcap-inc/Helix/issues/861); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`cutover_report.py`](../../server/app/domains/data_access/cutover_report.py) and global-admin `GET /api/v1/data-access/admin/cutover-validation` now classify projected DAX resources for cutover readiness without reading raw catalog/bootstrap input. The report lists bootstrap remaining/requestable/historical, unmatched bootstrap, catalog-fed active, suspended, duplicate locator, duplicate catalog id, locator-drift-with-history, owner-mismatch, unresolved/fallback owner, invalid, and unsafe unmatched-bootstrap revoke evidence; it returns full-scope category counts, blocking counts, row evidence, semantic history counts, and `cutover_ready` only when blockers are zero. Historical bootstrap rows remain readable as provenance when non-requestable/suspended; active requestable bootstrap rows block readiness. The report reads only `data_access_resources`, request items, bindings, compiled effects, and effect runs, and proves `provider_mutation_attempted=false`; it adds no Feishu approval, revoke intent, absent effect, provider call, request/callback direct BigQuery mutation, manual catalog UI, raw-catalog authority, table/single-user/project IAM, `bigquery.jobUser`, dataset creation, or Google Group membership reconciliation.

> **Data access DAX-049 deterministic bootstrap replacement** (2026-06-11, M5 catalog-federation cutover gate for [#860](https://github.com/pingcap-inc/Helix/issues/860); migration 338 adds `data_access_resources.catalog_resource_id` / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). Catalog and bootstrap dataset inputs may now carry a stable `catalog_id`, projected into `data_access_resources.catalog_resource_id`; catalog/bootstrap parsers reject duplicate catalog ids and the catalog feed matches existing rows by catalog id before falling back to the provider locator. Owner-resolved bootstrap matches are replaced in place as catalog rows while preserving the resource id plus existing requests, request items, bindings, compiled effects, audit, and effect-run history. If a catalog-id match would retarget a bootstrap- or catalog-owned row with preserved history to a different provider locator, the feed keeps the old locator/effect targets, suspends new authoring, and audits `bootstrap_locator_drift_suspended` or `catalog_locator_drift_suspended` plus `locator_drift_count` for admin resolution. Owner mismatches fail closed: the resource is suspended/non-requestable, runtime owner authority is cleared, the row stays bootstrap-owned for admin resolution, and feed audit records both owners plus the match strategy with `provider_mutation_attempted=false`. Late duplicate active bootstrap inserts remain blocked by the active-locator uniqueness key. This adds no Feishu approval, revoke intent, absent effect, provider call, request/callback direct BigQuery mutation, HTTP/MCP/UI surface, broad scan, table/single-user/project IAM, `bigquery.jobUser`, dataset creation, or Google Group membership reconciliation.

> **Data access DAX-048 confirmed resource removal** (2026-06-11, M5 catalog-federation removal gate for [#859](https://github.com/pingcap-inc/Helix/issues/859); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). Confirmed deletion is now the explicit revoke-producing resource lifecycle path. `catalog_feed.project_catalog_dataset_feed(...)` accepts a catalog-projected BigQuery dataset with `status='deleted'`, coerces it non-requestable, and delegates the semantic transition to `DataAccessService.confirm_data_access_resource_removed(...)`; trusted admin/system callers use that same service method for admin-confirmed removals. The service locks the resource, marks it `status='deleted'` / `requestable=false`, moves active bindings to `revoked_pending`, compiles absent effects with `managed_reason='revoke_request'` from the original present-effect target locator/member snapshots, and audits `data_access.resource.removal_confirmed` plus compile output with `provider_mutation_attempted=false`. Replays are idempotent and report already-pending/revoked counts without adding duplicate effects. This adds no provider call, Feishu approval, request/callback direct mutation, HTTP/MCP/UI surface, broad scan, table/single-user/project IAM, `bigquery.jobUser`, dataset creation, or Google Group membership reconciliation.

> **Data access DAX-047 catalog-missing suspension** (2026-06-11, M5 catalog-federation cutover gate for [#858](https://github.com/pingcap-inc/Helix/issues/858); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). The catalog feed projection is now authoritative for active `owner_source='catalog'` BigQuery dataset rows under the integration: if a previously catalog-fed row is omitted from the feed, `catalog_feed.project_catalog_dataset_feed(...)` idempotently moves that resource to `status='suspended'`, returns/audits `catalog_missing_suspended`, and increments `suspended_count`. This lifecycle change preserves the resource id, requestability intent, owner/source/version/etag evidence, existing bindings, and compiled effects. New request authoring fails through the existing inactive-resource validator and admin-visible DAX-043 notice path; no absent effect, revoke intent, Feishu approval, provider call, BigQuery scan, request-handler mutation, or Google Group membership action is created. Bootstrap rows remain transition input and are not suspended by catalog omission until explicit cutover replacement/report handling.

> **Data access DAX-046 catalog version/etag callback staleness** (2026-06-11, M5 catalog-federation callback gate for [#857](https://github.com/pingcap-inc/Helix/issues/857); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). Approved data-access callbacks now treat `data_access_resources.source_version` or `source_etag` drift from the pending `data_access_request_items` snapshot as the explicit `resource_catalog_version_stale` refusal reason. The request moves to `stale_requires_resubmit`, request items are marked stale with the same reason, the refusal is audited with the changed field/current/snapshot values, and no binding or compiled effect is created. Owner/locator drift remains `resource_context_changed`. The callback path still revalidates only the projected `data_access_resources` row, never reads raw catalog feed input, never calls BigQuery, and never mutates provider IAM; provider mutation remains reconciler-owned.

> **Data access DAX-045 catalog feed projection** (2026-06-11, M5 catalog-federation ingestion for [#856](https://github.com/pingcap-inc/Helix/issues/856); migration 337 adds `data_access_resources.labels` / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`catalog_feed.py`](../../server/app/domains/data_access/catalog_feed.py) is now the durable feed-ingestion boundary: it validates catalog-shaped V1 entries for BigQuery datasets only, rejects table/project/freeform locators and duplicate feed locators, verifies resolved `owner_person_id` values against Helix identity, and upserts by `integration_id + provider + resource_type + project_id + dataset_id`. Projection writes `owner_source='catalog'`, `owner_person_id`, `owner_resolution_status`, `display_name`, `labels`, `requestable`, `status`, `source_version`, `source_etag`, and `source_payload_digest` into `data_access_resources`; exact locator matches replace bootstrap provenance while preserving resource id, requests, bindings, effects, audit, and run history. Each run audits `data_access.catalog_feed.project` with created/updated/unchanged outcomes and `provider_mutation_attempted=false`. Request handlers, Feishu callbacks, revokes, and compilers do not import the raw feed; they continue to read the projected row only. No provider call, BigQuery scan, scheduled runner, HTTP/MCP/UI manual catalog surface, table/single-user/project IAM, `bigquery.jobUser`, dataset creation, Feishu template authoring, or Google Group membership reconciler shipped.

> **Data access DAX-044 frontend i18n closeout guard** (2026-06-11, M4 product/operations localization gate for [#855](https://github.com/pingcap-inc/Helix/issues/855); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`check-data-access-i18n.mjs`](../../web/scripts/check-data-access-i18n.mjs) now runs as part of `npm run lint` and makes the data-access UI copy contract executable. The guard scans every `web/src/pages/dataAccess/*.tsx` page for hardcoded visible JSX/Ant Design/message strings, verifies static `t(...)` keys across `en-US`, `zh-CN`, and `ja-JP`, checks dynamic `dataAccess.*` namespaces, and enforces flat `dataAccess.*` key parity across those locale files. This is a localization guard only: it adds no runtime data authority, user workflow, API, schema, provider call, approval path, RBAC role, MCP tool, scheduled apply runner, or provider mutation path.

> **Data access DAX-043 visible notices for suspended resources and deleted-group revocations** (2026-06-11, M4 product/operations notice surface for [#854](https://github.com/pingcap-inc/Helix/issues/854); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`router.py`](../../server/app/domains/data_access/router.py) now projects backend-owned notice fields instead of making the UI infer policy from raw state. `GET /api/v1/data-access/requestable-resources` returns `status`, `requestable`, `availability`, and `notice_code`; global admins can see suspended catalog-missing dataset rows with `availability="catalog_missing_suspended"` / `notice_code="catalog_missing_suspension"`, while employees still see only active/requestable resources. `GET /api/v1/data-access/principals/{principal_email_group_id}/access` now returns `resource_status`, `resource_requestable`, `revoke_reason`, and `notice_code`; `group_deleted_without_bridge` revocations are visible to global admins even for deleted Google Group rows that still have DAX bindings, while employees keep the deleted-group no-leak 404. [`DataAccessRequestPage`](../../web/src/pages/dataAccess/DataAccessRequestPage.tsx) renders suspended rows as visible but disabled with an operational notice, and [`DataAccessGroupAccessPage`](../../web/src/pages/dataAccess/DataAccessGroupAccessPage.tsx) renders catalog-missing suspension and deleted-group revocation notices on access rows. [`test_router.py`](../../server/tests/test_data_access/test_router.py) proves employee/admin visibility, submit blocking before Feishu/request/binding/effect state, suspended access notices, deleted-group admin-only notices, and the existing no-direct-BigQuery mutation guard. No provider call, Feishu callback mutation, new approval path, MCP tool, scheduled runner, broad provider mutation path, table/single-user/project IAM, `bigquery.jobUser`, dataset creation, manual catalog UI, Feishu template authoring, or Google Group membership reconciler shipped.

> **Data access DAX-042 reconciler run-history admin detail** (2026-06-11, M4 operations surface for [#853](https://github.com/pingcap-inc/Helix/issues/853); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`router.py`](../../server/app/domains/data_access/router.py) now exposes global-admin `GET /api/v1/data-access/admin/reconcile-runs` as a read-only backend-owned projection over `data_access_effect_runs`. It filters to V1 BigQuery dataset `read` rows with canonical Google Group provider members (`group:`), `roles/bigquery.dataViewer`, and `read:v1`, then joins current semantic effect, binding, resource, entitlement, and Google Group context. The response exposes run id, effect id, binding id, request/resource/integration ids, dataset locator/display name, group email/google id, mode, desired/effect status, provider outcome/error/etag, trace id, timestamps, and current effect/binding status. [`DataAccessReconcileRunsPage`](../../web/src/pages/dataAccess/DataAccessReconcileRunsPage.tsx) adds `/admin/data-access/reconcile-runs` under Cloud Assets with summary counters, search, mode/status filters, provider outcome/error/trace evidence, and expandable row detail. [`test_router.py`](../../server/tests/test_data_access/test_router.py) proves admin-only access, employee denial/no leakage, trace search, and that non-`group:` provider members remain hidden; the existing static guard still proves request handlers and Feishu callbacks do not call BigQuery directly. This surface reads Helix run history only and adds no provider API call, retry path, approval path, MCP tool, scheduled runner, broad provider mutation path, table/single-user/project IAM, `bigquery.jobUser`, dataset creation, manual catalog UI, Feishu template authoring, or Google Group membership reconciler.

> **Data access DAX-041 admin failed-apply queue and retry** (2026-06-11, M4 operations surface for [#852](https://github.com/pingcap-inc/Helix/issues/852); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`router.py`](../../server/app/domains/data_access/router.py) now exposes global-admin `GET /api/v1/data-access/admin/failed-apply` for failed/retryable compiled effects and `POST /api/v1/data-access/admin/failed-apply/{effect_id}/retry` for one-effect retry. The queue joins Helix semantic bindings, resources, entitlements, Google Group principals, compiled effects, and latest effect-run evidence; it does not read provider state or expose single-user principals. Retry records an `admin_retry` effect-run row, audits `data_access.failed_apply.retry`, moves failed effects back to `retryable`, then delegates to the existing apply-mode-gated apply-present/apply-absent reconciler path. [`DataAccessFailedApplyQueuePage`](../../web/src/pages/dataAccess/DataAccessFailedApplyQueuePage.tsx) adds `/admin/data-access/failed-apply` as a standalone global-admin page under Cloud Assets with summary counters, search, provider outcome/error evidence, latest run trace, and per-row retry. [`test_router.py`](../../server/tests/test_data_access/test_router.py) proves admin-only queue access, failed/retryable filtering, no employee leakage, no `user:` provider member exposure, and retry without reapproval by keeping `approval_requests` count unchanged while a fake BigQuery driver marks the selected effect/binding/request applied. Request handlers still do not call BigQuery driver/API primitives directly; provider mutation remains owned by the guarded data-access reconciler. No MCP request endpoint, scheduled runner, broad provider mutation path, table/single-user/project IAM, `bigquery.jobUser`, dataset creation, manual catalog UI, Feishu template authoring, or Google Group membership reconciler shipped.

> **Data access DAX-040 Google Group access view** (2026-06-11, navigation retired 2026-08-24; M4 group-access compatibility surface for [#851](https://github.com/pingcap-inc/Helix/issues/851); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`router.py`](../../server/app/domains/data_access/router.py) exposes `GET /api/v1/data-access/principals/{principal_email_group_id}/access` as a read-only backend-owned projection for global admins and current owners of an active Google Group. It joins Helix semantic bindings, resources, entitlements, compiled effects, and latest effect-run evidence into active, pending-apply, failed, revoke-pending, and revoked-history dataset access rows, always scoped to `principal_type="google_group"`, canonical `read`, and compiled `group:<email>` + `roles/bigquery.dataViewer` output. Unowned groups return 404 without group/dataset leakage, and the endpoint does not scan provider state or expose single-user access. [`DataAccessGroupAccessPage`](../../web/src/pages/dataAccess/DataAccessGroupAccessPage.tsx) remains routed at `/my/data-access/group-access` for legacy deep links, with group selection, dataset search, state filters, active/revoked/history counts, and source-request links; after Group new-request retirement, the employee data-warehouse sidebar and its derived global-search palette no longer expose a standalone Group-access tab. [`test_router.py`](../../server/tests/test_data_access/test_router.py) proves owned/admin visibility, active + revoke-pending + revoked-history rows, no single-user principal exposure, and unowned-group no-leak behavior. The surface still does not call BigQuery, create bindings/effects before approval, add MCP tools or scheduled jobs, expose table/single-user/project IAM/`bigquery.jobUser`, create datasets, author Feishu templates, reconcile Google Group membership, or mutate providers outside the guarded data-access reconciler/driver path.

> **Data access DAX-039 request detail surface** (2026-06-11, M4 detail surface for [#850](https://github.com/pingcap-inc/Helix/issues/850); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`router.py`](../../server/app/domains/data_access/router.py) now exposes `GET /api/v1/data-access/requests/{request_id}` as a read-only backend-owned detail model for global admins, the original requester, current owners of the requested Google Group, or resolved owners of requested data-access resources. It returns Feishu approval status, per-item display state, staleness reason, compiled preview, binding/effect apply status, and recent effect-run history; resource-owner-only readers receive only their owned items/effects/runs and no aggregate request error, request trace, or freeform business reason, while unauthorized employees receive 404 without dataset/group leakage. [`DataAccessRequestDetailPage`](../../web/src/pages/dataAccess/DataAccessRequestDetailPage.tsx) adds `/my/data-access/requests/:requestId`, and successful submissions from the authoring page navigate to the detail page. [`test_router.py`](../../server/tests/test_data_access/test_router.py) proves the detail payload, no-leak authorization boundary, and mixed-owner scoped projection. The detail surface still does not call BigQuery, create bindings/effects before approval, add MCP tools or scheduled jobs, expose table/single-user/project IAM/`bigquery.jobUser`, create datasets, author Feishu templates, reconcile Google Group membership, or mutate providers outside the guarded data-access reconciler/driver path.

> **Data access DAX-038 group-owner/admin request UI** (2026-06-11, first M4 authoring surface for [#849](https://github.com/pingcap-inc/Helix/issues/849); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`router.py`](../../server/app/domains/data_access/router.py) now exposes the request facade around existing DAX service authority: `GET /api/v1/data-access/requestable-resources` lists active/requestable BigQuery dataset rows with resolved owner authority only for global admins or employees who own at least one active Google Group, `GET /api/v1/data-access/requestable-principals` lists active Google Groups the requester owns (global admins see all active groups), and `POST /api/v1/data-access/requests/group-grants` submits one `email_groups.id`, fixed `read`, many dataset resource ids, a business reason, and an `Idempotency-Key` through `DataAccessService.submit_group_grant_request(...)`. [`DataAccessRequestPage`](../../web/src/pages/dataAccess/DataAccessRequestPage.tsx) adds `/my/data-access/requests` with a Google Group selector, searchable/paginated dataset picker, fixed read entitlement, business reason, and a sticky summary showing one Feishu approval for the selected dataset set. [`test_router.py`](../../server/tests/test_data_access/test_router.py) proves employee/admin visibility, non-owner employees cannot enumerate requestable dataset locators/owner labels, plus the DAX-038 invariant that 10 selected datasets create one data-access request, 10 request items, one `approval_requests` row, and one Feishu approval payload while leaving bindings/effects empty before approval. The HTTP/UI surface still does not call BigQuery, create bindings/effects before approval, add MCP tools or scheduled jobs, expose table/single-user/project IAM/`bigquery.jobUser`, create datasets, author Feishu templates, reconcile Google Group membership, or mutate providers outside the guarded data-access reconciler/driver path.
>

> **Data access DAX-037 end-to-end canary audit trail** (2026-06-11, M3 audit proof for [#848](https://github.com/pingcap-inc/Helix/issues/848); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). A new regression drives one approved Google Group dataset-read canary lifecycle through the real semantic services and reconciler methods: request submission, Feishu approval callback, binding/effect creation, [`apply_present_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py), [`revoke_data_access_binding(...)`](../../server/app/domains/data_access/service.py), and [`apply_absent_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py). It then proves the durable trail contains request, callback, binding, compile, apply, revoke, provider outcome, effect-run, provider etag, and trace-id evidence.
>
> The proof is authority-preserving: request/callback code creates semantic state and compile output only, while provider mutation remains reconciler-owned through the guarded `data_access/bigquery` dataset-IAM driver. The sentinel driver fails if this path lists datasets/resources, touches project IAM, or calls generic cloud grants. This is test/doc coverage only; there is still no scheduled runner, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, Terraform path, or cloud-lifecycle grant path.
>

> **Data access DAX-036 failure-semantics hardening** (2026-06-11, M3 failure proof for [#847](https://github.com/pingcap-inc/Helix/issues/847); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). Executable regressions now pin the apply failure contract around [`apply_present_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py) and [`apply_absent_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py). A provider auth/read failure while adding a present effect leaves the selected effect `retryable` with `apply_present_error`, keeps the semantic binding active and approved request retryable, audits the redacted error, and proves no `set_dataset_iam` call/provider write occurs.
>
> Partial absent failure is locator-scoped: a successful dataset revoke can promote only its own binding to `revoked` and mark its effect applied, while a separate failed dataset remains `revoked_pending`/retryable with its own provider error/etag evidence. This is proof of existing failure isolation, not a new production surface: request handlers and Feishu callbacks still do not call BigQuery, and there is still no scheduled runner, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, Terraform path, or cloud-lifecycle grant path.
>

> **Data access DAX-035 already-present canary grant adoption** (2026-06-11, M3 adoption proof for [#846](https://github.com/pingcap-inc/Helix/issues/846); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). The DAX-033 present-effect runner now exposes the provider no-op adoption marker in its result and audit path: when the guarded `data_access/bigquery` driver reports `already_applied=True` for an approved compiled effect, [`apply_present_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py) returns and audits `already_present_at_first_apply=true` from the compiled effect while still marking the effect `applied`, writing `mode='apply_present'` run history, and refreshing request/binding roll-ups.
>
> The regression proves the reversible adoption contract: an existing exact `group:<email>` + `roles/bigquery.dataViewer` grant becomes Helix-managed only through an approved binding/effect, and a later `revoke_data_access_binding(...)` compiles an absent effect for that exact provider member/role/locator so [`apply_absent_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py) removes only the adopted grant and preserves unrelated ACL members. Request handlers and Feishu callbacks still do not call BigQuery, and there is still no scheduled runner, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, Terraform path, or cloud-lifecycle grant path.
>

> **Data access DAX-034 apply-absent revoked binding runner** (2026-06-11, M3 absent-effect mutation for [#845](https://github.com/pingcap-inc/Helix/issues/845); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`apply_absent_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py) is the reconciler-owned revoke-side provider mutation path. It locks selected pending/retryable compiled effects, filters to `desired_state='absent'` rows whose semantic binding is already `revoked_pending` or `revoked`, reuses the DAX-032 enabled `data_access/bigquery` apply gate, reads the current dataset IAM etag for each allowed locator, and calls the guarded BigQuery `set_dataset_iam` path with a remove-only patch for the exact compiled `group:<email>` + `roles/bigquery.dataViewer` grant.
>
> Successful and already-absent results mark effects `applied`, promote `revoked_pending` bindings to `revoked`, write `mode='apply_absent'` effect-run rows, refresh binding/request reconcile roll-ups, and audit `data_access.reconcile.apply_absent` with gate decisions, ignored malformed active-binding effects, provider write counts, and per-effect outcomes. Conflicts and provider errors remain `retryable`; disabled config leaves desired state untouched and makes no provider call. Request handlers and Feishu callbacks still do not call BigQuery, and there is still no scheduled runner, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, or cloud-lifecycle grant path.
>

> **Data access DAX-033 apply-present runner** (2026-06-11, M3 present-effect mutation for [#844](https://github.com/pingcap-inc/Helix/issues/844); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`apply_present_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py) is the first reconciler-owned data-access provider mutation path. It locks the selected pending compiled effects, ignores absent/revoke effects for DAX-034, reuses the DAX-032 enabled `data_access/bigquery` apply gate for present effects, reads the current dataset IAM etag for each allowed locator, and calls the guarded BigQuery `set_dataset_iam` path with an add-only patch for exact `group:<email>` + `roles/bigquery.dataViewer` grants.
>
> Successful and already-applied results mark effects `applied`, write `mode='apply_present'` effect-run rows, refresh binding/request reconcile roll-ups, and audit `data_access.reconcile.apply_present` with gate decisions, provider write counts, and per-effect outcomes. Conflicts and provider errors remain `retryable`. Request handlers and Feishu callbacks still do not call BigQuery, and there is still no scheduled runner, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, or cloud-lifecycle grant path; DAX-034 above owns the absent/revoke side.
>

> **Data access DAX-032 reconciler apply-mode gate** (2026-06-11, M3 rollout gate for [#843](https://github.com/pingcap-inc/Helix/issues/843); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`apply_gate.py`](../../server/app/domains/data_access/apply_gate.py) centralizes the `data_access/bigquery` integration config contract for `apply_mode_enabled`, and [`BigQueryDataAccessDriver`](../../server/app/integrations/drivers/bigquery_data_access.py) reuses that parser for its fail-closed write guard. [`resolve_apply_reconcile_gate(...)`](../../server/app/domains/data_access/reconciler.py) gives the reconciler an audited apply gate: it locks only the selected pending compiled effects with `FOR UPDATE SKIP LOCKED`, classifies each as `allowed`, `apply_mode_disabled`, or `invalid_apply_gate_config`, and writes `data_access.reconcile.apply_gate` audit detail with the exact selection and decisions. Dataset scope is enforced by Terraform-managed BigQuery IAM on the dedicated DAX service account.
>
> The helper does not call `set_dataset_iam`, build a BigQuery client, or update effect/binding/request status, so disabled config stops mutation eligibility without deleting desired state. DAX-032 itself shipped no scheduled job, reconciler provider-write runner, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, or cloud-lifecycle grant path; DAX-033 above now owns the first present-effect mutation path while request handlers and Feishu callbacks still do not call BigQuery.
>

> **Data access DAX-031 BigQuery apply/revoke driver method** (2026-06-11, M3 etag-concurrency proof for [#842](https://github.com/pingcap-inc/Helix/issues/842); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`BigQueryDataAccessDriver.set_dataset_iam(...)`](../../server/app/integrations/drivers/bigquery_data_access.py) now wires the DAX-030 ACL planner to a guarded BigQuery dataset `PATCH` call. The method refuses before client construction unless `apply_mode_enabled` is truthy; dataset scope is enforced by the dedicated service account's Terraform-managed dataset IAM. When allowed it re-reads the same dataset with `datasetView=ACL` / `accessPolicyVersion=3`, compares provider etag with the caller's `expected_etag`, sends `If-Match`, patches with `updateMode=UPDATE_ACL` / `accessPolicyVersion=3`, returns `already_applied=True` for no-op add/remove patches, and returns `conflict=True` for stale etags or provider 409/412 precondition failures.
>
> The write body still changes only exact unconditional DAX compiled effects (`group:<email>` + `roles/bigquery.dataViewer`) on one dataset locator and preserves unrelated owners, writers, users, special groups, authorized views/routines, conditional ACL entries, and non-DAX IAM-style entries. Request handlers, Feishu callbacks, and observe-mode reconciler code still do not call the write path. No scheduled job, reconciler apply runner, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, or cloud-lifecycle grant path shipped in this PR.
>

> **Data access DAX-030 BigQuery dataset ACL canary planner** (2026-06-11, M3 IAM-vs-legacy-ACL behavior proof for [#841](https://github.com/pingcap-inc/Helix/issues/841); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`BigQueryDataAccessDriver`](../../server/app/integrations/drivers/bigquery_data_access.py) now exposes a pure dataset ACL update-body planner for the future apply call. The planner accepts current BigQuery REST `access` arrays and the current `google-cloud-bigquery` `access_entries` shape, accepts only unconditional DAX compiled changes for `group:<email>` + `roles/bigquery.dataViewer`, removes only matching managed entries, preserves unrelated REST entries byte-for-byte, converts current access-entry snapshots to REST `access` shape when needed, and appends missing Google Group dataset-reader grants as canonical `READER` + `groupByEmail`.
>
> Tests cover legacy `groupByEmail`, IAM-style `iamMember`, conditional entries, owners, writers, users, special groups, authorized views/routines, current access-entry snapshots, and a non-DAX `jobUser`-like entry. [`DATA_ACCESS.md`](DATA_ACCESS.md#bigquery-dataset-acl-patch-behavior) records the `datasetView=ACL` read / future `UPDATE_ACL` write merge contract. DAX-030 itself shipped no provider mutation, reconciler apply runner, scheduled job, HTTP/MCP/UI surface, project IAM, `bigquery.jobUser`, broad scan, table access, query job, raw-provider policy persistence, or cloud-lifecycle grant path; DAX-031 above now wires the planner to a guarded driver call only.
>

> **Data access DAX-029 BigQuery dataset IAM least-privilege contract** (2026-06-11, M3 credential-boundary proof for [#840](https://github.com/pingcap-inc/Helix/issues/840); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`BigQueryDataAccessDriver`](../../server/app/integrations/drivers/bigquery_data_access.py) now exposes the exact provider credential contract needed before apply mode: the dedicated `data_access/bigquery` `service_account_json` secret uses OAuth scope `https://www.googleapis.com/auth/bigquery`, while the documented Google custom role `HelixDaxDatasetIamReconciler` contains only `bigquery.datasets.get`, `bigquery.datasets.getIamPolicy`, `bigquery.datasets.update`, and `bigquery.datasets.setIamPolicy`. [`DATA_ACCESS.md`](DATA_ACCESS.md#bigquery-reconciler-credential-least-privilege) records why `bigquery.readonly`, project IAM roles, `roles/bigquery.jobUser`, job creation, dataset listing, table permissions, broad BigQuery roles, and the CMDB cloud-grant service account are out of scope. Tests prove the driver uses dedicated data-access config, the permission tuple excludes dataset create/delete/list plus job/table-data permissions, durable data-access request/binding/effect/run-history tables have no provider secret/raw-policy/raw-response columns, and request handlers plus Feishu callback dispatch still do not call BigQuery directly. No scheduled job, HTTP/MCP/UI surface, schema table, project IAM, `bigquery.jobUser`, broad scan, table access, or cloud-lifecycle grant path shipped in this PR.
>

> **Data access DAX-028 negative IAM and broad-scan guard proof** (2026-06-11, M2 negative-provider-boundary proof for [#839](https://github.com/pingcap-inc/Helix/issues/839); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). Tests now prove the observe-mode provider boundary remains locator-scoped: sentinel driver methods fail if the reconciler touches broad dataset/resource scans, project IAM/project-policy reads or writes, generic cloud grants, or `set_dataset_iam`, while the focused observe test asserts one selected effect/resource yields one `get_dataset_iam(project_id, dataset_id)` read plus explicit add/remove/unchanged/unmanaged/error counts and scoped audit detail. Driver-interface guards scan the production reconciler and BigQuery data-access driver for project-IAM, `roles/bigquery.jobUser`, broad-scan, and cloud-grant tokens, and request/Feishu callback scans prove handlers do not call BigQuery driver/API surfaces. This is guard-only evidence: no production behavior, schema, scheduled job, HTTP/MCP/UI surface, BigQuery write/apply implementation, table/single-user access, project IAM, `bigquery.jobUser`, broad scan, or cloud-lifecycle grant path shipped in this PR.
>

> **Data access DAX-027 unmanaged federated grant reporting** (2026-06-11, M2 delta-report evidence for [#838](https://github.com/pingcap-inc/Helix/issues/838); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`observe_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py) now emits unmanaged provider-side grant evidence for selected federated BigQuery dataset locators: after reading each locator with `get_dataset_iam`, it compares canonical provider grants (`group:` members with `roles/bigquery.dataViewer`) against Helix's compiled effects for that selected resource/locator and returns/writes `unmanaged_grant_count` plus `unmanaged_grants` in the `data_access.reconcile.observe` audit detail. The report is evidence-only: observation does not create bindings, does not adopt provider grants, ignores unsupported/non-dataset resource rows, and does not treat already-observed Helix-managed effects as unmanaged. Tests prove unmanaged reporting without binding creation, non-federated row exclusion, and the existing no-apply/no-broad-scan guard. No scheduled job, HTTP/MCP/UI surface, schema table, unmanaged-state authority, BigQuery write/apply path, project IAM, `bigquery.jobUser`, or broad BigQuery scan has shipped in this PR.
>

> **Data access DAX-026 effect run history and reconcile rollups** (2026-06-11, M2 run-history/status for [#837](https://github.com/pingcap-inc/Helix/issues/837); 1 migration / 0 cron / 1 schema table + 8 schema columns / 0 RBAC role / 0 MCP-tool-count). Migration [`336_data_access_effect_runs.py`](../../server/alembic/versions/336_data_access_effect_runs.py) adds `data_access_effect_runs` plus `reconcile_status`, `last_reconcile_run_id`, `last_reconcile_at`, and `last_reconcile_error` on both `data_access_bindings` and `data_access_requests`. [`observe_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py) now writes one history row per processed effect with run id, trace id, integration/effect/binding/request/resource ids, target locator, provider member/role, desired state, resulting effect status, provider outcome/etag/error, and timestamps. After each observe run, binding/request roll-ups are refreshed from their compiled effects: retryable/failed status is visible, pending remains pending, and successful observe rows roll up to `observed`. Tests prove per-effect run-history persistence, request/binding observed roll-up, failed-observe retry visibility, and static absence of apply/project-IAM/`bigquery.jobUser`/broad-scan tokens. No scheduled job, HTTP/MCP/UI surface, unmanaged-grant report, or BigQuery write/apply path has shipped in this PR.
>

> **Data access DAX-025 observe reconciler locking/idempotency** (2026-06-11, M2 observe hardening for [#836](https://github.com/pingcap-inc/Helix/issues/836); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`observe_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py) now locks only the selected pending or retryable `data_access_compiled_effects` rows using `FOR UPDATE SKIP LOCKED`, scoped to one explicit `data_access/bigquery` integration plus optional caller-selected effect ids and resource ids. Concurrent workers racing the same bounded set partition safely: the second worker skips locked rows, records a zero-effect `data_access.reconcile.observe` audit summary, and does not call the provider. Audit detail now carries `lock_strategy=for_update_skip_locked` and the selected effect/resource ids so future run-history can attach to the same execution boundary. Tests prove resource-scope narrowing, concurrent skip-locked idempotency, no double provider read, no status corruption, retryable-effect compatibility, and static absence of apply/project-IAM/`bigquery.jobUser`/broad-scan tokens. The runner remains observe-only: no `set_dataset_iam`, scheduled job, run-history table, binding/request roll-up status, unmanaged-grant report, UI, HTTP/MCP endpoint, or BigQuery write/apply path has shipped in this PR.
>

> **Data access DAX-024 observe-mode reconciler runner** (2026-06-11, M2 observe runner for [#835](https://github.com/pingcap-inc/Helix/issues/835); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`observe_reconcile_compiled_effects(...)`](../../server/app/domains/data_access/reconciler.py) now consumes pending or retryable persisted `data_access_compiled_effects` for one explicit data-access integration, groups them by compiled BigQuery dataset locator, calls the data-access driver's `get_dataset_iam(project_id, dataset_id)` once per selected locator, compares provider state with each compiled member/role/desired-state row, and records `observe_would_add`, `observe_would_remove`, `observe_unchanged`, or `observe_error` on the effect provider-outcome fields. Each run writes a traceable `data_access.reconcile.observe` audit summary with mode, counts, and delta details. Tests prove add/remove/unchanged deltas, integration/effect scoping, read-error retryable marking, audit output, and static absence of apply/project-IAM/`bigquery.jobUser`/broad-scan tokens. The runner never calls `set_dataset_iam`, does not enumerate datasets, and does not touch request handlers or Feishu callbacks. No scheduled job, locking/idempotency layer, run-history table, binding/request roll-up status, unmanaged-grant report, UI, HTTP/MCP endpoint, or BigQuery write/apply path has shipped in this PR.
>

> **Data access DAX-023 BigQuery IAM read path and credentials** (2026-06-11, M2 read-path contract for [#834](https://github.com/pingcap-inc/Helix/issues/834); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`BigQueryDataAccessDriver`](../../server/app/integrations/drivers/bigquery_data_access.py) now declares required secret `service_account_json` config, parses the Service Account JSON, builds a BigQuery client with the `https://www.googleapis.com/auth/bigquery` scope, validates canonical dataset locators, and reads only the requested BigQuery dataset via `get_dataset(..., dataset_view=DatasetView.ACL)`. The driver normalizes unconditional dataset access entries into `DataAccessDatasetIamPolicy` bindings (`READER` / `WRITER` / `OWNER` become BigQuery dataViewer / dataEditor / dataOwner role strings) while preserving conditional entries and all raw access entries for later reconcile/apply decisions. Mocked tests prove the read path touches exactly the requested dataset locator, requests ACL view only, never lists datasets, never runs query jobs, fails closed on auth/provider errors, keeps `set_dataset_iam` disabled, and does not route through CMDB/GCP cloud grant methods. No scheduled job, reconciler, HTTP/MCP endpoint, UI, run history, provider write path, broad scan, table/project access, project IAM, or `bigquery.jobUser` path has shipped.
>

> **Data access DAX-022 locator-scoped driver IAM interface** (2026-06-11, M2 interface contract for [#833](https://github.com/pingcap-inc/Helix/issues/833); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`DataAccessDriver`](../../server/app/integrations/driver_base.py) now defines exactly the dataset-locator IAM surface that future reconciler work can call: `get_dataset_iam(project_id, dataset_id)` and `set_dataset_iam(project_id, dataset_id, expected_etag, policy_patch)`. The same file carries immutable dataset IAM binding/policy/patch/update-result dataclasses so later provider and reconciler code share one interface shape. DAX-022 shipped the interface only; DAX-023 above is the first credential/read implementation and DAX-031+ owns apply behavior. Tests prove the interface is locator-scoped and expose no broad `list_datasets`, manage-all-datasets, cloud grant, project IAM, table access, or `bigquery.jobUser` surface. No credential loading, provider call, scheduled job, reconciler, HTTP/MCP endpoint, UI, run history, provider mutation, broad scan, table/project access, project IAM, or `bigquery.jobUser` path shipped in DAX-022.
>

> **Data access DAX-021 BigQuery driver registration** (2026-06-11, M2 registry boundary for [#832](https://github.com/pingcap-inc/Helix/issues/832); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`DataAccessDriver`](../../server/app/integrations/driver_base.py) now owns the dedicated `kind='data_access'` integration mixin, and [`BigQueryDataAccessDriver`](../../server/app/integrations/drivers/bigquery_data_access.py) registers `provider='bigquery'` through the normal driver import path. Registry tests prove `data_access/bigquery` resolves as a data-access driver, not the existing GCP cloud driver, and DAX tests statically guard the registration module against Google SDK imports, CMDB cloud lifecycle references, cloud scope/grant methods, and provider mutation tokens. This PR was registration-only: no credential fields, no live connection test, no locator-scoped IAM interface, no reconciler, no scheduled job, no HTTP/MCP endpoint, no UI, no broad scan, no table/project access, no project IAM, and no `bigquery.jobUser` path shipped in DAX-021.
>

> **Data access DAX-020 audit events** (2026-06-10, M1 audit traceability for [#831](https://github.com/pingcap-inc/Helix/issues/831); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`DataAccessService.submit_group_grant_request(...)`](../../server/app/domains/data_access/service.py) now audits the pre-approval `data_access.compile.output` preview with the same trace as `data_access.request.submit`, tying the persisted request and linked approval request to the exact dry-run provider effects shown to Feishu approvers. [`DataAccessService.handle_feishu_callback(...)`](../../server/app/domains/data_access/service.py) audits `data_access.request.callback_received`, per-binding `data_access.binding.created`, and callback-time `data_access.compile.output` before the existing approved/refused terminal audit rows, preserving the callback trace across receipt, semantic binding creation, compiled effect output, and failure states. Revoke paths also audit compile output for user revoke requests, system invalid/deleted group cleanup, and deleted-to-active group rename bridges. Tests assert trace IDs and structured effect payloads across request submission, approved/rejected/refused callbacks, binding creation, revoke intent, system cleanup, and rename bridges, and continue to prove request handlers and Feishu callbacks do not mutate BigQuery. No schema, HTTP/MCP endpoint, scheduled job, reconciler, BigQuery driver, provider mutation, UI, broad scan, table/project access, project IAM, or `bigquery.jobUser` path has shipped in this PR.
>

> **Data access DAX-019 revoke service** (2026-06-10, M1 semantic revoke intent for [#830](https://github.com/pingcap-inc/Helix/issues/830); 0 migration / 0 cron / 0 schema column / 0 RBAC role / 0 MCP-tool-count). [`DataAccessService.revoke_data_access_binding(...)`](../../server/app/domains/data_access/service.py) creates Feishu-free revoke intent for an active `data_access_bindings` row when the actor is a current Google Group owner, resolved resource owner, or global admin. It marks the binding `revoked_pending`, stamps the revoke reason/time, compiles one `desired_state='absent'` `data_access_compiled_effects` row with `managed_reason='revoke_request'`, and audits the transition with the caller trace id. [`DataAccessService.revoke_invalid_google_group_bindings(...)`](../../server/app/domains/data_access/service.py) consumes deleted/invalid Google Group read-model signals from `email_groups`: deleted-without-bridge, missing Google group id, and active duplicate same-`google_group_id` principals are idempotently marked `revoked_pending` with absent effects; ambiguous deleted-row rename bridge candidates fail closed with an audit-visible blocked state; and exactly one active same-`google_group_id` primary-email rename bridge relinks or supersedes semantic bindings while compiling old-email absent plus new-email present bridge effects for active survivors and respecting revoked-pending survivors without regranting them. Absent effects preserve the original present compiled-effect target when available, so resource-row drift does not retarget cleanup. Tests prove group-owner/resource-owner/global-admin/system authorization, unresolved resource-owner denial, non-owner denial, duplicate system signal idempotency, active duplicate identity invalidation, ambiguous bridge blocking, rename collision handling including revoked-pending collision, no Feishu approval creation, audit traceability, and no BigQuery/provider driver calls. No HTTP/MCP endpoint, scheduled job, reconciler, BigQuery driver, provider mutation, UI, broad scan, table/project access, project IAM, or `bigquery.jobUser` path has shipped in this PR.
>

> **Data access DAX-018 callback staleness hardening** (2026-06-10, M1 stale-refusal gate for [#829](https://github.com/pingcap-inc/Helix/issues/829); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`DataAccessService.handle_feishu_callback(...)`](../../server/app/domains/data_access/service.py) now indexes the Feishu-submitted compiled preview and refuses approved callbacks when the preview is missing/invalid/duplicated, when its `compiler_version` is not the current `dax-compiler-v1`, or when its entitlement-version snapshot differs from the pending request. The DAX-018 tests also pin the stale gates around resource requestability, catalog source-version drift, stale entitlement version, stale requester state, missing `google_group_id`, duplicate active group identity, deleted group without a bridge, and deleted-to-active same-`google_group_id` bridge success. Refused callbacks mark `stale_requires_resubmit`, audit a failure, and create no bindings/effects. Same-`google_group_id` bridges still require the active bridge row to validate and the requester to be authorized for that active row. No reconciler, BigQuery driver, provider mutation, UI, broad scan, table access, project IAM, or `bigquery.jobUser` path has shipped in this PR.
>

> **Data access DAX-017 Feishu approval callback transition** (2026-06-10, M1 callback state transition for [#828](https://github.com/pingcap-inc/Helix/issues/828); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`server/app/approval/service.py`](../../server/app/approval/service.py) now routes `approval_type='data_access_group_grant'` callbacks to [`DataAccessService.handle_feishu_callback(...)`](../../server/app/domains/data_access/service.py) instead of the DAX-016 deferral guard. The handler locks the linked `data_access_requests` row, validates the approval-request/request/Feishu-instance binding, records latest Feishu detail, and keeps Feishu status distinct from the DAX domain outcome. Rejected/cancelled callbacks terminalize the request/items without bindings/effects. Approved callbacks revalidate canonical `read:v1`, active requester, requester authorization, Google Group identity, and every dataset locator/source/owner snapshot; fresh approvals create one active semantic binding plus one pending compiled effect per request item. Duplicate callbacks are idempotent. Stale resource context, invalid/deleted/ambiguous group identity, mismatched instance/request binding, stale requester/entitlement state, and active binding conflicts mark the request `stale_requires_resubmit`, audit a failure refusal, and create no bindings/effects. Same-`google_group_id` primary-email rename is accepted and recompiles provider member output to the current primary email. No reconciler, BigQuery driver, provider mutation, UI, broad scan, table access, project IAM, or `bigquery.jobUser` path has shipped in this PR.
>

> **Data access DAX-016 Feishu-backed request submission service** (2026-06-10, M1 stateful submission for [#827](https://github.com/pingcap-inc/Helix/issues/827); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`server/app/domains/data_access/service.py`](../../server/app/domains/data_access/service.py) now exposes `submit_group_grant_request(...)`, the first stateful DAX path. It resolves the active `data_access_group_grant` Feishu flow, validates canonical `read:v1`, validates every active/requestable BigQuery dataset resource with resolved owner authority, validates an active Google Group principal without deleted-group bridging, authorizes the requester as group owner or global admin, builds the DAX-015 payload and Feishu form from the configured `payload_schema`, resolves the requester to a Feishu user id, creates `data_access_requests` / `data_access_request_items`, creates the Feishu approval instance, creates the linked `approval_requests` row, and writes an audit event. Idempotency keys return the existing request without creating another Feishu instance. Security-bar rejections run before external submission for inactive/missing flow config, invalid resource, deleted group, unauthorized requester, raw provider-role input, table/project resource, and user/person principal. The approval callback dispatcher has an explicit `data_access_group_grant` deferral guard: it records latest Feishu detail but leaves both rows pending so the generic approval fallback cannot terminalize DAX before the real callback transition lands. The v2 Feishu form is human-readable (summary/access/dataset list/requester note/link/context); persisted source payload still carries raw semantic intent, owner context, and compiled preview for callbacks/audit, with no Feishu secrets or alias-as-provider-member authority. This still stops at `pending_feishu`: no terminal DAX Feishu callback transition, binding/effect creation, reconciler, BigQuery driver, provider mutation, UI, broad scan, table access, project IAM, or `bigquery.jobUser` path has shipped in this PR.
>

> **Data access DAX-015 Feishu payload builder** (2026-06-10, M1 approval payload for [#826](https://github.com/pingcap-inc/Helix/issues/826); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`server/app/domains/data_access/service.py`](../../server/app/domains/data_access/service.py) now exposes `build_feishu_submission_payload(...)`, a pure pre-submit builder that accepts the resolved `data_access_group_grant` flow mapping, validated BigQuery dataset resource snapshots, validated Google Group principal snapshot, canonical `read:v1` entitlement row, business reason, and detail URL. Its v3 Feishu submission fields are approver-readable: request summary, requested access, dataset list with description when present, requester note/business reason, and Helix detail URL. The same object exposes separate audit fields for persisted `approval_requests.source_payload`: payload version, semantic intent, dataset count, dataset owner ids/context, business reason, detail URL, and DAX-014 compiled previews. The builder compiles previews internally, so provider member remains `group:<primary_email_snapshot>` and provider role remains `roles/bigquery.dataViewer`; stale provider-member strings and raw provider-role inputs are not payload authority. It rejects non-`data_access_group_grant` flows, missing external approval codes, empty or duplicate dataset selections, malformed detail URLs, and unsupported resource/entitlement combinations via the dry-run compiler. No Feishu instance creation, request/request-item persistence, binding/effect persistence, callback transition, reconciler, BigQuery driver, provider mutation, UI, broad scan, table access, project IAM, or `bigquery.jobUser` path has shipped in this PR.
>

> **Data access DAX-014 dry-run compiler** (2026-06-10, M1 compile preview for [#825](https://github.com/pingcap-inc/Helix/issues/825); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`server/app/domains/data_access/service.py`](../../server/app/domains/data_access/service.py) now exposes `compile_bigquery_dataset_read_dry_run(...)`, which consumes the validated BigQuery dataset resource snapshot, validated Google Group principal snapshot, and canonical `read:v1` entitlement row, then returns an immutable provider-effect preview without persisting rows or calling BigQuery. The compiler emits only provider `bigquery`, resource type `dataset`, target dataset locator, provider member `group:<primary_email_snapshot>`, provider role `roles/bigquery.dataViewer`, desired state `present`, entitlement/resource provenance snapshots, compiler version `dax-compiler-v1`, and managed reason `grant_request`. It derives provider member from the primary-email snapshot on every call, so primary-email rename recompiles to the new `group:<email>` and stale/alias/provider-member strings are not accepted as request input. Unsupported provider/resource/entitlement/member-prefix/provider-role/status/requestability combinations fail closed. No request submission handler, Feishu callback transition, persisted binding/effect write path, reconciler, BigQuery driver, provider mutation, UI, broad scan, table access, project IAM, or `bigquery.jobUser` path has shipped in this PR.
>

> **Data access DAX-013 requester authorization** (2026-06-10, M1 requester gate for [#824](https://github.com/pingcap-inc/Helix/issues/824); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`server/app/domains/data_access/service.py`](../../server/app/domains/data_access/service.py) now exposes `authorize_requester_for_google_group_principal(...)`, which authorizes a validated Google Group principal only when the requester is the current owner of the resolved `email_groups.id` through `email_group_owners`, or when the requester holds global-admin authority. Primary email and aliases are not authorization keys. The role check honors request-scoped view-role narrowing before falling back to an explicit async DB role read, so an admin narrowed to employee cannot use the admin escape hatch. Tests cover group owner allowed, non-owner denied, global admin allowed, view-role narrowing denied, email/alias match denied, deleted-row owner denied after rename bridge, active bridge owner allowed, and static proof that request-facing/Feishu callback surfaces do not mutate BigQuery. No request submission handler, Feishu callback transition, compiler, reconciler, BigQuery driver, provider mutation, UI, broad scan, table access, project IAM, or `bigquery.jobUser` path has shipped in this PR.
>

> **Data access DAX-012 Google Group principal validator** (2026-06-10, M1 principal preflight for [#823](https://github.com/pingcap-inc/Helix/issues/823); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`server/app/domains/data_access/service.py`](../../server/app/domains/data_access/service.py) now exposes `validate_google_group_principal(...)`, which resolves a user-entered group email/alias or existing `email_groups.id` to one unambiguous active Google Group row. The returned snapshot uses `email_groups.id` as the semantic principal key, `google_group_id` as the rename-continuity anchor, the primary email as `principal_email_snapshot`, and compiled provider member `group:<primary_email_snapshot>`. Alias input can resolve to a group, but aliases are never provider members or durable semantic keys. Deleted rows can bridge only when exactly one active row has the same `google_group_id`, preserving primary-email rename continuity; unknown groups, user/person/service-account principal types, deleted groups without a bridge, missing `google_group_id`, alias collisions, and duplicate active rows for one `google_group_id` fail closed. No group membership reconciliation, group creation, request submission handler, Feishu callback transition, compiler, reconciler, BigQuery driver, provider mutation, UI, broad scan, table access, project IAM, or `bigquery.jobUser` path has shipped in this PR.
>

> **Data access DAX-011 resource validator** (2026-06-10, M1 request preflight for [#822](https://github.com/pingcap-inc/Helix/issues/822); 0 migration / 0 cron / 0 schema table / 0 RBAC role / 0 MCP-tool-count). [`server/app/domains/data_access/service.py`](../../server/app/domains/data_access/service.py) now exposes `validate_requestable_bigquery_dataset_resource(...)`, which reads `data_access_resources` and returns an immutable request-intent snapshot only for active, requestable, canonical BigQuery dataset rows. It rejects resources that are missing from the DAX projection, non-BigQuery provider rows, table/non-dataset rows, inactive rows, and non-requestable rows before Feishu submission. When the caller requires a resource-owner decision, the validator fails closed unless the projected row has `owner_resolution_status='resolved'` and non-null `owner_person_id`; runtime owner authority still comes only from `data_access_resources.owner_person_id` via the row-local `resource_owner_authority_enabled` property. No request submission handler, Feishu callback transition, compiler, reconciler, BigQuery driver, provider mutation, UI, broad scan, table access, project IAM, or `bigquery.jobUser` path has shipped in this PR.
>

> **Data access DAX-010 compiled-effect table** (2026-06-10, M1 compiled desired output for [#821](https://github.com/pingcap-inc/Helix/issues/821); 1 migration / 0 cron / 1 schema table / 0 RBAC role / 0 MCP-tool-count). Migration [`335_data_access_compiled_effects.py`](../../server/alembic/versions/335_data_access_compiled_effects.py) and model [`server/app/domains/data_access/models.py`](../../server/app/domains/data_access/models.py) add `data_access_compiled_effects`, the provider-specific output compiled from semantic binding state. Effects reference binding/resource/`email_groups.id`/entitlement ids, then persist BigQuery target locator, provider member (`group:<primary_email_snapshot>`), provider role `roles/bigquery.dataViewer`, desired state, entitlement version, resource source version/etag, compiler version, `managed_reason`, `already_present_at_first_apply`, status, and provider outcome/error fields. The provider member snapshot is compile output and rename/revoke evidence; it does not replace the semantic Google Group id. No reconciler, BigQuery driver, policy patch execution, project IAM, provider mutation, request/callback transition, UI, or broad scan path has shipped in this PR.
>

> **Data access DAX-009 binding table** (2026-06-10, M1 semantic binding state for [#820](https://github.com/pingcap-inc/Helix/issues/820); 1 migration / 0 cron / 1 schema table / 0 RBAC role / 0 MCP-tool-count). Migration [`334_data_access_bindings.py`](../../server/alembic/versions/334_data_access_bindings.py) and model [`server/app/domains/data_access/models.py`](../../server/app/domains/data_access/models.py) add `data_access_bindings`, the approved semantic desired-state row for `resource dataset + principal google_group + entitlement read`. Bindings reference `data_access_resources.id`, `email_groups.id`, and `data_access_entitlements.id`; Google group id and primary email snapshots are retained only for later compile/revoke/rename evidence. Current uniqueness is enforced on resource id + group row id + entitlement id using generated `active_semantic_key`, so active/current bindings cannot duplicate even if the provider primary email snapshot changes. Historical `revoked` rows stay queryable with their old provider email snapshots. No compiled effects, reconciler, BigQuery driver, project IAM, policy patch, provider mutation, UI, or broad scan path has shipped in this PR.
>

> **Data access DAX-008 request tables** (2026-06-10, M1 semantic request intent for [#819](https://github.com/pingcap-inc/Helix/issues/819); 1 migration / 0 cron / 2 schema tables / 0 RBAC role / 0 MCP-tool-count). Migration [`333_data_access_requests.py`](../../server/alembic/versions/333_data_access_requests.py) and model [`server/app/domains/data_access/models.py`](../../server/app/domains/data_access/models.py) add `data_access_requests` and `data_access_request_items`. A request row stores the requester, canonical `approval_flow_key='data_access_group_grant'`, Feishu instance code, idempotency/trace fields, `read:v1` entitlement snapshot, and the semantic Google Group principal as `principal_email_group_id -> email_groups.id`; `principal_google_group_id_snapshot` and `principal_email_snapshot` are callback-staleness/provider-member snapshots, not alternate principal authority. Request items reference `data_access_resources.id` and snapshot dataset locator/source/owner context for stale callback checks. The tables deliberately have no alias, provider-member, provider-role override, binding, compiled-effect, BigQuery project IAM, or provider-mutation columns. No request submission handler, Feishu callback transition, compiler, reconciler, BigQuery driver, provider mutation, UI, or broad scan path has shipped in this PR.
>

> **Data access DAX-007 entitlement catalog** (2026-06-10, M1 semantic entitlement seed for [#818](https://github.com/pingcap-inc/Helix/issues/818); 1 migration / 0 cron / 1 schema table / 0 RBAC role / 0 MCP-tool-count). Migration [`332_data_access_entitlements.py`](../../server/alembic/versions/332_data_access_entitlements.py) and model [`server/app/domains/data_access/models.py`](../../server/app/domains/data_access/models.py) add `data_access_entitlements`, the canonical DAX V1 entitlement catalog. It idempotently seeds one requestable row: `read:v1` for domain `data_access`, provider `bigquery`, resource type `dataset`, principal type `google_group`, provider member prefix `group:`, and provider role `roles/bigquery.dataViewer`. The table deliberately has no resource, integration, project, dataset, locator, or provider-role-override columns, so future request/binding/compiler code has one semantic Helix entitlement to reference and no per-resource override path. No request tables, Feishu callback path, compiler, reconciler, BigQuery driver, provider mutation, UI, or broad scan path has shipped in this PR.
>

> **Data access DAX-006 resource table** (2026-06-10, M1 durable resource projection for [#817](https://github.com/pingcap-inc/Helix/issues/817); 1 migration / 0 cron / 1 schema table / 0 RBAC role / 0 MCP-tool-count). Migration [`331_data_access_resources.py`](../../server/alembic/versions/331_data_access_resources.py) and model [`server/app/domains/data_access/models.py`](../../server/app/domains/data_access/models.py) add `data_access_resources`, the row-local runtime source for BigQuery dataset identity and projected resource-owner authority. The row stores `integration_id`, canonical `provider='bigquery'`, canonical `resource_type='dataset'`, `project_id`, `dataset_id`, display/requestability/status, `owner_person_id`, `owner_resolution_status`, `owner_source`, `source_version`, `source_etag`, optional source digest/notes, and timestamps. Resource-owner authority is not read from raw catalog/bootstrap input: it is enabled only when the row has `owner_resolution_status='resolved'` and non-null `owner_person_id`; `unresolved_owner` and `fallback_owner` states are representable but grant no owner authority. MySQL/TiDB active-locator uniqueness uses generated `active_locator_key = SHA2(integration/provider/resource/project/dataset)` and a unique index, so duplicate active locators for one integration fail while suspended/deleted history rows can coexist. This PR introduced no request tables, Feishu callback path, compiler, reconciler, BigQuery driver, provider mutation, UI, or broad scan path.
>

> **Data access DAX-005 backend domain skeleton** (2026-06-10, M1 foundation for [#816](https://github.com/pingcap-inc/Helix/issues/816); 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). [`server/app/domains/data_access/router.py`](../../server/app/domains/data_access/router.py) is now registered in [`server/app/main.py`](../../server/app/main.py) and exposes `GET /api/v1/data-access/capabilities` to any authenticated user with the standard `success/data/meta/errors` envelope. The read-only service/schema pair ([`service.py`](../../server/app/domains/data_access/service.py), [`schemas.py`](../../server/app/domains/data_access/schemas.py)) returns the DAX-002 V1 contract and authority boundary: provider `bigquery`, resource `dataset`, principal `google_group`, entitlement `read`, flow `data_access_group_grant`, provider effect `group:<email>` + `roles/bigquery.dataViewer`, semantic principal `principal_email_group_id`, provider member `principal_email_snapshot`, runtime owner authority `owner_person_id`, and provider mutation owner `data_access_reconciler`. The skeleton deliberately has no DB session, request tables, migrations, scheduled jobs, approval callback handling, compiler, reconciler, BigQuery driver, provider mutation, UI, or new RBAC role. Tests: [`server/tests/test_data_access/test_router.py`](../../server/tests/test_data_access/test_router.py) imports the router, proves employee/admin callers receive the standard envelope, pins the payload, and statically guards the router/service files against direct BigQuery/provider mutation calls.
>

> **Data access DAX-004 bootstrap input parser** (2026-06-10, code foundation for [#815](https://github.com/pingcap-inc/Helix/issues/815); 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). [`server/app/domains/data_access/bootstrap.py`](../../server/app/domains/data_access/bootstrap.py) defines the ops-only transition input shape for bootstrapping requestable BigQuery datasets until catalog federation lands. It accepts only catalog-shaped dataset entries: canonical `provider='bigquery'`, `resource_type='dataset'`, `locator.project_id`, `locator.dataset_id`, `owner_source='bootstrap'`, `source_version`, `source_etag`, and either a normalized UUID `owner_person_id` or explicit `owner_resolution_status` of `unresolved_owner` / `fallback_owner`. `parse_bootstrap_resource(...)` and `parse_bootstrap_resources(...)` are side-effect-free parser/normalizer helpers; they do not write `data_access_resources`, create a manual catalog UI, submit Feishu approvals, register callbacks, call BigQuery, or mutate provider IAM. Tests accept BigQuery dataset ids that start with digits and reject table/project/freeform locators, top-level table/project/freeform fields, non-canonical providers, invalid locator parts, owner-authority ambiguity, non-bootstrap owner sources, and duplicate dataset locators: [`server/tests/test_data_access/test_bootstrap.py`](../../server/tests/test_data_access/test_bootstrap.py) (22 cases). This slice introduced no router, tables, migrations, scheduled jobs, request submission, callback registration, compiler, reconciler, BigQuery driver, provider mutation, or UI.
>

> **Data access DAX-003 Feishu flow mapping gate** (2026-06-10, code foundation for [#814](https://github.com/pingcap-inc/Helix/issues/814); 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). [`server/app/domains/data_access/approval_flow.py`](../../server/app/domains/data_access/approval_flow.py) now exposes `resolve_data_access_group_grant_flow(...)`, the single data-access resolver for the canonical `data_access_group_grant` Feishu approval mapping. It reads the existing `integration_approval_flows` table through `get_approval_flow`, so the approval domain remains the Feishu transport/config source of truth and Helix still does not author Feishu templates. Missing or inactive mappings fail closed with `APPROVAL_FLOW_NOT_CONFIGURED`; `accept_external_origin=True` is rejected with `DATA_ACCESS_APPROVAL_FLOW_EXTERNAL_ORIGIN_FORBIDDEN` so direct Feishu-originated instances cannot skip future Helix dataset/group validation. Tests: [`server/tests/test_data_access/test_approval_flow.py`](../../server/tests/test_data_access/test_approval_flow.py) (4 cases). This slice introduced no request submission, callback registration, approval instance creation, router, tables, migrations, scheduled jobs, compiler, reconciler, BigQuery driver, provider mutation, or UI.
>

> **Data access DAX-002 constants** (2026-06-10, code foundation for [#813](https://github.com/pingcap-inc/Helix/issues/813); 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). New package [`server/app/domains/data_access`](../../server/app/domains/data_access) starts the backend domain with constants only: [`constants.py`](../../server/app/domains/data_access/constants.py) freezes the V1 vocabulary (`data_access`, `bigquery`, `dataset`, `google_group`, `read`, `data_access_group_grant`) plus provider compile-output constants (`roles/bigquery.dataViewer`, `group:`). It also freezes the field-name contract future DAX tables/schemas must use: `principal_email_group_id` is the semantic Google Group principal key, `principal_google_group_id_snapshot` is the provider-id continuity snapshot, `principal_email_snapshot` is the provider-member email source, and `owner_person_id` / `owner_source` / `source_version` / `source_etag` are the resource-owner/source provenance fields. `assert_canonical(...)` and the three field-specific helpers reject forbidden aliases so schema/service code can fail closed before those aliases become durable names: primary email / aliases are not semantic principal keys, aliases are not provider members, raw catalog/bootstrap owner fields are not runtime authority, `gcp` / `google_cloud` are not the data-access provider, `table` / `project` are not V1 resources, `user` / `person` / `service_account` are not V1 principals, and provider roles (`roles/bigquery.dataViewer`, `bigquery.jobUser`) are not request entitlements. Tests: [`server/tests/test_data_access/test_constants.py`](../../server/tests/test_data_access/test_constants.py) (37 cases). This PR introduced no router, tables, migrations, scheduled jobs, compiler, reconciler, BigQuery driver, provider mutation, or UI.
>

> **Data access management V1 contract** (2026-06-09, doc-only DAX-001 for [#809](https://github.com/pingcap-inc/Helix/issues/809); 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). The implementation contract now lives in [`docs/architecture/DATA_ACCESS.md`](DATA_ACCESS.md), with milestone ordering in [`docs/architecture/DATA_ACCESS_EXECUTION_ORDER.md`](DATA_ACCESS_EXECUTION_ORDER.md) and the ADR in [`DECISIONS.md`](DECISIONS.md#2026-06-09--data-access-management-v1-is-bigquery-dataset-read-access-for-google-groups-through-semantic-helix-bindings). V1 is deliberately narrow: provider `bigquery`, resource `dataset`, principal `google_group`, entitlement `read`, compiled provider effect `group:<email>` + `roles/bigquery.dataViewer` at dataset scope, one Feishu approval for one group/entitlement/many datasets, and reconciler-owned BigQuery IAM mutation. Request handlers and Feishu callbacks must not call BigQuery directly. Data-access will use a dedicated `kind='data_access'`, `provider='bigquery'` driver rather than generic CMDB/GCP cloud grants. Non-goals are explicit: no table access, no single-user access, no project IAM, no `bigquery.jobUser`, no broad BigQuery scan, no dataset creation, no visible manual dataset catalog UI, no Terraform runtime apply engine, no Feishu template authoring in Helix, and no declarative Google Group membership reconciler. The contract includes a grounded current-state inventory: email-group tables/sync own Google Group facts, approval owns Feishu transport, existing BigQuery billing code is evidence only, and CMDB/GCP cloud grants are a forbidden runtime path for DAX. It also freezes two authority matrices: `email_groups.id` is the semantic Google Group principal key while `google_group_id` is the rename-continuity anchor and `group:<primary_email_snapshot>` is compiled provider output; `data_access_resources.owner_person_id` is the only runtime resource-owner authorization source, with bootstrap unresolved-owner/fallback and cutover owner-mismatch cases failing closed. The security planning bar requires every follow-up PR touching assets, actors, trust boundaries, credentials, callbacks, authorization, provider mutation, observability, or rollout gates to satisfy the relevant row with tests or a named implementation note. Catalog cutover requires a cleanup report/apply pass: bootstrap rows can be suspended from new authoring when unmatched, owner mismatches suspend new authoring until admin resolution, unresolved owner/fallback cases block cutover completion, and provider grants must not be revoked solely because a bootstrap row failed to match catalog. As of this entry, no router, tables, migrations, scheduled jobs, or driver have shipped yet; later DAX issues must update this map as those surfaces land.
>
> **Data access Service Account typed authoring (2026-08-16; provider gate updated 2026-08-24; 0 new migration / unchanged MCP count).** The employee request form and `POST /api/v1/data-access/requests/service-account-grants` accept a free-text GCP Service Account email; both enforce the canonical syntax, while `DataAccessService.record_service_account_principal` normalises and transactionally creates or reuses the DAX principal. There is no owner-scoped SA selector endpoint, catalog admission, technical-owner requester check, on-behalf operation, or direct GCP lookup. Service Account and personal requests consume the same `data_access_resources` dataset catalog and `read:v1` entitlement; legacy Group records retain their historical snapshots without a new-submit path. The SA approval definition now goes directly to dynamic selected-resource owners (2026-09-07); no manager or technical-owner approval node remains in the deployable SA artifact. On approval, the callback validates only the recorded principal and immutable request/resource snapshots before compiling exact `serviceAccount:<email>` / BigQuery `userByEmail` effects; it never writes BigQuery. The reconciler is still the sole provider writer. SA effects require integration apply mode, SA apply mode, an approved active binding and a current active/requestable/owner-resolved resource row with matching locator (principal allowlist retired 2026-09-07); the former exact dataset config allowlist is superseded and removed. `data_access_service_account_profiles` / `service_account_catalog_feed` are legacy observed metadata only: catalog owner, omission, or lifecycle state cannot gate typed authoring or callback compilation. Runtime never authors Feishu templates: the production DAX deploy workflow selects from a closed Group/SA definition set, stores each approval code in its own SSM parameter, and upserts the matching `integration_approval_flows` key.

> **Data access personal employee request (2026-08-19; approver chain updated 2026-08-24; 0 new migration).** The default 个人 tab and `POST /api/v1/data-access/requests/personal-grants` accept dataset ids/reason/idempotency metadata only: Helix derives the target from the authenticated active employee UUID plus normalized current work-email snapshot and persists `google_user` as a DAX principal. Thus any active employee can browse the same requestable dataset catalog for their own request, without becoming a Google Group owner/member. The Group new-request tab, public submit route, and public submit schema are retired; legacy Group requests, callbacks, bindings, effects, revokes, and read-only access history remain supported so existing authorization stays auditable and revocable. The separate `data_access_personal_grant` Feishu definition goes directly to the dynamic selected-resource-owner node; it has no requester-manager stage. Service Account definitions/mappings follow the same owner-only rule as of 2026-09-07. A successful callback compiles only exact `user:<work_email>` / BigQuery `userByEmail` dataViewer output, never a project grant, jobUser, credential, or direct provider call. Before each DAX apply cycle, identity departure or work-email drift converts active personal bindings to exact absent-effect revocations; the reconciler remains the only BigQuery writer, and the default-closed `personal_apply_enabled` flag must be explicitly enabled after staging validation. Runtime deploy supports a third closed DAX definition/key (`FEISHU_APPROVAL_DATA_ACCESS_PERSONAL_GRANT_CODE`) with exact SSM write permission.
>

> **Data access unified resource search (2026-08-26; 0 migration / 0 cron / 0 MCP).** `GET /api/v1/data-access/requestable-resources?search=...` remains the single request-authoring catalog read path, but now matches dataset project/id/display name/business description plus table id/description from two bounded projections: the deployed dbt lineage catalog and active managed table child resources. Filtering and count happen before pagination. Each dataset row may carry `matched_table_count` and up to 20 `matched_tables` summaries; the React request page renders these below the owning dataset and opens the existing schema drawer with the chosen table id prefilled. This read path never fans out `tables.list` across datasets and never persists discovered Provider rows. Uncataloged Provider tables remain discoverable only through the existing explicit, one-dataset drawer search, preserving the curated-first/Provider-on-demand decision and all existing requestability/owner/reconciler/IAM authority boundaries.
>

> **半年度全员访问自查·员工自证 (access self-attestation) · auditor 域 — PR1/3** (2026-06-28, migration 352 / +1 semiannual cron / +2 MCP→hint 376 / 0 新角色). 把"周期访问复核"从坐席-only 补成平台级,做成员工自证+批量+半年度安全产出。在既有访问认证引擎 [`access_certification_service.py`](../../server/app/domains/auditor/access_certification_service.py) 上加新 campaign 模式 **`self_attestation`**(自证人=持有人本人,`assigned_reviewer_person_id=person_id`;`decide_item` 显式拒绝自证项——必须走批量自助路径)。**全足迹 snapshot** `_snapshot_self_attestation`:一次性枚举每位**在职**员工(`employment_status='active'`)的五类访问——软件坐席 / 云授权 grant(`cmdb_cloud_access_grants`)/ 软件权限(`user_permissions`)/ SSO grant(`sso_grants`)/ API Key(`api_key_assignments`),逐(人,授权)一 `access_certification_items` 行(`access_type` ∈ software_seat/cloud_grant/user_permission/sso_grant/api_key,`raw_payload` 带 sensitivity/removable/granted_at/last_used_at);异构持有人列(Uuid vs String(36))用 `str(id)` 人字典映射规避 asyncmy 跨类型 JOIN 0 行陷阱;离职/无授权者不生成项。**批量自证** `submit_self_attestation`(keep=证据 / remove=我不需要了;校验全部属本人 + 活动 open;幂等跳过已决);remove 经协调器新函数 [`enqueue_self_service_removal_for_cert_item`](../../server/app/core/coordinators/access_certification_coordinator.py) 路由到各类型**现成自助撤销**语义——坐席=cancel-at-period-end(月初 cron 撤,标 `revocation_status='scheduled'`)、云授权=`CloudLifecycleService.revoke_grant(suppress_driver_error=True)` 即时(completed)、软件权限=`PermissionService.revoke_user_permission` 异步 provisioning task(scheduled);SSO grant=无干净自助撤销→`manual_required` 交 IT;API Key=仅确认不可撤(政策:不替员工承担密钥回收风险);失败兜底 `failed`,绝不丢自证决定。**绝不自动撤未响应者**(仅显式 remove 才动作)。**半年自动开盘** cron `auditor.access_self_attestation_auto_open_semiannual`(迁移 352 seed,每日 08:00 UTC;handler 在 auditor_handlers,worker+main 双注册):仅在**边界后 7 天窗口**内开盘(6/1→H1、12/1→H2),刻意**不追溯已过历史边界**(防中途上线全员推送风暴——存量风暴护栏);`open_self_attestation_campaign` 幂等(label `YYYY-H1/H2 全员访问自查自证`),开盘即 `notify_self_attestation_holders` 给每持有人**单条飞书卡+单条站内信**(绝不逐项),深链 `/me/access-review`;通知归 `CAT_SECURITY`(mutable=False=合规必达)。管理员有意识手动开:`POST /api/v1/compliance/certifications/self-attestation/open-now`(`can_manage_security`)。**自助面**:[`access_review_self_router.py`](../../server/app/domains/auditor/access_review_self_router.py)`GET/POST /api/v1/me/access-review{,/submit}`(self-scope,`submit_self_attestation` 强制 item 属本人故无额外 RBAC 门)。**MCP** +2:`get_my_access_review`/`submit_access_review`(self,mutate)。完成的活动 = SOC2 CC6.3 / ISO A.5.15 用户访问复核证据。**PR2 shipped**:员工批量页——桌面 [`AccessReviewPage`](../../web/src/pages/me/AccessReviewPage.tsx)(`/me/access-review`,KPI+一屏表+默认全保留/勾选清理+底部汇总提交+清理确认+友好空态)+ 移动 [`MAccessReviewPage`](../../web/src/mobile/pages/MAccessReviewPage.tsx)(`/m/access-review`,antd-mobile+MActionBar,飞书落地)+ 自隐藏 [`AccessReviewBanner`](../../web/src/components/AccessReviewBanner.tsx)(仅开盘挂「我的待办」顶,不加常驻菜单)+ 三语 i18n + PageHelp `my-access-review`。**PR3 shipped (+1 MCP→hint 377)**:治理面 + GRC + 半年产出——审计员汇总 [`build_self_attestation_summary`](../../server/app/domains/auditor/access_certification_service.py)(覆盖率/按决定·类型计数/标记清理处置队列[revocation_status]/未响应)+ `GET /compliance/certifications/{id}/self-attestation-summary`;半年 Word/PDF [`access_review_export.py`](../../server/app/domains/auditor/access_review_export.py)(复用 assessment_export 的 `_ensure_font`/`STSong-Light` CID + python-docx)+ `/export?fmt=docx|pdf`(StreamingResponse);GRC 控制项 `access_self_attestation`(compliance_catalog + crosswalk CC6.3/A.5.15/A.5.18,边 32→35;`_evaluate_controls` 读最新 self_attestation 活动);`escalate_overdue` 对 self_attestation 逾期项**按持有人去重**发飞书+站内信给持有人+经理(`_nudge_self_attestation_overdue`,非该类型行为不变);经理团队面 `build_team_attestation_status` + me_universe `GET /me/team/access-attestation`;管理员 `POST /compliance/certifications/self-attestation/open-now`;治理落地页 overview 增 `access_attestation` 覆盖率 tile;MCP `get_access_self_attestation_summary`(read)。前端:`CertificationDetailPage` self_attestation 概览+处置队列+导出按钮+隐藏逐项裁决列、`CertificationsPage`「立即开盘」(Popconfirm)、`SecurityGovernancePage` 覆盖率 tile、`TeamLifecyclePage` 自隐藏 `TeamAttestationCard`。**3-PR 主题全部交付。**

> **假期:请假事由同步 + 年假计算器内嵌优化 + 导出** (2026-06-23, migration 326 / 0 cron / 0 new MCP / 0 RBAC). 承接休假域(#1360)。**请假事由**:`TimeAccountDetail` 不带事由,事由/类型/审批在 SF `EmployeeTime`(请假单)上 → 新增 [`SuccessFactorsClient.list_employee_time`](../../server/app/identity/successfactors_client.py)(OR-batch + 两级 `$select` 回退 + 优雅降空),`sync_leave_data` 拉请假单按 (userId, `booking_date ∈ [start,end]`) **唯一匹配**回填;迁移 326 给 `hr_leave_postings` 加 `reason`/`leave_type`/`request_status`(nullable,无匹配即 NULL,多候选不匹配防误挂)。事由仅当 SF 里 `EmployeeTime.comment` 实际填了才有值;字段名防御式(prod SF 凭证当前不可解密以探 `EmployeeTime`,首次同步后确认)。**Bug 修**:`HrLeaveService.list_postings` 排序键混比 naive(DB 取回)与 aware 兜底 datetime → 账本抽屉 500(「加载假期记录失败」);键统一归一 naive。**年假计算器**:「假期余额与记录」卡改为**点「计算」后与结果一起显示**(默认收起);新增**导出图片**(`html-to-image` 抓结果+假期容器为 PNG)与**导出 Excel**(`GET /hr/leave/persons/{id}/export.xlsx`,走 `xlsx_export_response` 原语,账户+记录拍平单表,self/manager/HR scope)。
>
> **Assistant FY contract (2026-08-20 fix).** `get_my_leave_balances` additionally returns `leave_fiscal_year` and its naming basis for every account. The raw `fiscal_year` remains the SF account start year, but assistant answers must use the HR label derived from the leave-period end year: a 2025-04 grant that remains usable until 2026-09 is **FY26**, never FY25. This protects January–March joiner accounts whose raw start year differs from the grant cycle.

> **按人假期刷新审计原子性（2026-08-03，#2467）**：`sync_one_person_leave_now` 在单飞锁内通过调用方提供的 `before_commit` 写 HTTP/MCP 审计，将 SF 假期账户/流水与审计证据一次提交；Redis freshness 只在该提交成功后发布。审计失败时业务行不持久化、重试不被 freshness 错误阻断，非 SF skipped 尝试也保留同一审计契约。
>
> **年度报告生产级重做 + 足迹去重 + 对象存储员工视角死链修复** (2026-06-23, 0 migration / 0 cron / 0 MCP / 0 RBAC, 用户反馈整改). **年度报告叙事层**:[`build_my_wrapped`](../../server/app/domains/me_universe/wrapped.py) 在原 `WrappedResponse` 上加 `activity.active_days`(去重活跃天数 `COUNT(DISTINCT DATE(created_at))`)、`persona`(`{code,score}`——builder/guardian/social/collector 各维度归一化打分 argmax,`<0.15` 落 `steady`)、`scenes`(有序故事卡 `{id,emoji,value,headline_key,caption_key,params,accent}`;**tier 选择在后端**——登录按 active_days、操作按量级、安全按正确率,前端纯渲染零阈值分支)。`highlights` 字段保留供 [`wrapped_push.py`](../../server/app/domains/me_universe/wrapped_push.py) 年底飞书卡。[`WrappedPage`](../../web/src/pages/me/WrappedPage.tsx)(+`WrappedPage.module.css`)整页重做:hero + 年度关键词 + 场景序列 + 全量 recap(走全站唯一 KPI 原语 `StatCard`)+ **一键分享到动态广场**(客户端组装本地化贴文 → 复用 `feedApi.createPost`/`POST /feed/posts`,**非新流**故无新 MCP/无新端点/无迁移)。**我的足迹** [`FootprintPage`](../../web/src/pages/me/FootprintPage.tsx):`LoadoutView` 删除与「装备图鉴」重复的「在用装备」面板(同设备两现的根因),重排为 角色卡→里程碑时间线→`DeviceCollection`(唯一设备卡片区)→年度报告入口。**对象存储菜单门控**:`/my/cloud/object-storage` 后端 `can_read_cloud` 仅 admin 档(OSS 无 owner_person_id 不可按员工收窄),[MainLayout](../../web/src/layouts/MainLayout.tsx) 该叶子改读视角收窄后的 `effectiveUser.roles`(非原始 `user.roles`)——admin 切员工视角(`X-View-Role: employee`)时不再露出会 403 的死链(`effectiveUser` 既有约定,同 audit 菜单)。**侧栏版本号**收窄(去边框、11px、降透明度)。
>
> **安全治理中心统一落地页 · auditor 域** (2026-06-23, 0 migration / 0 cron / +1 MCP→hint 344 / 0 新角色, 等保自查与自评估体系 PR4 收官). 把「等保、认证、审计」三条线汇到一屏。**后端** [`governance_router.py`](../../server/app/domains/auditor/governance_router.py) 只读聚合 `GET /api/v1/auditor/governance/overview`:周期自检健康(self_check_service.get_overview 的 summary)+ 当期自评估(assessment_service.list_cycles[0])+ 在办整改措施数(非 done 且未归档)+ GRC 控制 by_status(compliance_service.get_posture,best-effort try/except 不阻塞落地页)。纯 projection 无新表。**前端** [`SecurityGovernancePage`](../../web/src/pages/auditor/SecurityGovernancePage.tsx)(`/auditor/governance`,菜单置 auditorItems 顶部作体系入口):4 张可点击 `StatCard`(自检完成/逾期、在办措施、控制达标)+ 当期评估卡 + 三个深链卡(自检/自评估/合规总览)。**help center** +3 admin 主题(`auditor-governance-center` / `auditor-self-check` / `auditor-self-assessment`,与各页 `PageHelp` helpId 对齐——index + `ADMIN_TOPIC_PARTS` body fn + 三语 i18n)。**MCP** +1:`get_security_governance_overview`(read)。RBAC `can_view_security`。**至此「等保自查与安全自评估体系」4-PR 全交付:周期自检台账(PR1,迁移 322)→ 自评估数据底座(PR2,迁移 323)→ AI 起草 + Word/PDF 产出(PR3)→ 治理中心收口(PR4)。**
>
> **信息安全自评估·AI 起草 + Word/PDF 导出 + 前端编辑器 · auditor 域** (2026-06-23, 0 migration / +1 MCP→hint 343 / +1 dep python-docx / 0 新角色, 体系第二支柱 PR3). 数据底座(PR2)之上的产出层。**AI 起草** [`assessment_ai.py`](../../server/app/domains/auditor/assessment_ai.py):`draft_cycle` 逐章把自动数据喂百炼/Qwen(`chat_json`)产出「评估描述 + 评估结论」初稿(复用平台 `ai_spending.llm.{api_key,model,region}` PlatformSetting key,无 key→`AppException(BAILIAN_LLM_NO_KEY,503)`);`_overall_from_sections` 确定性 roll-up(non_compliant > partial > 未评估视作 partial > compliant)。**导出** [`assessment_export.py`](../../server/app/domains/auditor/assessment_export.py):Word(`python-docx`,新依赖)+ PDF(reportlab 内置 `STSong-Light` `UnicodeCIDFont` 渲染中文、**免 TTF 打包**,首注册全局缓存),`_collect` 把章节树编号化为渲染模型(top→`N`,child→`N.M`)+ 整改措施附录;同步 `BytesIO` 流式下载(`StreamingResponse`,docx/pdf media type),报告体量小故不用 evidence-pack 异步队列。**上期对比** `compare_previous`(按章节 `conclusion` diff `period_key` 降序的上一期)。**前端** [`SelfAssessmentPage`](../../web/src/pages/auditor/SelfAssessmentPage.tsx)(`/auditor/self-assessment`,菜单在 auditorItems):列表 ↔ 编辑器(`StatCard` 概览 + 章节 `Collapse`:自动数据表 + 叙述 `TextArea` + 结论 `Select` + 行内刷新取数;工具条 AI 起草全部/刷新/导出 Word·PDF/与上期对比/定稿·重开;整改措施 CRUD Modal);导出走 `downloadExport`(blob + CD 文件名)。**MCP** +1:`draft_assessment_narratives`(mutate)。RBAC 全程 `can_view_security`/`can_manage_security`。**「等保自查与安全自评估」体系 PR1-3 交付,余 PR4 收口(统一安全治理中心落地页 + help + i18n)。**
>
> **信息安全自评估·数据底座 · auditor 域** (2026-06-23, migration 323 / +1 semiannual cron / +4 MCP→hint 342 / 0 新角色, PR2/3 之 PR2). 「等保、认证、审计」体系第二支柱(半年度 ISMS 报告)的数据底座,无前端(编辑器/AI/导出在 PR3)。**模板在代码** [`assessment_catalog.py`](../../server/app/domains/auditor/assessment_catalog.py)(单一真源,同 compliance_catalog/gamification 模式):21 章节 ISMS 树(引言/管理层承诺/资产管理/人力资源/物理环境/通信运营/访问控制/系统开发维护/事件管理/业务连续性/**周期自检执行**/结论 + 子项),每章声明 `has_table`/`table_columns`/`auto_source`/`conclusion_applicable`。**3 表**(迁移 323):`auditor_assessment_cycles`(每半年一份,`period_key` 如 `2026-H1` 唯一;status draft/finalized;overall_conclusion)、`auditor_assessment_sections`(实例化章节:`narrative` + `conclusion` compliant/partial/non_compliant/not_assessed + `data_table`(可编辑)+ `auto_data`(聚合快照)+ `auto_source` + `control_key` 交叉引用 + `ai_drafted`;`(cycle_id,section_key)` 唯一)、`auditor_assessment_measures`(常青整改/措施跟踪)。person 列**不对 persons 建 FK**(TiDB-safe),cycle→section 真 FK。**真值聚合** [`assessment_data.py`](../../server/app/domains/auditor/assessment_data.py)(13 个 `AGGREGATORS`:asset/device 清册、headcount、offboarding、rbac_access、mfa_coverage、access_review、exposure、change_mgmt、secret_rotation、incident、cloud、**self_check_summary 打通支柱一**)——点时快照 vs 期内流量分流,空租户不崩(summary 兜底)。**生命周期** [`assessment_service`](../../server/app/domains/auditor/assessment_service.py):`create_cycle`(实例化全章节 + 跑聚合器填表;period_key 缺省=`period_just_ended`;幂等)→ `update_section`/`refresh_section_auto_data`/`refresh_all_auto_data` → `finalize_cycle`(锁定,编辑前需 reopen)。半年 cron `auditor.assessment_cycle_opener_semiannual`(1/1+7/1;handler 在 auditor_handlers,worker+main 已双注册)。章节 `control_key` 经 `CONTROL_BY_SECTION` 接 GRC ComplianceControl。**MCP** +4:`list_assessment_cycles`/`get_assessment_cycle`/`list_security_measures`(read)+ `create_assessment_cycle`(mutate)。RBAC 全程 `can_view_security`/`can_manage_security`。
>
> **周期性自我检查(等保自查台账)· auditor 域** (2026-06-22, migration 322 / +1 monthly cron / +3 MCP→hint 338 / 0 新角色). 「等保、认证、审计」体系第一支柱。**3 表**(都在 [`auditor/models.py`](../../server/app/domains/auditor/models.py)):`auditor_self_check_categories`(可配检查类目目录:`periodicity` monthly/quarterly/semiannual/annual + `check_item` + `default_checker_person_id` + `requires_attachment`)、`auditor_self_check_records`(每 (类目,周期) 一条台账,`(category_id,period_year,period_label)` 唯一→幂等;`status` pending/completed/skipped + `outcome` normal/issue_found/not_applicable + `check_item_snapshot` 冻结历史)、`auditor_self_check_attachments`(证据 S3 object key)。person 列**不对 identity_persons 建 FK**(TiDB-FK-safe,沿用 gamification/wrapped 约定),域内父引用用真 FK(同 charset)。迁移 322 seed 6 个历史类目(均季度)。**周期引擎** [`self_check_service.open_due_records`](../../server/app/domains/auditor/self_check_service.py) + cron `auditor.self_check_opener_monthly`(每月 1 号 01:00 UTC;季/半年/年边界都落月初故单月节律全覆盖;handler 在 `auditor_handlers.py`,该模块已在 worker+main 双注册)。**证据**复用 [`core/storage`](../../server/app/core/storage.py) presign(域内 `can_manage_security`,限图片+PDF,key 前缀 `self_check_evidence/`,注册时校验 object_key 属本记录),不挤占员工 `/api/v1/uploads` 面。**GRC 接入**:`compliance_catalog` 新增控制项 `periodic_self_check`(`signal_type=self_check`、无新鲜度门控,仿 `access_certification`;映射 SOC2 CC7.1/CC7.2 + ISO A.8.16/A.8.8),`compliance_service._evaluate_controls` 按"逾期未检查"判 pass/fail 汇入持续控制监控(crosswalk 边 28→32)。**导出** xlsx(`xlsx_export_response`,列对齐旧 wiki 台账)。**前端** [`SelfCheckPage`](../../web/src/pages/auditor/SelfCheckPage.tsx)(路由 `/auditor/self-check`,菜单在 auditorItems):概览统计卡 + 类目目录(增删改 Modal)+ 台账表(完成/跳过/重开 + 证据抽屉 presign 上传)。**MCP** +3:`get_self_check_overview`/`list_self_check_records`(read,`_require_security_read`)+ `open_self_check_records`(mutate)。全程 `can_view_security`/`can_manage_security`,不进员工/经理视图。**这是「安全自评估」体系(半年正式报告,在后续 PR)的配套留痕层。**
>
> **暗色「全站封装」整改 —— 收敛到唯一 KPI 原语 + 主题变量铺满** (2026-06-23, 0 migration / 0 MCP / 0 RBAC, 用户反馈整改). 把上一轮逐页打补丁的暗色改成系统性、**用既存封装原语收敛**:① 6 路并行扫 **45 文件 ~93 处** chrome 硬编码(灰字/浅边框/白底)→ `--helix-*` 主题变量(保留彩色底白字 / SVG 美术 / antd-var 回退 / 语义状态色);② 一类**暗色下文字不可见**的 pastel KPI 卡(硬编码浅渐变底+浅边框+深字:IAM Users 四卡 / 云·运营分析 hero / 入离职生命周期 / 同期 / 采购总览…)**13 文件 18+ 处**统一套 `components/StatCard.tsx` 同款主题安全公式(`withAlpha(COLOR.<tone>,0.1)→var(--helix-bg-container)` tint + `STAT_TONE.<tone>.fg` 值色 + `TEXT.*` 标签,按色相映射 tone);③ 年度报告 11 卡 + 装备图鉴汇总**改用既存唯一 KPI 组件 `StatCard`**(过程中误建的 `StatTile` 已识别为近似重复并删);④ `AiSpendingCockpit.css` 去补丁:`--asp-slate-*` ramp 表面/浅文字档直接引用全局变量(亮色值相等→亮色不变),删 4 条 per-element 暗色覆盖。**原则**:加新页 KPI 一律 `StatCard` + tone,自定义卡片底用 `withAlpha(COLOR.x, .1)→var(--helix-bg-container)`,永不硬编码浅色渐变底。Preview 暗色实截 5 页验收。详见 L15 主题基础设施 + L67 StatCard 原语。
>
> **个人页三合一 + 暗色整体美化 + 年度报告丰富** (2026-06-22, 0 migration / 0 MCP / 0 RBAC, 用户反馈整改). **三合一**:「装备图鉴」+「我的装备墙」并入「我的 IT 足迹」`/me/footprint`(`FootprintPage` = `LoadoutView` + 年度报告入口 + `DeviceCollection`[主题感知重写] + 里程碑时间线)。**`/me/devices`、`/me/loadout` 现重定向到 `/me/footprint`**;头像菜单只剩「我的 IT 足迹」;**已撤掉 `/me` 暗色→装备墙绑定**(`MyItUniversePage` 恒经典);`LoadoutPage`/`DeviceGalleryPage` 已删(内容组件 `LoadoutView`/`DeviceCollection` 复用)。**暗色长尾系统扫除**:普通全局 `.css`(`AiSpendingCockpit.css` 加 `[data-theme=dark]` 覆盖块)、TSX inline 白底/浅渐变(受控扫 40 文件,排除 27 图表文件)、`Markdown.css`、申请分类卡均主题化;彩色 hero 上的白光晕叠加有意保留。**年度报告丰富**:`build_my_wrapped` 加活动维度(登录 audit `auth.%login%` / 平台总操作 audit 全量 / feed 发帖·评论·点赞),共 11 张统计卡;`/me/wrapped/:year` 常驻可访问(非推送门控)。
>
> **年度报告 (Helix Wrapped) + 年底护栏推送** (2026-06-22, migration 320 仅 seed cron / +1 yearly cron / +1 MCP→hint 335 / 0 RBAC). [`me_universe/wrapped.py`](../../server/app/domains/me_universe/wrapped.py) `build_my_wrapped(db,person,year)`——按年聚合工龄/安全答题/新领设备/工单/徽章 + i18n highlights;self-scoped(无 IDOR),不碰 org_memberships 故可直调。`GET /me/wrapped/{year}`,前端 `WrappedPage`(`/me/wrapped/:year`)+ dashboard 限时入口卡(12/20–1/31 窗口,过期不留死链)。**年底推送是唯一刻意的员工面推送例外**(标准规矩=不给员工加推送),重护栏 [`wrapped_push.py`](../../server/app/domains/me_universe/wrapped_push.py):一年一次幂等(`PlatformSetting` 标记 `wrapped.year_end_push.<year>`)+ opt-out 偏好 `appearance.wrapped_push_opt_out`(JSON-bool 在 Python 过滤避两库劈叉)+ 每轮上限 + 逐人隔离;仅飞书、深链 `/me/wrapped/<year>`。cron `wrapped.year_end_push`(migration 320 seed,12/31;无表,report 只读 + opt-out 走 KV;handler 在 worker+main 双注册)。MCP `get_my_wrapped`(read)。
>
> **成就徽章 · `gamification` 域** (2026-06-22, migration 319 / +1 daily cron / +1 MCP→hint 334 / 0 RBAC). 新域 [`app/domains/gamification`](../../server/app/domains/gamification)。**徽章目录在代码**([`catalog.py`](../../server/app/domains/gamification/catalog.py):9 枚 `code/icon/category/predicate`)——解锁标准是代码逻辑,定义表存不下;只持久化解锁 `gamification_unlocks`(migration 319,无 FK,(person_id,badge_code) 唯一→幂等;**UUID 列类型 2026-06-28 迁移 353 修正**:319 模型用裸 `Mapped[uuid.UUID]`→ stock `Uuid`,在 MySQL/TiDB 上绑去横杠 32 字符,与全平台横杠 CHAR(36) 劈叉——读写自洽(徽章墙正常)但跨表字符串 join `person_id` 静默 0 行;改用显式 `GUID` 类型 + 迁移 353 把存量 695 行的 `id`/`person_id` 重新加横杠[[reference_asyncmy_uuid_bind]],对齐 secquiz/identity_persons)。[`service.py`](../../server/app/domains/gamification/service.py):`_compute_facts`(轻量索引列:工龄/连胜/答对/云授权/设备数,**不碰 org_memberships** 故可直调)→ `evaluate_and_unlock`(幂等)→ `get_my_badges`(**读时懒评估**,新达标立即出现)。`GET /me/badges`(self)。每日 cron `gamification.scan_unlocks`(逐人 SAVEPOINT 隔离,全员失败才 raise;handler 在 `worker.py`+`main.py` 双注册)。**纯 pull、零打扰**:不发推送、不点 actionable 红点;徽章墙(设置「成就」Tab)+ 新解锁前端撒花(localStorage seen-set 每枚庆祝一次)即全部"站内通知"。MCP `get_my_badges`(read,懒持久化→mutates)。**加新徽章** = 在 catalog 追加 spec + i18n。 **全面升级 (2026-08-09, 0 migration / 0 新 cron / +1 MCP→hint 451)**:徽章 **13→39 枚 / 5→9 分类**(同行时光·装备·软件与工具·云端·安全·合规·自助与审批·社区与互助·放松一下)。`Badge` spec 增加 `tier`(普通/稀有/史诗/传说,纯展示)+ `metric`+`threshold`,**单枚进度由此免费得出**(「连胜 5/7 天」),非计数型徽章仍走显式 `predicate` 且不报进度;**已解锁徽章恒读 100%**(cloud_grants/seats 这类计数会降)。`validate_catalog()` import 时硬校验(重复 code / 未知 tier / 未知分类 / 无 predicate 亦无 metric+threshold / **metric 不在 `FACT_KEYS`**)。事实计算拆出 [`facts.py`](../../server/app/domains/gamification/facts.py) 且**全部集合式**(`person_id IN (…) GROUP BY person_id`),单人读路径传一元素集合走同一份代码;每日 cron 改为 **100 人一批**批量算事实 + 分批 commit(500 人 ~9000 次查询 → 固定十几次,事务不再横跨全公司)。**person 列类型不统一是本域硬约束**:cmdb 云授权 / feed 全表 / ai_tool_applications 的 person 列是 `String(36)` 而非 GUID,`_str_ids`/`_as_uuid` 强制每个调用点表态 —— 旧代码在此处漏了 `str()`,导致 **`cloud_explorer` 全公司零人可解锁**(静默 0 行,无报错),`me_universe/loadout.py` 同源 bug 一并修;`devices_ever` 补齐 `transfer`/`force_transfer` 两个交接拼写。徽章数据源以「模型里真的有这一列」为准:`approval_steps.approver_id` **从不写入**(审批在飞书侧决策)故审批类徽章 union `EmployeeRequest.processed_by` 等四张表、阈值 10/50;`platform_policy_notices` 无 per-person 确认表,故「政策已读」砍掉、改用 `onboarding_acknowledgements` 的「入职就绪」。解锁率分母复用 arcade 排行榜宽口径(`deleted_at IS NULL AND employment_status != 'left'`)以免同系列两个页面报不同百分比。**新增聚合读** `GET /gamification/admin/stats` (global_admin/management;逐枚持有率 + 参与率 + **无人解锁清单**;**刻意无点名排行** —— 徽章墙是私人零压力面)+ MCP `get_achievement_stats`;管理端并入 `/admin/arcade` 新 tab(页面更名「员工体验 · 运营看板」)。前端从设置 tab 提为独立页 `/me/achievements` + 移动 `/m/achievements` + 应用中心磁贴,设置 tab 与页面共用同一个 `AchievementWall` 组件;`loadout` 的 `achievements_preview` 由**占位的足迹里程碑**改接真实解锁表。纯 pull / 零推送不变。
>
> **安全段位擂台 · `security_quiz` 段位 + 部门擂台** (2026-06-22, migration 318 / +1 weekly cron / +1 MCP→hint 333 / 0 RBAC). 把安全每日一题升级成段位 + 团队荣誉。**段位单一真源** [`security_quiz/ranks.py`](../../server/app/domains/security_quiz/ranks.py)(按 lifetime 答对数:0/1/5/15/30/60/100 → 未定级/青铜/白银/黄金/铂金/钻石/王者,`rank_for` + `progress`)——M2 装备墙的段位 chip 委托此处(`loadout._rank_tier`→`ranks.rank_for`),不劈叉。`GET /security-quiz/rank`(self)。**部门擂台** [`team_battle.py`](../../server/app/domains/security_quiz/team_battle.py)`compute_department_standings`:按答题人**主部门**(`identity_org_memberships` primary+active,无主部门→"—"桶)聚合参与率/正确率,按参与率→正确率排名;`GET /security-quiz/team-battle`(任意登录用户)。**周快照** 新表 `secquiz_team_snapshots`(migration 318,无 FK,(week_start,department) 唯一)+ 周一 cron `secquiz.team_battle_snapshot` 冻结上周排名(读路径无快照回落实时,(week,dept) 幂等)。MCP `get_security_team_battle`(read)。前端 `SecurityQuizPage` 加段位卡 + 部门擂台卡。
>
> **我的装备墙 (RPG 角色卡) · `me_universe/loadout`** (2026-06-22, 0 migration / +1 MCP→hint 332 / 0 RBAC). `GET /api/v1/me/loadout`([loadout.py](../../server/app/domains/me_universe/loadout.py) `build_my_loadout`,self-scoped 无 target → 无 IDOR)把个人 IT 主页组成 RPG 角色卡:装备槽 / 5 轴属性雷达 / 背包 / 成就预览 / 安全段位 chip + level/power。**复用而非新建**——几乎全部由 `device_gallery`(稀有度)+ `footprint`(工龄/席位/权限/里程碑)+ `security_quiz.compute_stats`(连胜/正确数)组合,只额外查 `IdentityExternalIdentity.person_id`(账号)与 `CmdbCloudAccessGrant.person_id+status`(云授权)两个索引列补雷达轴。**装备槽源自实时 `AssetItem.assigned_to`**(非 gallery 轨迹)——直接 assign 的设备无 transfer/claim 轨迹事件,沿用轨迹会让在用设备装备槽空白;用 gallery card 富化 service_days/稀有度。**主题绑定**:`/me`(自己)暗色→装备墙、亮色→经典 `UserItUniverseTab`(`MyItUniversePage` 按 `useThemeStore.resolved` 切),`/me/:userId`(看他人)恒经典;另有常驻 `/me/loadout`(`LoadoutPage`)+ 头像入口。段位阈值(青铜→王者,按 lifetime quiz-correct)与 M3 段位擂台共用 `loadout._rank_tier`。MCP `get_my_loadout`(read)。
>
> **全站暗色模式 + 主题基础设施** (2026-06-22, 0 migration / 0 cron / 0 MCP / 0 RBAC). Helix 有了 light/dark/system 三态主题,**可复用机制**:① 颜色单一真源 [`web/src/theme/tokens.ts`](../../web/src/theme/tokens.ts) 的 `TEXT`/`SURFACE`/`STAT_TONE.fg` 解析为 **CSS 变量**(`var(--helix-*)`,值在 [`web/src/theme/themeVars.css`](../../web/src/theme/themeVars.css) 的 `:root` 亮 / `:root[data-theme=dark]` 暗两块),所以**新代码引用这些常量即自动双主题**——别再 inline slate/grey 字面量,也别对 `TEXT`/`SURFACE` 用 `withAlpha`(只吃 6 位 hex);`COLOR.*` 保留 hex。② 主题 store [`web/src/stores/theme.ts`](../../web/src/stores/theme.ts)(`useThemeStore`:`mode`/`resolved`/`festiveEnabled`):**后端偏好为权威源**(`appearance.theme_mode` via `/me/preferences`,跨设备),localStorage 仅防闪缓存,`main.tsx::bootstrapTheme()` 渲染前打 `<html data-theme>`,`system` 模式 `matchMedia` 实时跟随;dev 暴露 `window.__themeStore__`(同 `__authStore__`)。③ AntD 组件经 `App.tsx::buildTheme(resolved)` 在 `defaultAlgorithm`/`darkAlgorithm` 间切;顶栏三态切换器 + `MySettingsPage`「外观」Tab(`AppearancePrefsTab`,即时应用)。④ 偏好走现成 KV `identity_person_preferences`(无迁移):键 `appearance.theme_mode`(`Literal` 约束)/`appearance.festive_effects`(后者也是 `FestiveLayer` 节日皮肤开关的权威源,经 `localStorage['helix.festive.off']` 镜像)。**暗色长尾(2026-06-23 已系统扫除)**:chrome 硬编码(灰字/浅边框/白底)45 文件 + pastel KPI 卡 13 文件均已收敛到主题变量 / `StatCard` 公式(见顶部「全站封装」条目);echarts canvas 仍按设计不归 CSS 变量管。**加新页铁律**:KPI 用 `<StatCard tone=...>`;自定义卡片底用 `withAlpha(COLOR.x, .1)→var(--helix-bg-container)`,永不硬编码浅色渐变/白底/灰字字面量。
>
> **CLI 自助下载分发面 · `cli_dist` 域 + S3 发布管线** (2026-06-18, 0 migration / 0 cron / +1 MCP→hint 321 / 0 RBAC, PR2/3). 让员工自助拿到 `helix` CLI 的「分发」环。**发布管线** [`.github/workflows/cli-release.yml`](../../.github/workflows/cli-release.yml)(main 上动 `cli/**` 或手动触发):build wheel + 5 个 OS 的 PyInstaller 免-Python 单文件二进制(macOS arm64/Intel、Linux x86_64/arm64、Windows x86_64),`aws s3 cp` 到 uploads 桶**固定命名空间** `cli-dist/<version>/...` + `cli-dist/latest/manifest.json`(version/各平台 key/sha256/size),复用 image-release 同款 OIDC role(`vars.AWS_ROLE_ARN`)。**后端只读分发域** [`app/domains/cli_dist`](../../server/app/domains/cli_dist):`GET /api/v1/cli/manifest`(任意登录用户,读 manifest 投影出各平台元数据、**不暴露 S3 key**;无发布/存储未配优雅 `available=false` 不 500)+ `GET /api/v1/cli/download?platform=<key>`(校验 platform 白名单 → 返回短时效 presigned URL 的 **JSON**(信封 `data.url`)+ 审计一条 `cli.download`;**有意不用 302**——Web 端 JWT 由 axios 拦截器挂在 header、内存态非 cookie,浏览器对重定向的导航不会带上;前端 `window.location = data.url`,脚本/MCP `jq -r .data.url`)。**新增可复用存储原语** [`storage.get_object_bytes`](../../server/app/core/storage.py)(`presign_get` 的服务端读对偶,取**绝对 key**、NoSuchKey→None;`cli-dist/` 走固定命名空间、不吃 per-env `dev/` 前缀——CLI 二进制各环境一致,无隔离需求)。MCP 平价工具 `cli_get_download_manifest`(只读)。**运维依赖(部署时确认)**:OIDC role 需对 `<uploads-bucket>/cli-dist/*` 有 `s3:PutObject`;新增 repo 变量 `vars.CLI_DIST_S3_BUCKET`=uploads 桶名。前端自助下载卡在 PR3。
>
> **Claude Code 插件 `helix@helix` · plugin-dist 分发面** (2026-07-09, 0 migration / 0 cron / +1 MCP→hint 407 / 0 RBAC, AI 接入体系 PR1). MCP / CLI 之外的第三件套:员工把「Helix MCP 连接 + skills + slash commands」一条命令装进 Claude Code。**插件源码** [`plugin/marketplace/`](../../plugin/marketplace/)(marketplace `helix` → 插件 `helix`;`.mcp.json` **零凭证**,首用走 MCP OAuth 发现浏览器 SSO,`HELIX_MCP_URL` 覆盖本地 dev;5 skills + 5 commands,工具名与 `tools.py` 实名对齐)。**发布管线** [`plugin-release.yml`](../../.github/workflows/plugin-release.yml)(镜像 cli-release):结构校验 → zip(固定根 `helix-claude-plugin/`)→ `plugin-dist/<ver>+g<sha8>/` 不可变 + `plugin-dist/latest/manifest.json`;同 uploads 桶同 OIDC role(**role 需 `plugin-dist/*` `s3:PutObject`**)。**后端**:`cli_dist` 域顺延(不另立域,service 泛化 manifest-key/labels 参数),`GET /api/v1/plugin/manifest` + `GET /api/v1/plugin/download`(presigned JSON 信封,审计 `plugin.download`,语义同 CLI 下载)+ MCP `plugin_get_download_manifest`。**CLI 安装器** `helix plugin {install,update,status}`([`cli/helix_cli/plugin_dist.py`](../../cli/helix_cli/plugin_dist.py)):sha256+50MB 上限校验、zip-slip/symlink/异根防护、原子换入 `~/.helix/claude-plugin/` 固定路径、best-effort `claude` 注册(首装 add+install / 更新 marketplace update / 无 claude 打印手动命令)。**为什么 zip 而非 URL-marketplace/git**:URL 形态不解析相对路径插件源、外源需 git/npm(员工无权限);**为什么无 MCPB**:仅本地 stdio,劣于 OAuth Connector——两决策见 DECISIONS 2026-07-09。**前端自助面 (PR2, 2026-07-09)**:/help AI 集成 Tab 新增「我该用哪条路?」六路径选择矩阵(`ai-choose`,Tab 首位)+「Claude Code 插件」主题(`ai-claude-code`,含 `PluginDownloadCard` 下载卡)+ 站点根 [`/llms.txt`](../../web/public/llms.txt)(AI 可读平台自述);三语齐全。**MCP 满配 (PR3, 2026-07-09)**:[`app/mcp/annotations.py`](../../server/app/mcp/annotations.py) 启动时 AST 解析 tools.py 从 `tool_runtime(mutates=…)` 推导 ToolAnnotations 盖满 407 工具(危险子集与 CLI `_DESTRUCTIVE_MCP_TOOLS` 测试锁等,委托模式跟 3 跳,条件写保守按写);[`resources.py`](../../server/app/mcp/resources.py) `helix://platform/{overview,tool-catalog}`;[`prompts.py`](../../server/app/mcp/prompts.py) 官方剧本 ×5(插件斜杠命令的多端对应物)。**新增 MCP 工具时无需碰注解**——mutates 标即事实源;新增危险工具须同时进 `DESTRUCTIVE_TOOLS` 与 CLI 清单(测试会拦)。
>
> **CLI 版本/完整性安全 · 服务端最低版本 kill-switch** (2026-06-19, 0 migration / 0 cron / 0 MCP / +1 setting). 防止旧的(可能有 bug/漏洞的)`helix` CLI 继续访问。**设置** `CLI_MIN_SUPPORTED_VERSION`(`app/core/config.py`,默认 `0.0.0`=不拦)。**强制点** = MCP 边缘中间件 `app/mcp/middleware.py`(CLI 全部命令都过 MCP):解析 `User-Agent: helix-cli/<X.Y.Z>`,低于下限 → **426 Upgrade Required**(鉴权/DB 前就拒,旧客户端绕不过;`_cli_version_below_min`/`_release_tuple` 忽略 PEP440 local 段;只认 helix-cli UA,其它 MCP 客户端不受影响)。**升级 kill-switch 用法**:发了修复版后,把 SSM `CLI_MIN_SUPPORTED_VERSION` 提到修复版本并重启 → 全员旧 CLI 立即被拦并提示升级。**客户端**:`helix_cli.__version__`(importlib.metadata,发布戳 `+g<sha>`,二进制 `--copy-metadata`)进 `User-Agent`;`helix version` 命令 + 普通命令前的被动 nudge(24h 缓存,best-effort)在硬 426 前先提醒。manifest(`/api/v1/cli/manifest`)新增 `min_supported`,per-平台 `sha256` 供完整性校验(下载卡展示)。
>
> **官方 `helix` CLI 拆为独立分发包(repo-root `cli/`,只依赖 httpx)** (2026-06-18, 0 migration / 0 cron / 0 MCP / 0 RBAC, PR1/3). CLI 原本内嵌在后端 `server/app/cli/`、以 `[project.scripts] helix = "app.cli.main:main"` 注册。它本就是 MCP Streamable-HTTP + health/ready 探针之上的**瘦客户端**(零 `app.*` 耦合、MCP 协议在 `mcp_client.py` 手写、不引 `mcp` SDK,仅依赖 httpx)。**已整树 `git mv` 到仓库根 `cli/`**(包名 `helix_cli`,`from app.cli.*`→`from helix_cli.*`;测试 `cli/tests/`;入口 `helix = "helix_cli.main:main"` + `python -m helix_cli`)。目的:能作为 **wheel(`pipx install`)+ 各 OS 单文件二进制** 独立发给员工,而不拖整个后端依赖(PR2 建发布管线,PR3 网页自助下载)。**后端镜像不再内置 `helix` 命令**(员工本就进不了容器;IT/运维在服务器上 `pipx install` 即可)——换来零 Docker context / 零 uv workspace 改动。CI 新增独立 `cli` job(无 MySQL,几秒级:`uv pip install -e .[dev]` → ruff → smoke `--help` → pytest 114 passed);原 `identity-cli` 分片去掉 `tests/test_cli`(改名 `identity-sso`)。`_DESTRUCTIVE_MCP_TOOLS` ⊆ 注册 MCP 工具的 typo-proof 回归测试改读 sibling `server/app/mcp/tools.py`(全仓 checkout 时仍强制,独立 checkout 优雅 skip)。ADR 见 DECISIONS.md(2026-06-18)。
>
> **INVARIANT — 纳管 API Key 的 owner 唯一真源 = `api_key_assignments.assigned_to`;`bailian_api_keys.owner_person_id` 是它的派生镜像** (2026-06-17, 0 migration). 一把纳管的百炼 console key 两边各存一份 owner(注册表 `assigned_to` 驱动成本归属 / 云端 IAM 标签 / 共享成员 / 离职强制转让;百炼页显示 `bailian_api_keys.owner_person_id`,`assignment_id` 回链)。**所有 owner 变更都必须让两者一致**:① [`ApiKeyService.apply_transfer`](../../server/app/domains/apikey/service.py)(自助接受 / admin 直转 / **离职 T+0 强制转让** `hr_lifecycle/advance_service._handle_one_blocker`)翻 `assigned_to` 后,同步所有 `assignment_id` 指向它的 `bailian_api_keys.owner_person_id`(`detail.last_transfer.bailian_owner_synced` 记数);② 百炼侧 [`BailianService.reassign_api_key_owner`](../../server/app/domains/bailian/service.py) 改已纳管 key 的负责人时,经新 coordinator [`app/core/coordinators/apikey_owner_coordinator.py`](../../server/app/core/coordinators/apikey_owner_coordinator.py) 路由到 `apply_transfer`(bailian 域不 import apikey),成本规则 / IAM 标签 / 成员 / 注册表全搬;清空已纳管 key 的负责人被拒(会孤立 NOT NULL 的 `assigned_to`);未纳管 key(`assignment_id` NULL)仍只翻本地镜像。中央注册表页有 admin 直转入口,「我的 API Key」列表 + 详情页有自助转交。
>
> **AWS Bedrock API Key「按 profile / 用途(UsedBy 标签)」花费拆分** (2026-06-17, migration 298 / 复用 cost_facts cron / 0 MCP / 0 RBAC). 在 per-AK「API Key 花费详情」页给 AWS Bedrock 的 Key 加一个**按 profile 维度**的拆分——profile = AWS 上已激活的 `UsedBy` 成本分配标签(把同一 owner 的 Bedrock 花费再细分到 `investibook-dev` / `aiops` / … 等工作负载)。**新表(隔离式、无 FK、`String(36)` integration_id)** `cmdb_bedrock_profile_facts`,grain=`(date × owner × profile × service)`。**为何独立成表而不进 `cmdb_cost_facts`**:CE 的 GroupBy 上限 2 维、`cmdb_cost_facts` 已用 `SERVICE×iamPrincipal/Owner` 两维;且该表被大盘/账单抽屉/per-AK 总额/by-model 多处 SUM、入库路径久经 ~5 起生产事故调优——加细粒度行要么丢维要么污染总额,故隔离存放,**对所有现有聚合零影响**(与 #1197 的 fact_key 求和修复正交:本表自然键含 service、独立 ON DUP)。**入库** = AWS 驱动 `list_bedrock_profile_facts`(先发现 owner 列表,再 per-owner Filter+GroupBy `[UsedBy, SERVICE]` 扇出几次廉价 CE 调用)→ `cost_service.sync_bedrock_profile_facts_for_integration`(幂等 per-(scope,date) 预删 + ON DUP KEY 写入,服务侧用同一 AI 白名单服务集裁到 Bedrock 与 `mtd_usd` 同口径)**挂在既有 `cmdb.cost_facts_sync` cron 同窗口内**(同步水位线对齐 → by-profile 总额必与页面 `mtd_usd` 同口径),非致命失败不拖垮主成本同步;仅 `cost_facts.aws_profile_tag.<slug>` 配置的 AWS 集成生效(migration 298 种 `aws-sg=UsedBy`)。**读** = `/assignments/{id}/spend/detail` 新增 `by_profile`(Bedrock-only,按 owner 过滤读隔离表,大小写归一合并 `aiops`/`AIOPS`,带 per-profile 模型下钻;未配置/未同步→空数组,前端隐藏)。前端详情页新增「按 profile / 用途拆分」可展开表。 **2026-07-29 扩展(migration 435)**:同表新增 **per-Key 轴** `profile_tag_key='iamPrincipal/helix-request-number'`,`spend.py` 的 Bedrock 分支在 #1776 canonical 钉板**之前**先查该轴 —— 本窗口有行就用它出数(MTD/日曲线/模型表/6 月序列同源 `_per_key_where`),caveat 码 `bedrock_per_key_tag[_shared]`;闸门是「本窗口有行」而非「设置已配」,激活前的历史窗口继续走 Owner 钉板,不会把 $0 当真数据。per-Key 轴被显式排除在 `by_profile` 之外(否则每把 Key 冒充一个 workload、by-profile 合计翻倍)。 **2026-07-30 修正(#2102,0 迁移)**:per-Key 轴下 `by_profile` 原本返回**空数组**(理由:持有人级的行加不回 per-key 头条)——算术正确但产品判断错了:SG 全量 IAM 打标后**每把 Key 都落 per-key 轴**,工作负载视图就从产品里整体消失,而数据仍在照常入库。改为**照常返回、显式标注**:新增 `by_profile_scope`(`self`=行合计等于本页 `mtd_usd`;`holder`=行拆的是**持有人**的 Bedrock 月总额,与头条相加即重复计算)、`by_profile_total_usd`(占比所用的分母,holder 口径下取持有人总额而非头条,否则份额可能 >100%)、`attribution_axis`(`owner`/`per_key`,让前端把 Owner 轴那唯一一行 `(uncategorised)` 解释成**轴没有模型维度**而不是丢数据)。同时:`usage_unit` 为 AWS 字面量 `"N/A"`(该组混合 输入token/输出token/请求次数)的行不再输出 `effective_unit_price_usd` —— 花费 ÷ 跨口径用量之和是个没有单位的费率(生产曾显示 `$0.8473`)。 **2026-07-31 修正(#2225/#2226/#2239,0 迁移)**:两轴的**并集不变量**——「canonical + siblings == Owner 总额,恰好一次」——此前有两处不成立。(a) 兄弟扣减遍历 `payload["daily"]`,而本模块所有 builder 产的是 **`daily_series`**,逐日扣减因此从未执行:头条扣了、sparkline 没扣,同一页两个答案;`monthly_series` 更是完全没接扣减(它在头条扣减之后才构建,现在有自己那一趟按月分组的兄弟总额查询)。(b) `_bedrock_owner_canonical` 从**全部**同 owner key 里选最早创建者,但 per-key 轴的 key 永远走早退路径、永远不替 Owner 总额答数 —— 它一旦最早创建就赢了 canonical,那笔 Owner 钱**无人报告**(fleet `total_mtd` 漏计 + 被钉那把的预算告警死区);candidate 集现在排除 per-key 轴的 key。(c) canonical 判定与兄弟扣减各自跑一遍 O(K×N) 的 cohort 扫描,合并成一次 `_bedrock_cohort`(一条分组查询拿全 cohort 的 per-key 轴存在性)。
>
> **cost_facts fact_key 去碰撞不变式 · `_preaggregate_facts_by_fact_key`** (2026-06-17, 0 migration / 0 cron / 0 MCP / 0 RBAC). **不变式**:`cmdb_cost_facts` 的 grain = `compute_fact_key`(date×integration×scope×resource_id×usage_type),**不含 product_code/service**。驱动可能合法产出多条同 fact_key 的 `CostFact`(AWS CUR EdpDiscount/PrivateRateDiscount 行 NULL resource_id 被合成成单个折扣桶 id 但横跨 67/8 个 product_code;Aliyun BSS 信用/调账日在一个 resource_id 上多条调整行)。`ingest_cost_facts` 现在分摊+分块 upsert **之前**强制过 [`_preaggregate_facts_by_fact_key`](../../server/app/domains/cmdb/cost_service.py) 把同 fact_key 的 facts **求和**(此前 `ON DUPLICATE KEY UPDATE` 每键只留一条、静默丢负向折扣行,致 AWS +9% / alicloud_intl 信用日漏 -$70K)。**写新 cost driver 或改 fact 取数时**:不必让驱动产出唯一 fact_key —— ingest 层会按 grain 求和兜底;但仍应让 driver 的金额可加(同 grain 多行 = 应净额的多个分量)。**Aliyun 空 OwnerID 归属**:`AliyunCloudDriver._billing_row_belongs_to_scope` + 缓存 `_master_account_id` 把空 OwnerID(master-allocated)BSS 行只归 RD master scope(`list_scopes` 注册该 master),三条 BSS 取数路径共用,cost_facts 与 billing_daily 同口径不扇出。**对账心智**:`cmdb_cloud_billing_daily`(按天内存求和,无 fact_key 折叠)是权威总额;cost_facts 是逐资源/服务/部门归因,修复后两者应 1-2% 内对齐。
>
> **云花费口径统一 · `CloudCostUsdConverter` + 删除重复的「洞察」tab** (2026-06-17, 0 migration / 0 cron / 0 MCP / 0 RBAC). **新可复用抽象** [`app.core.fx_rate.CloudCostUsdConverter`](../../server/app/core/fx_rate.py):凡读 `cmdb_billing_monthly` / `cmdb_cloud_billing_daily` 的 `total_cost_usd` 列(CNY 计费租户把**原币 CNY** 存在这个误名列里、`currency='CNY'`)的聚合都该过它——按 integration 缓存 FX 率(整合 `config_public` 覆盖,复刻 `/billing/summary` 的逐行换算逻辑),`to_usd(raw, currency, integration_id)` 返回 USD,无率/不支持币种计入 `unconverted_by_currency` 而非静默归 0。**接入点**:`/cmdb/analytics` billing_trend(`cmdb/service.py`,→报表 tab)、`/dashboard/insights/cloud`(`insights_router.py`,→领导洞察看板 CloudModule)、MCP `get_cmdb_billing_summary`;`cost_by_service`(原币)按行级 native→USD 因子缩放。**别再写裸 `SUM(total_cost_usd)`**——会把 CNY 当 USD ~7× 虚高,让同一区间在不同端点给出不同数。**已正确换算、不要动**:`/billing/overview`(日表,FinOps 账单 tab 权威)、`/billing/summary`(月表卡)。**同 PR 删 tab**:云资产总览的「洞察」tab 删除——它嵌领导看板 `CloudModule`(读**月表** + 对非整月区间强制取滚动近12个月),被「近30天」chip 包裹时显示 trailing-12mo 合计($22.9M)而与「账单」tab 的区间正确值($3.5M)6.5× 打架。逐 widget 核查内容已全被其它 tab 覆盖(服务器→主机/域名→网络/花费→报表+账单/scope→云订阅/授权→总览),零信息丢失;`CloudModule` 本体保留给领导洞察看板(`InsightsPage`/`InsightsSectionPage`);旧 `/admin/cloud/cost-insights` 路由重定向到 `?tab=billing`。**口径心智模型**:云花费的区间真值 = 日表 `cmdb_cloud_billing_daily`(账单 tab);月表 `cmdb_billing_monthly` 仅适合按月趋势(报表 tab / 领导看板),**不要**拿月表回答「近N天」。
>
> **财务全生命周期闭环 · PR4 成本问责下沉(预算负责人自助)** (2026-06-18, 0 migration / 0 cron / +1 MCP→hint 320 / 0 RBAC). 把成本数字推到花钱的人头上。PR2 预算的 `owner_person_id`(成本中心/部门负责人,多无 finance 角色)此前只有后台能看;新 `GET /finance/budgets/me`(**self-scope,任何登录用户**,严格只返回 owner==self 的预算,无 finance 门控否则 403 掉正主)+ `budget_vs_actual(owner_person_id=…)` 过滤。前端复用 `/me/budget`(同受众)加「我负责的成本预算」区块(仅有数据才渲染,不留空死页)。MCP `finance_my_budgets`(self-scope read)。复用既有 manager 成本可见性,不新开敏感暴露面。

> **财务全生命周期闭环 · PR3 月结开权财务 + 发现变动作** (2026-06-18, migration 305 / 0 新 cron / +1 MCP→hint 319 / +1 RBAC gate). **激活两个"看得到动不了"的现有功能**。**① `can_operate_finance`**(finance + global_admin):月结 close/reopen 从 global-only 放给 finance(关账是 controller 本职)。边界:`can_operate_finance` ≠ `can_manage_finance`(后者仍 global-only,守 FX pin/rollup 刷新/预算 CRUD/供应商付款等主数据与不可逆)。前端 Closeout 拆守门。**② 发现变动作**:新表 `finance_finding_acks`(无 FK,`(kind,ref)` 唯一,status=acknowledged/snoozed/resolved + snooze_until + note + actor) + [`app/domains/finance/finding_service.py`](../../server/app/domains/finance/finding_service.py)。治理读(先接 savings 浪费/闲置坐席)注解每条发现处置态,resolved/未到期 snoozed 过滤出活跃列表(`suppressed_count`),到期重现——只读发现墙变工作队列。`POST /finance/findings/ack`(can_operate_finance,幂等+审计)+ MCP `finance_acknowledge_finding`(mutate)。generic by kind/ref,合同续约/异常/孤儿/报销治理可复用。ADR 见 DECISIONS.md(2026-06-18)。

> **财务全生命周期闭环 · PR2 预算(计划→实际→偏差→预测→告警)** (2026-06-18, migration 304 / 0 新 cron / +1 MCP→hint 318 / 0 RBAC). 补上财务核心缺失的预算环(生产实证:`finance_budgets` 不存在、`procurement_budgets` 0 行)。**新表 `finance_budgets`**(无 FK):年度 USD 预算,维度 `(fiscal_year, cost_type, department, cost_center)` 任一可 NULL=不限;`owner_person_id`=问责人+唯一告警接收方;`last_alerted_threshold` 单调去重。与 `procurement_budgets`(PO 承诺锁定,cash 基)**互补不重叠**:这张表度量预算 vs **物化实际** `finance_spend_facts`。**`app/domains/finance/budget_service.py`**:CRUD + `budget_vs_actual`(每条独立:年累实际/YTD 摊销预算/偏差/消耗%/run-rate 年底预测/预测超支/状态)+ `evaluate_and_alert`(折进已启用的 `finance.spend_rollup`,不新增 cron;越 80/100% 给 owner 发一次去重飞书卡,只告警负责人)。HTTP `GET /finance/budgets`(read)+ `POST/PUT/DELETE /finance/budgets`(write=can_manage_finance,幂等+审计);MCP `finance_budget_status`。前端财务系统加「预算」页。
>
> **Budget wildcard non-overlap invariant (#2285 #2453 #2454 #2455, 2026-08-02; 0 migration):** within one fiscal year, two budget scopes conflict when every `(cost_type, department, cost_center)` dimension is equal or either side is NULL (NULL means all). HTTP create/update serialize the overlap query, mutation, success audit, and commit under `GET_LOCK('helix.finance.budget.<FY>', 5)`; callback/audit failure rolls back the budget before releasing the owner-connection lock, lock-infrastructure NULL fails 503, and contention fails 409. DELETE likewise commits deletion and audit atomically. The legacy nullable UNIQUE is only defence-in-depth for fully concrete tuples. Reads diagnose pre-fix rows as `scope_conflicts` without deleting or selecting a winner; conflicted detail stays visible in the Finance UI for manual remediation, while both sides are excluded from budget totals, BU/CFO budget aggregation, health counts, and alerts so the same fact cannot be counted or notified twice. The diagnostic is an in-memory pass over one indexed FY query (no budget N+1). Budget amounts remain decimal strings through the web form/API, so the full `DECIMAL(16,2)` range preserves cents.

> **财务全生命周期闭环 · PR1 成本中心归属主数据(从 HRIS 真值重建)** (2026-06-17, migration 303 / 0 新 cron / 0 新 MCP / 0 RBAC). **解决一个生产实证的口径塌方**:`finance_cost_centers` 权威主数据生产仅 1 行(汇联易开放 API 对本 app 只吐 1 个成本中心/0 报销单——外部授权缺口,非代码 bug),导致孤儿判定 `used − master` 把 2026-06 的 44 个成本中心 / $1.63M / 76% 花费全判孤儿。真值其实就在同库 `identity_persons.cost_center`(SF 同步,63 编码覆盖 497 人,恰为花费用到的那 63 个)。**新协调器函数** [`finance_huilianyi_coordinator.reconcile_master_from_hris`](../../server/app/core/coordinators/finance_huilianyi_coordinator.py):读 `identity_persons` 去重成本中心 → 名称取 `HUILIANYI_COST_CENTER_LABEL_MAP` → 在职人数(leaver 不计但编码仍入 master,免历史花费再成孤儿)→ `source='hris'` 幂等 upsert;汇联易行 `source='huilianyi'` 仅作 enrichment 叠加,`cost_center_coverage` 读全表 union 自动生效(孤儿归零,已用生产数据预演)。**不新增 cron**(零 seed 风险)——挂进已启用的 `finance.spend_rollup` 日任务 + `finance.huilianyi_actuals_sync` 开头。`cost_center_coverage` 增强:master 按来源拆分 + 孤儿带名称 + **负责人覆盖率**轴(owner_person_id 空的未归属花费,独立于成本中心覆盖)。migration 303 = `finance_cost_centers.headcount`(guarded 可重入)。`POST /finance/cost-centers/reconcile-master`(global_admin 幂等+审计)即时重建;增强字段经 PR-C 已注册的 MCP `finance_cost_center_coverage` 自动透出(同一 read_service 函数,0 新 MCP)。前端「成本中心」页加「重建主数据」按钮 + 负责人覆盖率 KPI + 来源拆分 + 在职人数。

> **财务对账中心 · Phase 2 外部实际账(汇联易)PR-A** (2026-06-17, migration 297 / +1 cron 默认禁用 / 0 MCP / 0 RBAC). 把公司费控/报销系统**汇联易**接入财务对账中心作为外部实际账来源(补全报销/差旅口径 + 内外对账地基)。**复用现有 `identity_sync_huilianyi` 连接器 + `HuilianyiClient`**(身份「推」/财务「拉」同一 app)。跨域读走 [`app/core/coordinators/finance_huilianyi_coordinator.py`](../../server/app/core/coordinators/finance_huilianyi_coordinator.py)(finance 域不 import identity_sync)。**新表(无 FK)**:`finance_external_statements`(外部实际账行,grain=`(source,报销单号,分摊行号)`,带成本中心/部门/项目/供应商/原币+USD/发票/付款状态/`raw`;`promote_to_ledger` 标记是否入统一账层)、`finance_cost_centers`(汇联易成本中心主数据镜像=权威维度)。**双算护栏**:只有非 IT 费用类型(平台设置 `finance.huilianyi.promote_categories` 白名单)`promote=True` 进总账为 `cost_type='expense'`;IT 重叠类只对账不入账(Helix 已从源系统算)。币种复用 `finance.fx`。**防御式 + 默认禁用**:汇联易 expense API 无法 CI 真调,故端点/字段 config 可覆盖、tolerant getter、留 `raw`、无连接器/无读权限优雅跳过;cron `finance.huilianyi_actuals_sync` seed `enabled=0`,启用需开放平台授读权限 + dry-run 校验(运维手册 [`docs/ops/finance-reconciliation-ops.md`](../ops/finance-reconciliation-ops.md))。ADR 见 DECISIONS.md(2026-06-17)。**PR-B(shipped 2026-06-17, 0 migration, +2 MCP→hint 306)**:补全口径 `collect_expense_rows` 把 promote=True 报销提升进统一账层为 `cost_type='expense'`(双算护栏:只提升 promote 行);对账 `read_service.external_reconcile`(内部 finance_spend_facts 排除 expense vs 外部 finance_external_statements,按成本中心/部门/供应商 + 差异 + 单据链计数)+ `external_statements` 浏览;`GET /finance/{reconcile/external,external/statements}` + MCP `finance_external_reconcile`/`finance_external_statements`;前端 `外部账比对` 页。**PR-C(shipped 2026-06-17, 0 migration, +2 MCP→hint 308)**:成本中心权威化 `read_service.cost_center_coverage`(花费成本中心对齐 `finance_cost_centers` 主数据→覆盖率/孤儿/未使用)+ 报销治理 `expense_governance`(重复发票/已付未验真);`GET /finance/cost-centers/coverage`、`GET /finance/governance/expense-hygiene` + MCP;前端 `成本中心与治理` 页。**汇联易 Phase 2 三 PR 主题完成;实时拉取默认禁用,待开放平台授读权限 + dry-run 激活(`docs/ops/finance-reconciliation-ops.md`)。**
>
> **财务对账中心 · 统一花费总账 (finance domain, PR1)** (2026-06-16, migration 294 / +2 cron / 0 MCP / 0 RBAC). 新增 `finance` 域 ([server/app/domains/finance](../../server/app/domains/finance)) —— 跨四条花费线(云/软件/AI/采购)的**统一花费账层**,把 Helix 全部支出归一成同一形状、归一 USD、按 `时间 × 部门 × 成本中心 × 类型 × 供应商` 可切片,供财务对账。**核心表(4 张,均无 FK,沿用 AI rollup 规避 TiDB CHAR/VARCHAR FK 严格性)**:`finance_spend_facts`(按月物化的统一账,grain=`period×cost_type×basis×部门×成本中心×owner×供应商×子类目`;`amount_usd` 是所有聚合求和的归一额,`amount_native`+`currency_native`+`fx_rate_used`+`fx_basis` 留原币与血缘)、`finance_fx_monthly`(月度冻结 USD-per-unit 汇率)、`finance_period_close`(按月关账冻结 + `closed_total_usd` 快照)、`finance_restatements`(关账后总额漂移审计)。**跨域聚合器** [`app/core/coordinators/finance_spend_coordinator.py`](../../server/app/core/coordinators/finance_spend_coordinator.py) 是唯一合法跨域读点(域间不互相 import);`materialize_period` 物化、`reconcile_internal` 自检。**去重不变式(帐准的根)**:`cloud`=cost_facts 排除 AI 白名单服务、`ai`=取自 `AiSpendingService._collect_period_rows`(与 AI 大盘构造性相等)、`software`=月账单排除 catalog `category='ai'`、`procurement`=PO 实际支出且 `basis='procurement'`(cash 口径,不进消费总额)——`cloud+software+ai` 无重叠划分消费域。**月度 FX 地基** [`finance.fx`](../../server/app/domains/finance/fx.py):云/AI 本就 USD(`native_usd`),软件/采购原币按当月冻结汇率换算(`month_frozen`),关账后实时汇率不再影响已发布数;回填取自 frankfurter.dev(ECB,与既有 CNY 周刷新同源),admin 可按月 `PUT /finance/fx/monthly` pin。**关账/重述**:开放月 fact 标 `is_provisional`,关账转 final;已关账月被重算时不替换已发布数,只在漂移>1 分时记 restatement;reopen 转回 provisional。**HTTP `/api/v1/finance/*`**(读=`can_view_finance`,写=`can_manage_finance`):`POST /spend/rollup/refresh`、`GET /spend/reconcile/internal`、`GET /period/status`、`POST /period/{close,reopen}`、`GET|PUT /fx/monthly`。**Cron**:`finance.spend_rollup`(日,当月+上月)、`finance.fx_monthly_refresh`(周,滚动 13 月)。测试 `tests/test_finance`(CI `core-domain` 分片)。**PR2(shipped 2026-06-16, migration 295, +6 MCP→hint 301)**:看数与对账体验闭环——读 API `/finance/spend/{summary,pivot,timeseries,detail,movers,completeness,digest,export.xlsx}`([read_service.py](../../server/app/domains/finance/read_service.py),读=`can_view_finance`,NULL 部门/成本中心折叠成显式 `__unallocated__` 桶)+ 保存视图 `finance_saved_views` + 6 个 finance MCP 读工具 + 前端 4 页(`web/src/pages/finance/`:对账总览/花费浏览器透视下钻血缘/变动异常/关账完整性,menu `财务→对账中心`,RoleRoute `global_admin/finance/auditor`)。digest 为**确定性**叙述(与大盘同口径,无 LLM 漂移)。**PR3(shipped 2026-06-16, 0 migration, +3 MCP→hint 304)**:成本治理与供应商全景——新跨域聚合器 [`finance_governance_coordinator`](../../server/app/core/coordinators/finance_governance_coordinator.py)(读 asset+procurement);`GET /finance/governance/{savings,renewals,contract-actuals}`:① 能省的钱(残留付费坐席 `software_external_holder` leaver_residual/off_paid_site/vendor_inactive + 闲置坐席 idle_warned_at,join 套餐价折每月 USD,用当前实时汇率)、② 续约日历(active 合同 N 天内到期)、③ 合同 vs 实际(签约额 vs 关联 PO 实际 + 履约率);前端 `成本治理` + `供应商与合同` 两页(供应商跨线复用 pivot)。**Phase 2(未做)**:外部账单比对(发票/NetSuite/汇联易/银行,待用户指定来源)。
>
> **云资产总览「账单」tab 接通部门过滤** (2026-06-16, migration 292 / 0 cron / 0 MCP / 0 RBAC). 部门过滤此前只接进了 owner-归属面（概览/云订阅/帐号与权限/身份IAM）；FinOps「账单」tab 完全无视它。部门归属只存在于 `cmdb_cost_facts.cost_owner_department`（按账号 owner 部门派生），而账单「概览」子 tab 走的是账号级日汇总 `CmdbCloudBillingDaily`（**无部门维度**）。**新增**：cost_facts 全部读路径（`breakdown`/`timeseries`/`top_resources`/`list_tag_keys`/`detect_anomalies`/`forecast_month_end`/`export`）经新 helper `cost_service._department_filter_clause` → `app.core.org_scope.resolve_department_subtree_names`（选父部门子树 rollup，与其它部门过滤同语义）→ `cost_owner_department IN (...)`；NULL-owner（未分摊/SP netting）行在过滤下**排除**，使「部门花费」是大盘真子集。七个 `/cmdb/cost/*` 端点加 `department` Query 参；migration 292 复合索引 `ix_facts_dept_date (cost_owner_department, date)`（镜像 `ix_facts_cc_date`）。前端 `CloudCostAnalysisPage.embeddedDepartment` 接顶部部门选择器，贯穿下钻/资源/预测三个子 tab，选部门时自动落到「多维下钻」并默认按云账号(integration)拆分。**架构边界（沿用 analytics 端点 docstring 既定约定）**：账单「概览」子 tab 数据源结构上无部门维度，保持部门无关并显式提示「概览不随部门收窄，部门视角请看多维下钻」——per-department cost lives in the drilldown，不强行把概览 re-source 到 cost_facts（两表口径不同：SP netting / 币种归一）。**同 PR 第二处对账（fix）**：云订阅 tab「各订阅花费排行」(`admin_overview_service._cost_by_scope_ranking`) 之前按 `cost_facts.cost_owner_department`（owner 转移后滞后、只在夜间 `recompute_allocation` 重刷）过滤，与同 tab 的「生效订阅」计数 / 订阅列表（都按 scope 实时 `owner_person_id`→部门）对不上——owner 转走后排行仍卡旧部门。改用同一套实时 owner 谓词（`scope_id IN (SELECT id FROM cmdb_cloud_scopes WHERE owner_person_id IN dept_pids …)`），`dept_pids` 从 `get_admin_overview` 透传。**与账单 tab 有意不同**：账单=per-fact 分摊（`cost_owner_department`,夜间重算）；云订阅=订阅当前归属,两者语义各自正确。**同 PR 第三步(feat)——「部门成本」双口径视图**：新端点 `GET /cmdb/overview/admin/department-cost`（`admin_overview_service.department_cost_reconciliation`）一次返回某部门某月的**两种口径并排**——分摊口径(`cost_owner_department` ∈ 子树,共享账号拆分)与账号归属口径(scope 当前 owner ∈ 部门→整账号),按云厂商 + 按云账号 + 两个总额。前端 `CloudCostAnalysisPage` 条件子 tab「部门成本」(选部门时出现并自动落入,月份默认上月),每个云账号一行并列「分摊给本部门 / 整账号」两列,差异(如 AWS SG 整账号 $4422 仅 $564 分摊到 BO)一眼可见。这是面向「某部门上月在各云账号/云厂商花了多少、总共多少」这一具体问题的直答面;概览/多维下钻仍是分摊口径细节面。新增通用维度键 `cost.dim.provider`。
>
> **Cloud access-request: submit-time role-shape pre-flight + pending-approver visibility** (2026-06-15, 0 migration / 0 cron / 0 new MCP). **(a) Pre-flight** — new driver hook `CloudDriver.validate_request_role(scope, role_key) -> str | None` ([driver_base.py](../../server/app/integrations/driver_base.py); base returns None / fail-open). `AlicloudIntlCloudDriver` implements it to mirror `grant_access`'s shape decision (CloudSSO directory present + RAM-shape `Type:Name` key → reject; no directory + bare key → reject), so a stale catalog row is refused at **request/add-member time** instead of failing at grant time where only the approver saw the toast. Wired via module helper `_preflight_request_role_shapes` into `submit_access_request` + `owner_add_member` (loads driver, raises `AppException(422)` on a problem, fails open on driver-load/transport error). **(b) Approver visibility** — `_serialize_grants` ([lifecycle_router.py](../../server/app/domains/cmdb/lifecycle_router.py)) now emits `pending_approver` (`{kind:'owner'|'cloud_admin_team', label}`, pending rows only; owner-left/missing → team) consumed by 我的云订阅→待审批, scope-detail→申请记录 tab, and MyRequestsTracker. New aggregator provider `_provider_cloud_grant` + detail builder `_detail_cloud_grant` ([my_requests.py](../../server/app/domains/me_universe/my_requests.py) / [request_detail.py](../../server/app/domains/me_universe/request_detail.py)) surface per-role cloud access grants (`source='helix'` only) in the unified 我的申请 — previously invisible there.
>
> **我的已办 (todo history)** (2026-06-15, 2 endpoints / +2 MCP → hint 295 / 0 migration). `GET /me/todos/done` ([todo_history.py](../../server/app/domains/me_universe/todo_history.py) `build_todo_history`) — read-only history complement to `MyTodosPage`: items the caller personally acted on across all five todo domains (asset transfers responded, provisioning completed, cloud grants granted/revoked, api-key releases decided, inventory responded), newest first, owner-scoped. provider-registry sibling of `my_requests`/`action_inbox`. MCP `list_my_todos_done`. **Promoted to its own page `/my/done` ([MyDonePage.tsx](../../web/src/pages/my/MyDonePage.tsx)) on 2026-06-15** (split out of the 已办 tab that used to live inside `MyTodosPage`; new 我的工作 sidebar leaf `nav.myDone`). Two tabs: **列表** (the paginated history; each row opens [`DoneDetailModal`](../../web/src/components/DoneDetailModal.tsx) — a polished sectioned modal mirroring `RequestDetailModal`, rendering `DoneItem.fields` (label/value pairs built from columns already on the source row, zero extra queries; apikey provider batch-loads the parent `ApiKeyAssignment` for project/request-number; mirrors `my_requests`' `RequestContentField`) + a "前往" jump when the item has a real owning surface (cloud-grant → scope detail, inventory → cycle)); **报表** powered by `GET /me/todos/done/stats` (`build_todo_history_stats`, MCP `my_todos_done_stats`) — total / type-mix / status-mix / 12-month stacked-by-type trend (month buckets in the caller's local `resolve_timezone`) + this-vs-prev-month + active-days + first/last-acted, reusing the same 5 providers as the single source of truth at `_STATS_CAP=2000` (`truncated=true` when a domain exceeds the cap); FE renders via `reportsShared` (ECharts) GradientKpi/BarTrend/Donut/HBar.
>
> **催办 (request urge)** (2026-06-15, 2 new endpoints / +1 MCP → hint 294 / 0 migration). Applicant `POST /me/requests/urge` + admin `POST /approval/admin/overview/urge` ([urge.py](../../server/app/domains/me_universe/urge.py) `urge_request`). Notifies the requester's manager + IT admins in-app+Feishu via `NotificationService.dispatch`; **rate-limited 1×/24h per request via a Redis key+TTL (no table/migration)**; open-only (terminal → `urged=false` reason); audited (`me.request.urge`). MCP `urge_request`. FE: shared `UrgeButton` in `RequestDetailModal` + `ApprovalDetailModal` (employee pending) + admin `AdminApprovalOverviewTab` row. Does NOT resolve the exact Feishu pending-approver (no reliable open_id→person map) — manager+IT cover the decision owners.
>
> **Unified request detail + grant-terminal status fix** (2026-06-15, 1 new endpoint / 0 migration). `GET /me/requests/detail?id={source}:{pk}` ([request_detail.py](../../server/app/domains/me_universe/request_detail.py) `build_my_request_detail`) — owner-scoped, source-agnostic rich detail for ANY 我的申请 row (summary + requester + domain `content_fields` + synthesised lifecycle `timeline` + raw + `has_feishu_flow`). FE: `MyRequestsTracker` retired its thin 6-field drawer — `employee_request` rows reuse the admin `ApprovalDetailModal` (employee viewMode), every other source uses the new `RequestDetailModal`. Also: `software_seat`/`api_key_request` lifecycles **terminate at `approved`** (grant is synchronous on approval), so `build_my_requests` now maps their `approved`→`completed` — fixes backfilled seats/keys showing as perpetual 处理中. **Widened to the whole source 2026-08-26** (see the 我的申请 terminal-status entry below): `employee_requests` has no post-approval state at all, so `SRC_EMPLOYEE` sits in `_APPROVED_IS_TERMINAL_SOURCES` and the per-type allowlist is gone. **Live approver overlay (2026-06-15)**: the detail endpoint passes `with_live_approval=True`, so any source carrying a `feishu_instance_code` (DNS / cloud-scope / VPN / AI-tool / announcement / domain / SSO — every non-`employee_request` Feishu flow) now gets the **real Feishu approval flow + current approver** overlaid via the new row-agnostic `ApprovalService.build_live_feishu_flow(instance_code)` (one HTTP → name enrichment → reuses `_build_progress_from_instance`/`_build_timeline_from_instance`; best-effort, returns `(None,[],err)` on failure, no snapshot persist, no callback reconcile). New response fields `approval_progress`/`approval_timeline`/`approval_timeline_error`; FE `RequestDetailModal` renders the same `ApprovalFlowTimeline` as employee_request when present (+ a 当前审批人 row in summary) and falls back to the synthesised timeline otherwise. 催办 keeps `with_live_approval=False` (pure DB read, no Feishu round-trip). Answers "who is approving my DNS request right now?" — previously the Progress tab showed only `Submitted → Awaiting approval`.
>
> **Cloud-sync per-capability observability** (2026-06-13, 0 migration / +1 MCP → hint 293). The sync engine already writes per-capability detail into `CmdbSyncJob.capability_results`; the new diagnostic roll-up answers "what's the latest outcome of EACH capability for THIS cloud account, and which are failing?" — `GET /cmdb/integrations/{id}/sync-capability-status` ([sync_status.py](../../server/app/domains/cmdb/sync_status.py) `build_capability_status`; latest outcome per configured capability incl. `status='never'` for never-synced). automation-health gained a `cloud_sync` lane (`build_cloud_sync_health` — failed/partial jobs + top failing integration×capability with reasons). Single-capability re-run reuses the existing `POST /cmdb/sync/{id}/trigger` with one `resource_types`. MCP `get_cloud_sync_status`. FE: 同步能力状态 drawer on the integrations page (per-capability status + re-run button + expandable capability_results) + automation-health cloud-sync card. **Closes the "Cloud sync run history granularity" open-work item.** This completes the **角色视角的功能对称 (persona-scope parity)** 4-PR theme.
>
> **Self-service request lifecycle-verb parity** (2026-06-13, 0 migration / +3 MCP → hint 292). Closed the verb-matrix asymmetries across request types: **permission** requests gained withdraw (`POST /permissions/requests/{id}/cancel` — owner/PENDING-only local flip, NOT Feishu-routed; new `PERM_CANCELLED`) + tracker visibility; **sso** grants gained tracker visibility (both via new `_provider_permission`/`_provider_sso` in `me_universe/my_requests.py`); **ai_tool** applications gained `handover` + `batch-handover` (transfer a paid seat to an active colleague — mirrors `DomainRegistrationService.handover`) + a standalone admin `revoke` HTTP endpoint, and `RES_AI_TOOL_APPLICATION` was added to the offboarding handover resolver's `supported_types` (offboarding default stays auto-revoke = cost-correct; handover is the admin's opt-in). MCP `cancel_permission_request` / `handover_ai_tool_application` / `revoke_ai_tool_application`. FE: permission/sso withdraw auto-surfaces in the unified tracker; ApprovedAiToolsPage gained handover/revoke actions.
>
> **Manager team-lifecycle cockpit** (2026-06-13, 0 migration / +1 MCP → hint 289, zero push). `GET /api/v1/me/team/lifecycle` ([team_service.py](../../server/app/domains/me_universe/team_service.py) `build_team_lifecycle`) — read-only view of where each direct/transitive report sits in onboarding (Day-1 readiness: hire countdown, pending readiness blockers) or offboarding (exit countdown, status, pending blockers incl. hardware-return, SLA, handover). Sibling of `/my/team/footprint` + `/my/team/cloud`; FE `TeamLifecyclePage` under 我的团队; MCP `list_team_lifecycle`. (Also corrected a stale ROADMAP claim — managers already saw subordinates' cloud grants via `build_team_cloud`.)
>
> **Hardware-asset financial lifecycle** (2026-06-13, migration 283 / +1 cron / +3 MCP → hint 288 / finance unlocked for asset read). Closes the finance-persona parity gap — `finance` saw software/cloud/AI/procurement cost but had ZERO hardware visibility (`can_read_all_assets()` excluded it). New: depreciation math ([`depreciation.py`](../../server/app/domains/asset/depreciation.py) — straight_line + declining_balance, pure/testable), per-category **depreciation policies** (`asset_depreciation_policies`, NULL row = platform default; `GET/PUT/DELETE /assets/depreciation-policies`, read=finance/asset_admin, write=asset_admin), **disposal P&L** (`dispose` takes optional `disposal_proceeds`; freezes net book value at disposal → gain/loss; columns on `asset_items` + mirrored into the disposal operation detail), **financial-stats** aggregation ([`financial_service.py`](../../server/app/domains/asset/financial_service.py); `GET /assets/financial-stats/{my,subordinates,department,all}` mirrors software cost-stats scope/RBAC; NBV/accum-dep/original/repair-TCO by category/department/office, currency-exact), **book-value trend** (monthly cron `asset.book_value_snapshot` materialises `asset_book_value_snapshots` CNY-normalised; `GET /assets/financial-stats/book-value-trend`), **per-asset financial timeline** (`GET /assets/{id}/financial`), and an **insights** tab (`GET /dashboard/insights/hardware-finance` + `/export/hardware-finance`). MCP (+3, hint 285→288, read-only): `hardware_financial_stats` / `get_asset_financial_profile` / `list_depreciation_policies`. FE: `/admin/assets/financial`, 折旧策略 settings tab, AssetDetailPage timeline, dispose-proceeds field.

> **PRIMITIVE — browser-direct upload purposes: one registry, one key sanitiser, one photo strip** (2026-09-02, 1 migration / 0 cron / 0 RBAC / 0 MCP-tool-count). Adding a browser→S3 upload flow is now three registrations, not a new pipeline. (1) **Purpose**: append to `PURPOSE_MAX_BYTES` in [`app/core/storage_uploads.py`](../../server/app/core/storage_uploads.py) (the key derives `ALLOWED_PURPOSES`) **and add an explicit ACL clause** to `presign_get_endpoint` in [`app/storage_router.py`](../../server/app/storage_router.py) — the `else` branch 403s an unregistered purpose **on purpose**, so a new flow can never silently inherit "anyone authenticated may read"; `tests/test_storage_router_acl.py::test_every_allowed_purpose_has_an_explicit_read_policy` walks the whole registry and fails on the one you forgot. (2) **Key shape**: validate caller-supplied keys with `sanitize_object_key_list(raw, field=…, max_items=…)` (same module) — length / traversal / absolute / URL-scheme / duplicate. Do NOT hand-roll it: the key reaches `presign_get` verbatim, and this used to live only inside the inventory domain's `sanitize_evidence_photo_keys` (now a thin wrapper over it). (3) **Reference claim**: call `mark_uploads_referenced(...)` before persisting the keys — it HEADs the landed object against the size / content-type / checksum declared at presign time and flips the grant to `referenced` so the orphan sweep leaves it alone. A mismatch must fail the whole domain write, not drop the evidence. (4) 🔴 **IAM prefix**: add the purpose to `local.uploads_read_write_prefixes` in [`infra/aws/modules/ec2/main.tf`](../../infra/aws/modules/ec2/main.tf) **and to the live inline policy** `helix-prod-ec2-role` / `HelixProdUploadsAccess`. The presigned PUT is signed with that role's credentials, so S3 evaluates its per-prefix ARNs when the browser uploads — a purpose without a prefix is a **403 that only happens in production**: the presign endpoint signs the URL happily, every local test passes (none touch real S3), and the failure lands in the browser. `asset_disposal_evidence` shipped exactly that way (#3401 — code deployed, migration applied, feature dead on arrival). 🔴 **合进 Terraform 不等于授权**:这里的 Terraform 是 plan-only(CI 只跑 `terraform plan`),#3402 把前缀写进了模块、契约测试也绿了,而生产上那条权限直到 **2026-09-03** 才被手工 `put-role-policy` 推上去 —— 中间整整一天,处置凭证上传在生产是 403。第四步的完整做法(含用真签名者在生产容器里验一条 200)写在 [`docs/ops/storage-direct-upload.md`](../ops/storage-direct-upload.md) 的 Production EC2 role contract 一节。 `tests/test_deploy/test_ec2_uploads_policy_contract.py::test_every_upload_purpose_has_an_iam_prefix` now walks `PURPOSE_MAX_BYTES` against the prefix list so the next one fails in CI instead. Note the live policy and Terraform had already drifted (`bounty_attachment` was added live only, priming a `terraform apply` to revoke it) — reconciled 2026-09-02 and pinned by a second test. Rendering back is [`web/src/components/UploadedPhotoStrip.tsx`](../../web/src/components/UploadedPhotoStrip.tsx) (object keys → batched `presign-get` → previewable thumbnails, cancelled on unmount) — reuse it rather than re-deriving the exchange. Current purposes: `inventory_evidence` (holder's 「不属于我」 proof), `asset_disposal_evidence` (admin's 处置凭证 — 回收单 / 付款截图 / 归还回执 / 销毁照片, migration 591), `feed_post_image`, `feed_announcement`, `awareness_material` (安全宣贯素材 —— **写侧也有角色闸**,见 `_PURPOSE_WRITE_GATES`), `ai_labs_image` / `ai_labs_document` / `ai_labs_audio`(AI 实验室输入 —— **三点与其它 purpose 不同**:内容类型按 purpose 收窄而不是共用全局图片白名单;对象键**不带文件名**(`OPAQUE_NAME_PURPOSES` —— 键会被抄进全公司可读的审计行,而员工的文件名是内容);凭证有效期 4 小时而非 10 分钟(`GRANT_TTL_BY_PURPOSE` —— 它选文件时上传、发送时认领))。🔴 **第 (3) 步「认领」是 AI 实验室唯一一个漏掉过的域**,而漏掉的后果是每个附件在上传 24 小时后被孤儿清理删掉 —— 员工自己的消息行留着一个指向空的 key。本域的测试从没碰过 grant 台账,所以三轮复审都走过去了(2026-09-03 修)。 `approval_supplement` deliberately stays OUT of the registry (server-side put, request-scoped read — see the Feishu attachment PRIMITIVE above).
>
> **PRIMITIVE — geographic distribution map `<GeoDistributionMap>` + country-code normalizer (`app/core/geo.py`)** (2026-06-15, frontend + 1 read endpoint, 0 migration / 0 cron / 0 MCP / 0 RBAC). One shared world→China drill-down choropleth for "how many X are in each country/city": world map coloured by per-country count (ECharts `map` series + `visualMap`), click China → China province outline + per-city `effectScatter` bubbles sized by count. Component [`web/src/components/geo/GeoDistributionMap.tsx`](../../web/src/components/geo/GeoDistributionMap.tsx); data contract is identical for every caller — `{ byCountry: {ISO-alpha2 → n}, byChinaCity: {rawCityName → n} }`. Adopted by HR 洞察「人员分布」tab (endpoint `GET /dashboard/insights/people-geo`, point-in-time active-headcount snapshot, `people` section gate) and 资产工作台「资产分布」tab (fields `by_country`/`by_china_city` added to `AssetService.get_stats()`, resolved holding-office→assigned-person). **Country normalization is the single chokepoint `normalize_country_code()` in [`app/core/geo.py`](../../server/app/core/geo.py)** — folds an alpha-2 code, alpha-3 code, or free-text EN/中文 name into a canonical ISO alpha-2 (drops what it can't resolve, never invents). Reuse it for ANY country-keyed aggregation; do NOT hand-roll name→code maps. ECharts 6 ships **no** bundled map data, so the GeoJSON comes from npm (`@surbowl/world-geo-json-zh` registered with `nameProperty:'iso_a2'`, `china-geojson` province outline) + a self-maintained China city→[lng,lat] table ([`chinaCityCoords.ts`](../../web/src/components/geo/chinaCityCoords.ts), ~43 cities, zh/en aliases) and ISO→localized-name table ([`countryMeta.ts`](../../web/src/components/geo/countryMeta.ts)). The ~650 KB GeoJSON is isolated in a lazy `geo-data` vite chunk (manualChunks) and the component is `lazy()`-imported at both call sites, so it only downloads when a distribution tab opens. Cities with no coordinate are surfaced in an "unmapped" table, not dropped, so totals reconcile.

> **PRIMITIVE — truncating free-text table cell `<EllipsisText>` (`web/src/components/EllipsisText.tsx`)** (2026-06-15, frontend-only, 0 migration / 0 cron / 0 MCP / 0 RBAC). Drop-in for any long free-text table column (说明 / 备注 / 原因 / 解释 / 描述 / 用途 / error-message / response-note …): caps its OWN max-width and reveals the full value in a hover `Tooltip`. The tooltip is **overflow-aware** — checked lazily on `mouseEnter` (`scrollWidth > clientWidth`, or `scrollHeight` for multi-line), so short values stay tooltip-free. Crucially it works under `scroll={{ x: 'max-content' }}`, where antd's bare `ellipsis: true` never truncates (the column just grows to fit content — the exact bug that made 用途说明/原因 columns blow out the table width). Props: `children` (cell content, string or markup), `title` (plain-text tooltip override when children is decorated markup), `maxWidth` (px, default 240 — ~220 short notes / ~280 reasons / ~320–360 descriptions), `lines` (>1 for multi-line clamp), `empty` (placeholder for null/'' — keep the existing `—`/`-` secondary Text). **Reuse this, don't add bare `ellipsis: true` to a free-text column.** Distinct from [`JmlCell`](../../web/src/components/jml/JmlHelpers.tsx) (a name+sub-line cell with always-on tooltip + link support — leave its columns alone). Applied across 34 tables (asset / admin / cloud / identity-sync / provisioning / procurement / transfer / compliance / google-workspace / hr / email-group) in the same PR.

> **PRIMITIVE — `<JsonBlock>` raw-JSON display (`web/src/components/JsonBlock.tsx`)** (2026-06-14, frontend-only). One component for showing a raw JSON payload / object to the user (admin inspection panels: identity-sync request/response/rendered payloads, provisioning `context_data`, vendor raw records, risk-score breakdowns, scheduled-job run summaries). Replaces the scattered hand-rolled `<pre style={{…}}>{JSON.stringify(x, null, 2)}</pre>` blocks (each with ad-hoc padding/colour, one a dark terminal skin, two literally named `JsonBlock` locally) — same "various styles of one component" problem. `<JsonBlock value={obj} maxHeight?={280} />` pretty-prints (2-space) or shows a pre-formatted string, with token-based styling (`SURFACE.subtle` bg, `RADIUS.control`, monospace, wrap + scroll cap) and a built-in copy button (antd `Typography.Text copyable`). **Use this for any user-facing JSON dump — do NOT hand-roll another `<pre>`.**
>
> **PRIMITIVE — canonical KPI tile `<StatCard>` + semantic colour tokens (`web/src/theme/tokens.ts` + `web/src/components/StatCard.tsx`)** (2026-06-14, frontend-only, 0 migration / 0 cron / 0 MCP / 0 RBAC). One component for every "number + label" metric across the app, killing the **five** pre-existing divergent implementations — `GradientKpi` (reportsShared), `KpiCard` (DashboardPage), `MetricBlock` (insights/sharedUI), `StatCard` (ImpactModule, icon-chip), `HeroCard` (insights/OverviewModule) — plus the bare `<Statistic>` dashboard rows. `<StatCard>` has **three variants**: `gradient` (Card + 3px tone top-stripe + pale tint, `height:100%` so a `<Row align="stretch">` of tiles is always flush), `plain` (bare label/value/hint block for use inside an existing Card, replaces MetricBlock), and `chip` (Card + coloured icon chip + value + an always-reserved sub line / progress bar — the information-dense leadership-board tile, replaces ImpactModule's local StatCard). Colour is driven by a semantic `tone` (`default|primary|success|warning|danger|info|accent`); legacy hex callers pass `stripeColor`. **`theme/tokens.ts` is the single source of truth for the app's colour palette** — `COLOR.{primary,success,warning,danger,info,accent}` (mirror the Ant Design `ConfigProvider` tokens in App.tsx), `TEXT.{primary,secondary,tertiary}` slate shades, `SURFACE.{border,borderSubtle,subtle}`, `RADIUS.{card,control}`, `STAT_TONE` (per-tone stripe+fg), `toneColor()`, and `withAlpha()` (the one rgba helper, de-duped out of reportsShared). The codebase had ~2,200 copy-pasted hex literals (`#10B981`/`#F59E0B`/`#EF4444`/`#4F46E5`/slate shades) — **new code MUST reference these tokens, never inline a hex**; migrate hexes to tokens opportunistically when touching a file. Adopted: `GradientKpi`/`MetricBlock`/`KpiCard`/`HeroCard`/ImpactModule's icon-chip `StatCard` are all now thin wrappers over `<StatCard>` (zero call-site churn for the 10+ GradientKpi callers + ImpactModule's 17 chip cards), and the DashboardPage AdminPanel/ManagerPanel/employee-cost stat rows switched from non-stretched bare `<Statistic>` to `<StatCard variant="plain">` under `align="stretch"`. All five KPI-card implementations are unified. All exact-match inline hexes across the six insights modules (Overview/Operations/Security/Cloud/Software/Impact) were swapped to `COLOR.*`/`TEXT.*` tokens (1:1, no visual change). **Remaining**: the bulk of the ~2,200 hex literals elsewhere (migrate opportunistically); ImpactBoardPage keeps deliberately-lighter dark-mode hues (not token-mapped). **2026-06-23 dark-encapsulation sweep** extended adoption: WrappedPage's 11 stat cards + DeviceCollection summary now use `<StatCard>` (a transient duplicate `StatTile` was created then deleted in favour of the canonical one), and 13 pages' hand-rolled pastel KPI cards (CloudIamUsers/CloudAnalytics/Offboarding lifecycle+rulemap/Onboarding strategy/Cohort/Procurement overview/…) were converted to StatCard's theme-safe tint formula inline so they read in dark — see the top-of-file 「全站封装」 entry. Pairs with the ECharts report kit ([reportsShared.tsx](../../web/src/pages/dashboard/reportsShared.tsx)) and `utils/statusColor.ts` (the Tag-colour SSOT — distinct concern: enum→Antd Tag colour, not raw hex).
>
> **PRIMITIVE — transaction-aborting DB-error classifier (`is_transaction_aborting_db_error` in `app/core/exceptions.py`)** (2026-06-13). One source of truth for "did this MySQL/TiDB error roll back the WHOLE transaction server-side" — errno ∈ {1205 lock-wait, 1213 deadlock, 1305 orphaned-savepoint}. Use it at **every `begin_nested()` catch boundary** to decide whether a per-item failure is savepoint-local (`IntegrityError` / `AppException` → safe to catch-and-continue) or transaction-fatal (these errnos → MUST re-raise, because the savepoint is already gone and continuing on the dead session snowballs into a `SAVEPOINT ... does not exist` 1305 cascade that silently skips the rest of the batch). The scheduled-job lock-retry helpers (`_run_in_fresh_session_with_lock_retry` / `run_with_lock_retry` / `_is_retryable_lock_error` in [scheduled_jobs/handlers.py](../../server/app/scheduled_jobs/handlers.py)) delegate to it. Born from the 2026-06-13 `hr.process_advance` incident — do NOT hand-roll another `errno in (1205, 1213)` check. **Sibling classifier — connection-lost (`is_connection_lost_db_error`, 2026-06-14)**: errno ∈ {2006 server-gone-away, 2013 lost-connection-during-query}. Kept SEPARATE from the transaction-aborting set on purpose — these mean the connection *socket* died (a TiDB Cloud node recycle can RST an in-flight query, "connection reset by peer"), not that the server chose us as a deadlock victim, and `begin_nested()` savepoint boundaries must NOT change behaviour on a dead connection. `_is_retryable_lock_error` ORs the two classifiers, so the fresh-session cron retry recovers from BOTH a deadlock and a connection drop — any idempotent re-scan cron (`onboarding`/`offboarding.reconcile_processes`, overdue-leavers, seat-syncs) survives a transient TiDB connection reset. `pool_pre_ping=True` only probes at checkout and cannot catch a mid-query drop, which is why app-level retry is the recovery.
>
> **Reconciliation center now has fix actions (not read-only)** (2026-06-13, 0 migration / 0 cron / +3 MCP → hint 285 / 0 RBAC). The `/admin/reconciliation` four lenses ([ReconciliationPage.tsx](../../web/src/pages/admin/ReconciliationPage.tsx)) grew per-row repair actions — detection no longer hands off to "go find the right admin page yourself". **Owner re-assign** (ownership + leaver-residual lenses): one shared `AssignOwnerModal` dispatches to the rtype's existing endpoint — `cloud_scope`→`PATCH /cmdb/scopes/{id}` (owner_person_id), `dns_record`→`POST /cmdb/dns-records/batch-owner` (single id), `domain`/`cdn_domain`→existing owner PATCH, `asset`→`POST /assets/{id}/assign` (operation voucher, NOT a bare metadata PATCH), `api_key`→**new `POST /apikeys/assignments/{id}/transfer-owner`** (reuses `ApiKeyService.apply_transfer`: local flip + cost-allocation-rule swap + AWS owner-tag rewrite, recipient must be active). **Leaver grant**→one-click `revokeGrant`; **procurement / office_admin**→keyword deep-link to the owning page. **Cost-drift lens**: `stale_fingerprint`/`not_materialized`→**new `POST /ai-spending/period-rollup/refresh`** (closed-month-only — 400 `ROLLUP_OPEN_MONTH` on an open month; reuses the daily cron's `refresh_period_rollup`; sensitive-endpoint rate-limited), `late_facts_or_drift`→deep-link to the AI dashboard. **New `POST /apikeys/assignments/{id}/cost-rules/re-enable`** is the inverse of the revoke-time `_disable_allocation_rules_on_revoke` (idempotent: already_enabled / not_found). MCP twins (+3, hint 282→285, all `mutates=True`, RBAC mirrors HTTP): `transfer_apikey_assignment_owner` / `re_enable_apikey_cost_rules` (asset-admin) / `refresh_ai_spending_rollup` (IT-admin).
>
> **PRIMITIVE — unified tabular XLSX export (`app/core/exporting.py` + FE `<ExportButton>`)** (2026-06-13, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count). Every list surface that offers a download goes through ONE implementation — do NOT hand-roll another openpyxl endpoint. Backend: `xlsx_export_response(db, request, current_user, columns=[(machine_key, zh_label)], rows=[dicts], total_matched, filename_stem, sheet_title, audit_action, audit_resource_type, filters)` gives the two-row header convention (human label row + machine-key row — same shape as the asset bulk-update template, future import round-trip), Excel formula-injection neutralisation on every text cell (OWASP CSV injection), tz-aware datetime / date / Decimal / UUID / bool / list / dict cell coercion, `X-Export-Total-Matched/-Returned/-Truncated` honesty headers, a 20k row cap (`EXPORT_ROW_LIMIT`), and the `{domain}.{entity}.export` audit row — **committed eagerly before the response streams** (get_db only commits after the body is sent; a client disconnect must not roll back the egress trail). Endpoint pattern: mirror the list endpoint's filter signature + RBAC branches exactly, call the same list service/query helper with `page=1, page_size=EXPORT_ROW_LIMIT`, register the `/export.xlsx` route BEFORE any `/{id}` sibling. Frontend: `utils/exportDownload.ts` (blob + Content-Disposition filename + X-Export-* metadata) + shared `components/ExportButton.tsx` (loading + truncation warning via `common.export*` keys ×3 languages) — pass the page's CURRENT filter state as `params` so the file always matches the screen. Coverage as of PR1: provisioning tasks, approval admin overview, my/team requests, auditor findings, audit logs (`/audit/export` upgraded in place: CSV→xlsx, cap 500→5000 — audit `detail` cells are 16KB-bounded, keep this cap, NOT 20k; the at-least-one-filter guard + sensitive-endpoint rate limit stay), procurement orders, offboarding + onboarding processes, apikey assignments, notifications outbox, software seat allocations — plus the pre-existing asset / ai_spending / cmdb / google_workspace / identity_sync exports (not yet migrated onto the primitive; migrate opportunistically when touched). Shared query extraction happened where list SQL was inline (`_outbox_filtered_stmt` / `_query_seat_allocations` / `_decorated_order_rows`) — list and export must never carry two copies of WHERE logic. MCP deliberately gets no binary-download twins (the data surfaces already have `list_*` tools).
>
> **Insights `impact` section — cumulative platform achievements，平台成果 (`GET /dashboard/insights/impact`)** (2026-06-12, 0 migration / 0 cron / 0 RBAC role / 0 MCP-tool-count; zh naming「平台成果 / 成果大屏」since 2026-06-12 — never「成绩单」, user-rejected as 功利). The leadership-facing all-time achievements tab, now the DEFAULT tab of `/admin/insights` (admin/management-only — deliberately NOT widened via `_INSIGHTS_SECTION_EXTRA_ROLES`; `/export/impact` same gate). Six blocks: reclaim (seats / permissions / cloud grants w/ revoke_reason split / API keys w/ auto-revoke share / SSO / leaver-residual holders / confirmed asset returns / leaver asset recovery / offboarding blocker resolutions auto-vs-manual; **cloud-grant reclaim counters MUST mirror the cloud overview's definition** — `GRANT_STATUSES_RECLAIMED` (revoked/expired/disabled) + stamped `revoked_at` + scope not soft-deleted, shared constant in `cmdb/models.py` — or 云资产总览 and 平台成果 show different totals to the same audience, 2026-06-12 incident: 980 vs 1131), managed scale (people/org/hardware/software+seats/api-keys/servers/domains+DNS/LB+CDN/cloud scopes+IAM+grants/email groups/procurement orders+contracts+suppliers/enabled integrations), flows served, security governance (leaver-block intercepts, certifications, MFA coverage, sign-in anomalies, audit-log volume), automation (cron runs + success rate, notifications delivered, provisioning automation rate, MCP tool count via `_REGISTERED_TOOLS_COUNT_HINT` lazy import), and estimated savings (`is_estimate`-flagged; seat savings = revoked allocations × plan price annualized per currency, CNY-converted via `core/fx_rate`; + revoked API-key monthly budgets × 12). **Counting discipline**: reclaim counters require a platform-stamped timestamp (`revoked_at`/`resolved_at`/`processed_at` IS NOT NULL) — migrated legacy rows are never claimed; scale counters are `deleted_at IS NULL` live inventory. Trend + cumulative-growth series bucket via the generic `_bucket_counts(db, col, start_dt, unit, *extra_where)` helper (reusable for any day/month count-by-bucket). FE: `ImpactModule.tsx` (GradientKpi wall + MetricBlock grids + 2 LineTrends) + the chromeless dark **projection board** `ImpactBoardPage.tsx` at `/admin/insights/board` (routed OUTSIDE MainLayout, same admin RoleRoute gate, 60 s auto-refresh, reuses the same endpoint at `range=24m`; entry points: impact tab "投屏模式" button + ops-cockpit menu `nav.impactBoard`). Tests: `test_insights_impact_2026_06_12.py` (RBAC matrix + counting-discipline regression). conftest `_SYSTEM_ROLES` now seeds `management` so tests can exercise the management role. **2026-06-13 additions (same PR as the parity fix):** ① **AI 解读 (AI Briefing)** — `insights_impact_summaries` table (migration 282, mirrors `ai_spending_summaries`: bilingual md + highlights JSON + aggregates snapshot; GUID FK to identity_persons via `_fk_source_col_type`), generator `app/dashboard/insights_impact_summary.py` reuses the platform Bailian credentials (`ai_spending.llm.*` — ONE LLM config, no second key) and feeds the LLM the SAME `build_impact_payload()` the page renders (extracted from the endpoint; pure aggregates, no PII); `GET /dashboard/insights/impact/summary` (admin+management) + `POST .../regenerate` (global_admin only, per-user-per-day quota default 5, debit committed in own session); weekly cron `insights.impact_summary_generate` (Mon 03:10 SH, soft-skips without LLM key, raises otherwise); FE `ImpactAiSummary.tsx` light/dark dual-skin. ② **reclaim.recent_30d** momentum block (5 reclaim types, same WHERE as all-time counters) → "+N (30d)" hero chips. ③ Visual: `AnimatedNumber.tsx` count-up (via new `GradientKpi.formatter` passthrough — Statistic stringifies ReactNode values into `[object Object]`, never pass nodes as `value`), reclaim-composition + cloud-revoke-reason donuts (reasons reuse `cloudOverview.perms.reason.*`), board curve+donut 2:1 grid.
>
> **Insights `people` / `lifecycle` tabs — HR-facing rebuild + AI diagnosis** (2026-07-08, migration 393 / 0 cron / 0 RBAC role / 0 MCP-tool-count). The 人员洞察 / 入离职洞察 tabs of `/admin/hr-tools/insights` were rebuilt to lead with an **AI diagnosis panel** (`HrAiPanel.tsx`) over an HR-framed KPI strip + grouped charts (all `reportsShared` echarts primitives). AI mirrors the impact-summary pipeline: `insights_hr_ai_summaries` (migration 393, mirrors `insights_impact_summaries` but keyed on `(section, range_key)` — `section`∈{people,lifecycle}, `range`∈{7d/30d/90d/12m/24m}); generator `app/dashboard/insights_hr_ai.py` reuses the platform Bailian credentials (`ai_spending.llm.*` — no second key) and feeds the LLM the SAME payload the tab renders (the `/people` + `/lifecycle` aggregation was extracted into `build_people_payload` / `build_lifecycle_payload`; the rosters are reduced to **counts** in the digest so no PII reaches the LLM). Endpoints `GET /dashboard/insights/{section}/ai-summary` (read gate = `_require_insights_section`, so hr reads it) + `POST .../regenerate` (**global_admin OR hr** — HR's own page may spend LLM quota; auditor/management read-only; per-user-per-day quota). **On-demand, no cron** — a missing row renders an inviting empty state, not an error. Tone taxonomy for highlights is strength/watch/risk (vs impact's win/governance/momentum). i18n `insights.hrAi.*` + new `insights.people.{kpi,section}.*` / `insights.lifecycle.{section,upcoming}.*`.
>
> **RBAC — `require_lifecycle_read` opens 入离职管理 to HR (read-only)** (2026-07-08, 0 migration / 0 cron / 0 MCP). New read gate in `core/dependencies.py` = `global_admin + hr + auditor + management` (same set as `require_hr_read`, named for the onboarding/offboarding/provisioning call sites). All **read** endpoints on `domains/{onboarding,offboarding,provisioning}/router.py` moved `require_admin_read → require_lifecycle_read` (27 handlers); every **mutation** stays `require_admin` (global_admin only). Frontend: `App.tsx` adds `'hr'` to the onboarding / lifecycle-overview / cockpit / process-detail route guards (offboarding already had it) and splits `/admin/lifecycle*` out of the global-admin-only block; `MainLayout.tsx` adds the 入离职管理 group (overview / onboarding / offboarding — NOT the cross-domain 任务队列) to `hrItems`. HR sees the read surface; action buttons 403 cleanly + are mostly hidden via frontend `canManage*`. (Alembic head → **393**.)
>
> **PRIMITIVE — supplement uploads can write INTO the form widget when the flow is parked on a 提交人本人 node (`FeishuApprovalClient.approve_task` + form)** (2026-06-11, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count). Feishu has no "update submitted instance form" API, but the laptop_replacement 回购 template parks the flow on requester-self nodes (上传旧电脑恢复出厂设置 / 转账截图) whose upload widget carries node-level **编辑** permission — while parked there the requester is the PENDING task's operator, and `POST /approval/v4/tasks/approve` with `form` (same JSON-array-string shape as create-instance; values = approval-upload **codes**, never URLs) writes the files into the widget itself and completes the node, exactly like the native client. The approve-API `form` param is only *documented* for condition-branch routing, so `AssetService.supplement_employee_request_uploads` **verifies** the write (re-fetch instance, per-widget value count must grow by the supplemented file count) and falls back to the comment channel for anything that didn't stick; admins acting on behalf always take the comment channel (never approve a node in the requester's name). Delivery recorded per supplement entry (`detail.feishu.supplements[].delivery` = form/comment/mixed + `completed_task`), surfaced in the audit row and the FE success toast. Locating the self node: instance detail `task_list` filtered `status==PENDING && user_id==requester`. Tests: `tests/test_asset/test_supplement_uploads.py` (form-write happy path / merge / mixed-fallback / approve-failure / admin-never-approves).
>
> **PRIMITIVE — Feishu v4 approval-comment attachments are URL-based (`{url, fileSize, title, type}`), NEVER token-based** (2026-06-11, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count). Verified against prod: the comment-create API silently drops an `images` key entirely and loses token-shaped `files` entries — no API error, the attachment just never renders, which is how supplement uploads shipped invisible to approvers. `FeishuApprovalClient.add_instance_comment` now takes `attachments=[{url,fileSize,title,type}]` (pinned by `test_feishu_client_comment_user_id.py`); the viewable `url` comes from the approval upload endpoint's response and is a **signed CDN link that expires in ~14 h with no re-sign channel** (unlike form widget values, which re-sign on every instance GET). Therefore supplement uploads ALSO persist a durable Helix S3 copy: `app/core/storage.py::put_object_bytes` (server-side put, new primitive — use when the file already transits the backend; browser-direct flows keep using presign-put) under purpose prefix `approval_supplement/` (deliberately NOT in `storage_router._ALLOWED_PURPOSES` — no browser-PUT path; its read path is the request-scoped `GET /assets/employee-requests/{id}/supplement-files/presign`, which checks requester-or-asset-read AND object_key-membership in that request's supplement history — no generic-purpose IDOR surface).
>
> **MCP + CLI 上线前全面加固 — 11-agent 三轮审计（3 code review + 3 逻辑 + 3 安全）→ 修复 → 3+1 轮复审收敛** (2026-06-11, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count, hint 仍 266). 平台级行为变更：**(1) `AppException` 现在调用 `super().__init__(message)`** — `str(exc)` 不再是空串（曾导致业务 404/409 被压成空消息 500）；`format_exception_for_log` 仍是审计/错误列的规范格式器（它还带 `.code`）。**(2) `NotFoundError` 接受单参形式** `NotFoundError("post not found")`（消息原样直出，404）— 修复 feed/asset 共 25 处隐性 TypeError→500 炸弹。**(3) MCP 工具错误契约**：tools.py 全部 27 处 `raise ValueError(str(exc))` 压平点已清除，AppException 1:1 透传、Pydantic→422；入参解析一律走 `_u()` / `_parse_iso_date()`。**(4) MCP middleware**：新增按 token-hash 的失败窗 peek（死 PAT 重试循环在 PAT 查库前被 429，合法用户与同 NAT 同事永不受失败预算牵连）；流式超限 500→413；错误信封/审计行 trace_id 关联外层 X-Trace-ID；CORS 放行 `MCP-Protocol-Version`/`Mcp-Session-Id`。**(5) 出站净化表（runtime `_CTRL_TABLE` 与 CLI `_TERMINAL_STRIP_TABLE`）补 C1 控制符 U+0080–U+009F**（U+009B 单字符 CSI 可改写终端）。**(6) CLI 脱敏契约改为**「所有字节都过 redact()，唯一豁免 = `_REVEAL_OUTPUT_TOOLS`（reveal_api_key/reveal_my_contact）的结果载荷，且仍剥终端控制符」；`_DESTRUCTIVE_MCP_TOOLS` 补齐 15 个高危工具并有 typo-proof 回归测试。**(7) 越权修复**：`update_my_software_catalog_entry` ownership 字段改 admin-only（对齐 HTTP #527）；`admin_change_seat_plan` 补 product-access vendor 拒绝。**(8) 静默回滚修复**：`list_approval_templates`/`list_approval_flow_stages` 缓存写穿透改独立提交会话。**(9) feed 点赞通知去重**：`toggle_like` 返回 `(count, changed)`，仅 changed 才通知。详见 CHANGELOG 2026-06-11 条目。
>
> **PRIMITIVE — Feishu cancel-desync visibility (`app/approval/cancel_desync.py`)** (2026-06-10, 0 migration / 0 cron / 0 schema / 0 RBAC / 0 MCP-tool-count). Every requester-facing withdraw flow (DNS request / SSO grant / VPN / announcement / AI tool / domain registration / cloud-scope withdraw / asset employee_request, plus the cloud-scope admin-decision side-cancel) does a best-effort Feishu `/approval/v4/instances/cancel` that must not block the local cancel — but a failure (canonically **60009**: template's 「允许撤销审批中的申请」 disabled) used to be log-only and invisible while approvers kept receiving todos. Now: each service records `last_feishu_cancel_synced` (class-attr default `True`, set per cancel call); the 8 HTTP cancel/withdraw endpoints return `feishu_cancel_synced` in `data` and the 7 MCP cancel/withdraw tools mirror it; the frontend's 5 withdraw entry points toast `common.feishuCancelOutOfSync` (zh/en/ja) when `false`. `notify_feishu_cancel_desync(db, flow_label=…, resource_type=…, resource_id=…, instance_code=…, error=…)` pushes an **in-app-only** notification (`NotificationEnvelope(channels=[])` → outbox row for the bell, no Feishu/email push since the Feishu API is exactly what failed) to every active global_admin via `failure_notifier.list_admin_recipients`, body carries `parse_feishu_error_code` + remediation hint (60009 → enable template withdraw in Feishu admin), dedup per `resource_type` over 24h (template_key `approval_feishu_cancel_desync`), and never raises into the withdraw path. **Reuse this for any future Helix↔Feishu state-divergence alerting; do not add per-domain webhooks.** Tests: [`tests/test_approval/test_feishu_cancel_desync.py`](../../server/tests/test_approval/test_feishu_cancel_desync.py). Memory [[reference_feishu_cancel_60009_template_withdraw]].
>
> **Data access management V1 contract** (2026-06-09, doc-only DAX-001 for [#809](https://github.com/pingcap-inc/Helix/issues/809); 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). The implementation contract now lives in [`docs/architecture/DATA_ACCESS.md`](DATA_ACCESS.md), with milestone ordering in [`docs/architecture/DATA_ACCESS_EXECUTION_ORDER.md`](DATA_ACCESS_EXECUTION_ORDER.md) and the ADR in [`DECISIONS.md`](DECISIONS.md#2026-06-09--data-access-management-v1-is-bigquery-dataset-read-access-for-google-groups-through-semantic-helix-bindings). V1 is deliberately narrow: provider `bigquery`, resource `dataset`, principal `google_group`, entitlement `read`, compiled provider effect `group:<email>` + `roles/bigquery.dataViewer` at dataset scope, one Feishu approval for one group/entitlement/many datasets, and reconciler-owned BigQuery IAM mutation. Request handlers and Feishu callbacks must not call BigQuery directly. Data-access will use a dedicated `kind='data_access'`, `provider='bigquery'` driver rather than generic CMDB/GCP cloud grants. Non-goals are explicit: no table access, no single-user access, no project IAM, no `bigquery.jobUser`, no broad BigQuery scan, no dataset creation, no visible manual dataset catalog UI, no Terraform runtime apply engine, no Feishu template authoring in Helix, and no declarative Google Group membership reconciler. The contract includes a grounded current-state inventory: email-group tables/sync own Google Group facts, approval owns Feishu transport, existing BigQuery billing code is evidence only, and CMDB/GCP cloud grants are a forbidden runtime path for DAX. It also freezes two authority matrices: `email_groups.id` is the semantic Google Group principal key while `google_group_id` is the rename-continuity anchor and `group:<primary_email_snapshot>` is compiled provider output; `data_access_resources.owner_person_id` is the only runtime resource-owner authorization source, with bootstrap unresolved-owner/fallback and cutover owner-mismatch cases failing closed. The security planning bar requires every follow-up PR touching assets, actors, trust boundaries, credentials, callbacks, authorization, provider mutation, observability, or rollout gates to satisfy the relevant row with tests or a named implementation note. Catalog cutover requires a cleanup report/apply pass: bootstrap rows can be suspended from new authoring when unmatched, owner mismatches suspend new authoring until admin resolution, unresolved owner/fallback cases block cutover completion, and provider grants must not be revoked solely because a bootstrap row failed to match catalog. As of this entry, no router, tables, migrations, scheduled jobs, or driver have shipped yet; later DAX issues must update this map as those surfaces land.
>
> **TLS/SSL certificate inventory + expiry alerts (PR1/3)** (2026-06-09, **migration 267** / +1 MCP / **+2 cron** / 0 RBAC / **+1 platform setting**). New CMDB sub-domain tracking the TLS certificates hosts actually serve — the gap left by domain-only (`cmdb_domains.expiry_date`) + DNS tracking. **Tables**: `cmdb_certificates` (provenance `source=probed|cloud|manual`; idempotent upsert key `dedup_key` = `probed:{host}:{port}` / `cloud:{integration}:{cert_id}` / `manual:{uuid}` — a renewal moves `fingerprint_sha256`+`not_after` but keeps the dedup_key so the row updates in place and the moved-forward `not_after` auto-clears the alert) + `cmdb_cert_expiry_alert_state` (per-cert dedup, sibling of `cmdb_domain_expiry_alert_state`). **Active probing** ([`cert_probe.py`](../../server/app/domains/cmdb/cert_probe.py)): `probe_certificate(host,port)` does a thread-pooled TLS handshake (`CERT_NONE`/`check_hostname=False` — must read expired/self-signed certs) and parses via `cryptography`; target list built by [`cert_service.build_probe_targets`](../../server/app/domains/cmdb/cert_service.py) from enabled-tenant DNS (A/AAAA/CNAME) + domain apexes (dedup, **env inheritance only — owner is NOT snapshotted as of migration 360**, see Owner model below; wildcard/MX/disabled skipped). Unreachable hosts → `status=unreachable`, **never `expired`**, and no row is invented for an unreachable host. **Crons** (both in [`scheduled_jobs/handlers.py`](../../server/app/scheduled_jobs/handlers.py), seeded by migration 267): `cmdb.cert_probe_sweep` (daily 03:00 UTC, bounded concurrency + per-host timeout, data collection — NOT gated by the kill switch) and `cmdb.cert_expiry_alert` (daily 04:00 UTC; gated by `network.user_notifications_enabled`). **NOTE (migration 362): the alert cadence/recipients below were rewritten by the graduated engine — see "Graduated alert engine — theme PR2/2" at the end of this entry. The current buckets are `<=30d/<=14d/<=7d/expired` with daily sends inside ≤7d, NOT the original `<=30d/<=7d/<=1d/expired`.** **网络负责人 fallback** ([`network_notifications.resolve_network_owner_id`](../../server/app/domains/cmdb/network_notifications.py) + platform setting `network.owner_person_id`, best-effort seeded to 孟凡生): owner-less cert/domain/DNS expiry alerts fall back to a single configurable "network owner" person — deliberately a designated person, NOT a 9th canonical RBAC role (see DECISIONS.md). **Surfaces**: HTTP `GET/POST/PATCH/DELETE /cmdb/certificates` + `/stats` (owner-demoted for non-admins like servers/domains — certs leak hostnames/SANs; **NB: DNS is NO LONGER owner-demoted as of 2026-06-09 — it's a company-public asset, see DECISIONS**); MCP `list_certificates` (`_REGISTERED_TOOLS_COUNT_HINT`=257 post-merge); FE `CloudCertificatesPage` at `/admin/cloud/certificates` + `/my/cloud/certificates`. **PR2 (shipped)**: cloud cert-manager sync — `ProviderCapability.CERTIFICATES` + `CloudCertificate` dataclass + `list_certificates()` on AWS (ACM, per-region), Aliyun (CAS `DescribeUserCertificateList`), Tencent (SSL `DescribeCertificates`); `sync._sync_certificates` upserts `source='cloud'` rows (dedup `cloud:{integration}:{cert_id}`, `last_seen_sync_job_id` drives `_safe_sweep` which only touches this integration's cloud rows — probed/manual rows have NULL integration_id and survive). Capability is opt-in per integration. **PR3 (shipped)**: `cert_expiry_alert` collapses rows of one logical cert to a single alert, keyed on the **covered-name set (CN + sorted SANs)** — the identity probed + cloud rows reliably share (a probed leaf always has a SHA-256 but ACM/Tencent list calls expose none, so a fingerprint-first key would never collapse the realistic probed+cloud pair; fingerprint is a fallback only when the cert carries no names). Representative = lowest id (stable across runs regardless of owner churn, so the per-cert alert-state row doesn't flip and reset first-observation suppression); routing uses an owner-override (best owner in group) without mutating the ORM owner. `CloudCertificate` carries an optional normalized `fingerprint_sha256` (Aliyun CAS when it's a 64-hex SHA-256; AWS/Tencent None) threaded through `_sync_certificates`. NetworkOverviewPage gained a 证书 tab embedding `CloudCertificatesPage embedded`; `cloud-certificates` pageHelp topic registered. Probe `_fetch_der` SSRF-guards via `_url_safety._is_blocked_ip` (skips private/loopback/metadata IPs) + bounds the TLS handshake with `sock.settimeout`. Also fixed a first-run crash: the first-observation `first_seen_at` compare coerces the naive fresh-load datetime to UTC. (Superseded by the graduated engine below — both cert AND domain alerts now share the 30/14/≤7-daily/expired-daily cadence.) **Owner model — live resolution (2026-06-29, migration 360, cert-expiry-governance theme PR1/2)**: a certificate no longer holds its own owner snapshot. [`cert_owner_resolver.CertOwnerResolver`](../../server/app/domains/cmdb/cert_owner_resolver.py) resolves the responsible chain live at read/alert time — **manual override (`owner_is_manual`) → `probe_host` reverse-mapped DNS record owner (本人) → zone domain owner → network-owner fallback** — exposing `owner_source` (manual/dns_record/domain/network/none) + `domain_owner_person_id` (域名负责人 = the escalation target distinct from 本人). `build_probe_targets`/`upsert_probed_certificate` stopped writing owner; the `owner_person_id` column survives only as a deliberate admin override gated by the new `owner_is_manual` flag (migration 360 sets it true for `source='manual'` rows; legacy probed/cloud snapshots stay in the column but dormant — resolver ignores them). `list_certificates` resolves in memory then filters owner / employee-manager scope on the **resolved** owner (returns `(rows,total,resolution_map)`); router list/detail/create/patch + scope + MCP `list_certificates` all route through the resolver; `/stats` gained `ownerless`. FE `CloudCertificatesPage` shows 本人 + provenance badge + 域名负责人 + a 无责任人 KPI; the owner edit field is framed as a manual override (set = force, clear = back to auto-resolution). **Graduated alert engine — theme PR2/2 (2026-06-29, migration 362)**: the weak "one alert per coarse bucket transition, owner only" model is replaced by a SHARED engine ([`expiry_alert_engine.py`](../../server/app/domains/cmdb/expiry_alert_engine.py)) over BOTH certs and domains. **Cadence**: T-30 once → T-14 once → DAILY inside ≤7d → DAILY while expired (30-day lookback). **Escalation**: inside the last-mile window (≤7d / expired) the fan-out adds the 域名负责人 (zone domain owner) + the lead (本人's direct manager) on top of 本人; T-30/T-14 stay owner-only. **First-enable storm guard**: `cmdb_expiry_alert_state.escalation_started_on` (non-null ⇒ entered the window under the cron's watch ⇒ daily-eligible; NULL ⇒ backlog seeded at first-enable ⇒ suppressed), plus a fresh+urgent exception (resource discovered <24h ago alerts on first sight, else a born-expired cert never transitions). **Acknowledge/snooze**: "标记续期中" (in-app button or the Feishu card's action-token magic link → public `/cmdb/expiry/ack` landing → `acknowledge_expiry`) sets `acknowledged_until`, suppressing alerts + escalation through that date. The unified `cmdb_expiry_alert_state(resource_type, resource_id, …)` (migration 362) replaces the old per-type `cmdb_cert_expiry_alert_state` / `cmdb_domain_expiry_alert_state` tables (both empty in every env — kill switch short-circuits the write path — so the drop is safe; downgrade recreates). Item builders are cmdb-domain (`build_cert_expiry_items` via `CertOwnerResolver` + name-set dedup + soonest-expiry; `build_domain_expiry_items` + network-owner fallback); the SENDER (`_make_expiry_notifier`: governed outbox, in-app bell + Feishu card with ack/detail buttons) is in the scheduled_jobs layer. `run_cert_expiry_alert` / `run_domain_expiry_alert` are thin delegations. HTTP `POST /cmdb/expiry/{type}/{id}/acknowledge` (authed, scoped admin/owner/domain-owner/lead) + `POST /cmdb/expiry/ack?token=` (no-login action token); MCP `list_my_expiring_resources` + `acknowledge_expiry_renewal` (hint 386). **Still gated by `network.user_notifications_enabled` (off) — prod behaviour unchanged until ops enable.** **Production-environment opt-out (2026-07-21, fix)**: both item builders skip records tagged `environment='prod'` (`build_cert_expiry_items` drops a logical cert if ANY probed/cloud member is prod-tagged; `build_domain_expiry_items` skips prod domains) — prod TLS/registrar renewal is owned outside Helix self-service, mirroring the prod cloud-access opt-out (`PROD_ENVIRONMENTS`). Single chokepoint: the two builders also feed MCP `list_my_expiring_resources`, so no surface pages an employee about a prod resource. Only the **explicit** `environment='prod'` tag suppresses; untagged records (env unset) are unaffected.
>
> **CONVENTION — every subordinate/team resolution goes through the canonical `person_scope` resolver; never hand-roll a manager-tree BFS** (2026-06-08, 0 migration / 0 cron / 0 schema / 0 RBAC / 0 MCP). The person lifecycle is three orthogonal axes — `employment_status` (active/leaver_pending/left/pre_hire) × `deleted_at` × `hris_record_state` — and "who counts as one of my reports?" was re-derived inline at ≥7 surfaces, each with its own `employment_status` predicate; they drifted, producing the recurring "departed report still shows in 我的团队/团队资源/全局搜索/关系图谱" bug class (#782→#784→#786→…) and, inversely, a mobile helper with **no** leaver filter at all. Single source of truth is now **[`app/core/person_scope.py`](../../server/app/core/person_scope.py)**: `subordinate_person_ids(session, manager, *, scope, max_depth)` (the only manager-tree BFS — bounded, cycle-safe, returns `list[uuid.UUID]`, **traverses through** non-deleted nodes so a leaving intermediate manager never hides their active deeper reports, only set membership is scope-filtered) + composable predicate `employment_status_clause(scope)` + `includes_status()`. `SubordinateScope`: **MANAGED** (exclude `left`; keep `leaver_pending` — the "current team" view incl. offboarding-window), **ACTIVE_ONLY** (exclude `left`+`leaver_pending` — resource/cost attribution), **ALL** (no employment filter, `deleted_at IS NULL` only — forensic / cloud-resource visibility with its own `include_left` toggle). Migrated callers: `rbac.get_subordinate_person_ids` (thin `scope=ALL` wrapper, still returns `str` for its 20+ cmdb/mcp/permission callers), `mobile/aggregate`, `dashboard/router`, `me_universe/team_service`, `asset/router`+`asset/service`, `provisioning/router`. **CI guard** [`scripts/check_subordinate_helpers.py`](../../server/scripts/check_subordinate_helpers.py) (wired into `ci.yml` next to the alembic-chain + scheduled_jobs guards) rejects any inline `IdentityPerson.manager_person_id.in_(...)` outside `person_scope.py`; deliberate non-BFS exceptions (single-hop manager checks, the onboarding-cockpit skip-level subquery) carry `# person-scope-exempt: <reason>`. Tests: [`test_core/test_person_scope.py`](../../server/tests/test_core/test_person_scope.py) + [`test_subordinate_guard.py`](../../server/tests/test_core/test_subordinate_guard.py). This is the sibling of the `org_scope` department-subtree convention below. Memory [[reference_leaver_display_invariants]].
>
> **CONVENTION — every person reference renders through `PersonTag` with `employeeNo` + `employmentStatus`; departed people show 姓名(工号) + 已离职** (2026-06-18, 0 migration / 0 cron / 0 RBAC / 0 MCP). The display-side sibling of the `person_scope` filter convention above: where `person_scope` decides *whether* a person appears, this decides *how* they look once shown. [`web/src/components/PersonTag/PersonTag.tsx`](../../web/src/components/PersonTag/PersonTag.tsx) takes optional `employeeNo`/`employmentStatus` props (or reads `employee_no`/`employment_status` off a `person={}` blob); when [`utils/format.ts::isDepartedStatus`](../../web/src/utils/format.ts) is true (`employment_status ∈ {left, terminated}`) it renders the name struck-through + muted, inlines `姓名(工号)` (degrades to bare 工号 when the name is missing or *is* the 工号 — the legacy 易盘点 import case), and appends a `已离职` tag; the 工号 is always in the hover tooltip. Active people are unchanged (name only — no added noise). **Backend contract**: any serializer that denormalizes a person into `<prefix>_name`/`<prefix>_email` MUST also emit `<prefix>_employee_no` + `<prefix>_employment_status` from the same `IdentityPerson` row (`AuthUser` is an alias of `IdentityPerson`; `.email` aliases `work_email`), so the frontend never has to round-trip for the 工号/status. Wired across asset / cloud+cmdb / admin+audit+approval+permission / apikey / provisioning+onboarding+offboarding / hr-lifecycle / identity-sync / email-group / me-universe / auditor surfaces. Free-text actors with no Person FK (Feishu approver name strings, modifier labels, snapshot-frozen monthly-bill rows) are left as-is. Regression: `test_asset_api.py::test_operations_surface_leaver_employee_no_and_status`.
>
> **PRIMITIVE — backend person labels arrive pre-joined as `"Name · email"`; parse them with `splitPersonLabel`, never with an ad-hoc `.split(' · ')`** (2026-09-03, frontend-only, 0 migration / 0 cron / 0 MCP / 0 RBAC). ~20 serialisers ([`cmdb/router.py::_resolve_owner_labels`](../../server/app/domains/cmdb/router.py), the `owner_label` / `person_label` f-strings in [`cmdb/lifecycle_router.py`](../../server/app/domains/cmdb/lifecycle_router.py), [`cmdb/timeline_service.py`](../../server/app/domains/cmdb/timeline_service.py)'s `actor_label`, [`ai_spending/service.py::_person_label_map`](../../server/app/domains/ai_spending/service.py)) emit one joined string, **on purpose** — the same helper feeds the CSV exports, where one flat column is what you want. So the un-joining belongs on the display side, and there is now exactly one of it: [`utils/format.ts`](../../web/src/utils/format.ts) exports `splitPersonLabel(label) → {name, email}` (splits on `' · '` **with** the spaces, so a name containing a bare interpunct — 玛丽·居里 — survives; only a trailing segment that really looks like an email is taken, so a three-part `"{software} · {name} · {email}"` label keeps its prefix), plus two wrappers: `personLabelShort(label, lang)` (the string form of what PersonTag renders — for `Select` option labels, antd-mobile `List.Item extra`, a Tag inside a sentence) and `stripLabelEmail(label)` (drop the address, touch nothing else — for labels whose *dimension is not statically known*, e.g. an AI-spending breakdown row keyed by user OR software, where locale-aware name extraction would cut 「阿里云 DashScope」 down to 「阿里云」). `PersonTag` calls the parser itself, so **every** desktop call site was fixed by that one wire-up. Mobile has no hover, so it gets its own component instead: [`mobile/components/MPersonLabel.tsx`](../../web/src/mobile/components/MPersonLabel.tsx) — name on line one, address muted underneath, each clipped on its own line. **Why this is a primitive and not a one-off**: three ad-hoc splitters had already grown independently (`ScopeIdentityBar`, `ScopeActionRail::firstName`, and a half-done one in the resource-directory panel), each with its own edge-case bugs, and the *unsplit* sites were worse — under `en-US` **every** owner cell showed name-plus-address jammed together, because `extractLatinFragment` keeps the email (an email is Latin text) and only people with a Chinese `display_name` were spared. Tests: [`utils/personLabel.test.ts`](../../web/src/utils/personLabel.test.ts) (parser) + [`components/PersonTag/PersonTag.test.tsx`](../../web/src/components/PersonTag/PersonTag.test.tsx) (the wire-up — a green parser test proves nothing if PersonTag still reads the joined string).
>
> **CONVENTION — fire-and-forget background work goes through `app/core/background.py::spawn`, never bare `asyncio.create_task`** (2026-06-09, 0 migration / 0 cron / 0 schema / 0 RBAC / 0 MCP). asyncio only weakly references tasks from `create_task`, so a discarded-return-value task can be GC'd mid-flight and silently drop its work — the JML lifecycle fires several post-commit identity-sync pushes this way (hire Day-1 directory push, offboard push, hire-confirm push). [`spawn(coro, *, name=...)`](../../server/app/core/background.py) retains a strong reference in a module-level set until completion, then drops it and surfaces any exception through the logger. Migrated the 4 lifecycle call sites (`hr_lifecycle/hire_day_service`, `hr_lifecycle/advance_service`, `core/coordinators/hr_sync_coordinator`, `domains/leaver_blocked/router`). Memory [[feedback_worker_handler_imports]] is the cron-side sibling (handlers must be import-registered); this is the in-request/in-cron side (background coroutines must be ref-retained).
>
> **CI guard — TiDB-parity lint (`scripts/check_tidb_parity.py`)** (2026-06-08, 0 migration / 0 cron / 0 schema / 0 RBAC / 0 MCP). Third hard CI guard (sits next to the alembic-chain, scheduled_jobs-UUID-seed, and subordinate-resolution guards in the `ci.yml` backend-migrations job, before `alembic upgrade head`). Rejects three "passes on local CI MySQL, breaks on prod TiDB" classes: **(1) `sa.String(36)`-typed `ForeignKey` columns** in new migrations — TiDB error 3780 (CHAR↔VARCHAR FK incompatible; `identity_persons.id`/`asset_software_catalog.id` are CHAR(36) on prod but VARCHAR(36) on CI MySQL); fix = `app.core.migration_helpers.id_type_for(parent)`. Detection is balanced-paren per-`sa.Column(...)` so a non-FK `String(36)` column (~90%) is NOT flagged. 7 historical migrations grandfathered (011/012/013/042/072/074/084). **(2) `.nullslast()`/`.nullsfirst()`** — error 1064 (no NULLS LAST/FIRST); emulate with `order_by(col.is_(None), col.desc())`. 0 current occurrences (pure prevention). **(3) `func.timestampdiff("UNIT", …)`** bare string-literal unit — error 1064 (binds as `%s`); wrap in `literal_column("MINUTE")`. Inline exemption `# tidb-parity-exempt: <reason>` (on/within the offending span or two lines above). Pure `scan_text(kind, text)` exposed for unit tests; [`test_core/test_tidb_parity_guard.py`](../../server/tests/test_core/test_tidb_parity_guard.py) (18 cases). **Kind (1b) added 2026-07-30 — bare `CHAR(36)` + FK** (same error 3780, different cause: *collation*, not data type). A column without an explicit `COLLATE` inherits the TABLE default, which is safe only while every FK in that table points at parents sharing one collation. Migration 437 mixed parents — table default `utf8mb4_bin` from `cmdb_cloud_access_grants`, four columns FK'ing to `identity_persons`' `utf8mb4_unicode_ci` — and the deploy died on `fk_3` after migration 436 had already committed; local/CI MySQL has one collation everywhere and cannot reproduce it. **`id_type_for` now returns the parent's collation with its type**, so all 79 helper-using migrations get the mixed-parent case right without the author knowing it exists; `sa.CHAR(36, collation=...)` and non-FK bare `CHAR(36)` (a PK is nobody's child) are not flagged; 015/037 grandfathered (parent created in the same file). Reproduced on real MySQL by [`test_fk_collation_mixed_parents.py`](../../server/tests/test_alembic/test_fk_collation_mixed_parents.py), which also replays the unmodified `437.upgrade()` against prod-shaped parents. Memories [[feedback_migration_tidb_fk_checklist]] / [[feedback_mysql_no_nulls_last]] / [[feedback_timestampdiff_literal_column]]. **Kind (1c) added 2026-09-05 — `sa.Uuid()` as a DDL column type in a migration.** SQLAlchemy's own `Uuid` is CHAR(32) on MySQL, but in this repo `Uuid` *means* CHAR(36): every model imports it as `from app.core.sql_types import GUID as Uuid`. The name therefore reads correctly in a model and betrays you in a migration, where `sa` is the real SQLAlchemy. Migration 612 shipped `sa.Column("person_id", sa.Uuid())` against CHAR(36) `identity_persons.id` and **wedged the prod chain at 611** — every later migration blocked behind it — while its own tests stayed green, because test schemas are built from ORM metadata where parent and child are *both* the CHAR(36) alias and agree; the mismatch exists only against the real chain. Unlike (1)/(1b) this kind ignores whether the column is an FK: a CHAR(32) column the ORM addresses as CHAR(36) is already wrong, PK included. Scoped to `op.create_table`/`op.add_column`, so a lightweight `sa.Table(...)` built for a bulk INSERT (migration 545) is not flagged. Fixed forward in 612 itself with `id_type_for` + `table_kw_for` — safe to edit in place because the migration failed identically in every environment and had therefore never applied anywhere.
>
> **Reconciliation leaver-residual lens covers procurement + office-admin; onboarding zero-task is audited** (2026-06-08, 0 migration / 0 MCP / 0 cron / 0 RBAC / 0 schema). Two verified leaver-lifecycle coverage holes closed. **(a)** `app/domains/reconciliation/service.py::leaver_residual` (the `/admin/reconciliation/leaver` forensic lens) now also surfaces a `left` person's **in-flight** procurement contracts/orders (`procurement_contracts`/`procurement_orders` `business_owner_id`/`procurement_owner_id`, excluding terminal `terminated`/`expired`/`completed`/`cancelled`/`rejected`) and **office-admin** roles (`asset_office_admins.person_id` — then the table `asset_admin` was auto-derived from). Since migration 535 that lens row is `resource_type='location_admin'`, reading `asset_location_admins`, and a second `office_local_admin` row covers the departed 当地 Admin — which grants nothing but is still who a new hire would be told to find. These were invisible to both the offboarding blocker scan (21 resource types) and the prior lenses — read-only detection only; the fix-action stays on the relevant admin pages. `reconciliation.leaver.resourceType.{procurement_contract,procurement_order,office_admin}` i18n ×3. **(b)** `provisioning/service.py::execute_onboarding_for_person` now emits a `provisioning.onboarding_no_tasks` audit row (reason ∈ `no_matching_policy` / `policy_without_resolvable_target` / `all_targets_already_provisioned`) when a hire creates zero provisioning tasks — previously silent, so a hire matching no `ProvisionPolicy` left no trace. **Audit-only honest non-fixes** (rigorously verified false positives, NOT changed): actor-eligibility guards are correct + consistent (`!= active` receiver vs `not in (left,leaver_pending)` grantee are two intentional policies); dual-sign secondary is validly signed while active (no execute-time re-check); `default_handover_person` IS re-validated at force-transfer (advance_service:1853-1866); rehire-stale `force_logout_at` is a past timestamp that cannot block a future-issued token. The transition state-machine (`@validates` + `LeaverBlockGuard` + `before_flush` listener) was judged mature enough that a central transition-choke-point refactor was NOT worth its blast radius.
>
> **AI 大盘折扣前(原价/gross)成本 — PR1+PR2** (2026-06-06, **migration 259+260** / 0 MCP / 0 cron / 0 RBAC). AI 大盘金额一直是**折扣后**(各 provider 的应付净额)。新增**折扣前**轴,端到端贯通 `BucketRow.cost_usd_gross → _buckets_from_rows → AiSpendingBucket.{amount_gross,discount_amount,discount_pct,gross_coverage_pct}`(折扣率带符号不 clamp;覆盖率 = 桶内能溯到原价的折后额占比;`gross_coverage_pct<100` 时折扣率只针对可溯部分,大盘不伪造完整原价)。前端 `BucketPod` 加「原价 · 省 X%」折扣行,gross=NULL 不渲染。各桶 gross 来源与点亮状态:
> - **Bailian 取数(2026-06-17 重订,migration 299;此前 2026-06-07 用 QueryBillOverview)**:百炼正确源 = BSS **`DescribeInstanceBill`**(`Granularity=DAILY`,`ProductCode='sfm'`)。**关键实证(2026-06-17,推翻旧结论)**:其 `InstanceID` 是复合字段 **`apiKeyId;workspaceId;model;tokenType;channel;0`**(首段=全局 ModelStudio API Key id,对得上 `bailian_api_keys.api_key_id`),所以**能按「日 × 账号 × API Key × 模型」分账**——旧注「DescribeInstanceBill 只 $354 子片」是当年参数/结论下早了(合并计费下主账号 AK 看得到全 RD 成员的 sfm 用量)。逐 Key 求和对账到分:2026-05/2026-04 净额与 `QueryBillOverview` **完全一致**,2026-06=$11.7237=控制台 $11.72。`query_instance_bill_daily` 按天查、筛 sfm 消费行(gross>0)、把 token-type/channel 折叠、聚合**三档**(原价=PretaxGrossAmount / 优惠后=原价−InvoiceDiscount / 应付=PretaxAmount),`_to_usd_cny` 按 Currency 自适应。`sync_usage_daily` 把 raw key/workspace id 解析成 `api_key_pk`/`workspace_pk`/owner 落库(`OwnerID`→`aliyun_account_id`);唯一键 `(integration_id, usage_date, aliyun_account_id, bailian_api_key_id, model_name)`;月度范围内清旧 `aggregate` 行防双算。三档仍贯通 `cost_*_after_discount`→`AiSpendingBucket`→前端。**归属**:`list_usage_daily` 优先 API Key 负责人、其次账号负责人(COALESCE);`usage_summary` 加 `by_api_key`、`by_owner` 改 OUTER。**已知边界**:落在 Helix 未纳管子账号下的 Key(`ListApiKeys` 跨账号看不到 → `api_key_pk=NULL`)只能归到账号、暂无法落到人(本月 98.8% 花费的 `132245` 即此例),纳管该成员账号后自动落人。回填=范围回填(`days≤430`,每天一次 DescribeInstanceBill DAILY 调用)。性能同前(`_load_period_rows` rollup-first)。
> - **AWS Bedrock**(PR2,migration 260):`cmdb_cost_facts` 加 `amount_usd_gross`/`amount_native_gross`;`CostFact`→`cost_service`→fact 列贯通;AWS BQ CUR 查询加 `SUM(pricing_public_on_demand_cost)`(按 `pick.columns` 守卫缺列投影 NULL)。回填=重跑 `cmdb.cost_facts_sync`。✅ 代码就位,部署后随 sync 点亮。**口径**:EDP/Private Rate 折扣是独立行(被桶 service 白名单过滤),Bedrock 桶内折扣只反映行级 RI/SP/私有费率,不含组织级 EDP。
> - **Azure Foundry**(follow-up,2026-06-06,**0 migration**):✅ **已点亮(前向)**。CM `query` API 无任何零售价聚合(只出折后 `Cost`/`PreTaxCost`/`CostUSD`),`paygCostInBillingCurrency` 仅在 per-meter 的 Cost Details report、聚合 grain 拿不到、历史不可回填。实现:给**同一条** CM query 增 `MeterId` 分组 + `UsageQuantity` 聚合,join 公开免认证 **Azure Retail Prices API**(`prices.azure.com`,按 `meterId` GUID 取 tier-0 `Consumption` `retailPrice`,USD)→ `gross_usd=Σ(数量×PAYG 单价)`,算完**折叠回 canonical fact grain**(净 fact 及 fact_key 逐字节不变)。净路径零风险:元价查询全 try/except 失败退 NULL;增维 query 被拒(400)→ 自动回退基础 query;kill-switch `cost_gross_pricing:false`。诚实护栏:gross<net 或 net≤0 退 NULL。历史聚合行仍 NULL(`gross_coverage_pct` 如实)。详见 [azure_cloud.py](../../server/app/integrations/drivers/azure_cloud.py) `list_cost_facts` + `_fetch_retail_prices_usd`。
> - **GCP Vertex**(follow-up,2026-06-08,**0 migration**):✅ **已点亮(前向)**。承接 #756 的净额化(`amount_*` 已 = `cost+Σcredits`),把 credits-前的 list-price gross 从 `CostFact.raw` 提升到 `amount_usd_gross`/`amount_native_gross`(口径:原价=目录成本、优惠=SUD/CUD/承诺用量/赠金 credits、实付=净额)。诚实护栏同 Azure:`net≤0` 或 `gross<net`(正向调账/FX 异常)退 NULL。无 schema 变更(列 #260 已建)、无 SQL 变更(gross 早已算出),`cost_service` upsert 既有 gross 列贯通。回填=重跑 `cmdb.cost_facts_sync`(列就位,部署后随 sync 点亮)。见 [gcp_cloud.py](../../server/app/integrations/drivers/gcp_cloud.py) `list_cost_facts`。**至此 4 桶(百炼/Bedrock/Foundry/Vertex)原价口径全部点亮,仅 AI 坐席为固定订阅价无此口径。**
> - **前端卡片(2026-06-08,方案 A「财务清单」重做)**:`BucketPod` 由早期一行 "原价 · 优惠后 · 省X%" 串改为**对账式明细**——主数值=实付(打标签),下方 `原价 → 优惠(省% 绿标 + 省额)→ 实付(合计行)→ 上月` 逐行左标右值对齐,环比做右上角标,页脚=每卡统一的本地化「数据说明」`Popover`(`aiSpending.bucket.caveat.<key>` zh/en/ja,逐桶讲来源/口径/归属)+ 行·主体计数。后端 `data_caveat`(英文)保留供 CSV 导出,前端展示一律走 i18n。有 gross 的桶(4 桶)自动出现原价行,无 gross(坐席)只展示实付。
> - **性能 PR3**(migration 261+262,+1 cron):新表 `ai_spending_period_rollup` 物化闭月 canonical BucketRow 全维度集(BigInteger PK、无 FK、索引 `(period_start,period_end_excl,whitelist_fingerprint)`)。`_collect_period_rows` 改 read-through:L1 进程缓存 → 闭月查物化表(指纹匹配)→ miss/开月实算;开月从不物化,读路径绝不写(仅 cron 写,无竞争)。指纹=`whitelist+FX 汇率+AI 套餐 price/currency/cycle 摘要+语义版本` sha256(内容派生 → 改白名单/汇率/AI 套餐价自动失效闭月物化;**FX 必须进指纹**:seats 桶用实时汇率换 USD 非冻结,review 修正了最初"FX 不进"的错误判断;**AI 套餐 price/currency/cycle 同理必须进指纹**(2026-06-08,`_seat_plan_fingerprint`):seats 桶每次实时读 `software_license_plans` 的 `category='ai'` 行换算,改套餐价/币种/计费周期若不翻指纹,大盘会一直吐改前旧坐席 USD —— 当初 OPEN AI Team 币种误录 CNY 修成 USD 后大盘不更新就是这个漏)。cron `ai_spending.period_rollup_refresh`(`30 6 * * *` UTC,在 cost_facts_sync/bailian 之后;handler 在已导入的 ai_spending_handlers.py 内;失败 raise;最近 2 闭月每跳必算吸收迟到 facts,更早缺指纹才补算)。根治每请求扫 270 万行 cmdb_cost_facts。
> - **收尾 PR4**:`AiSpendingOverview` 加 `grand_total_gross`/`grand_discount_pct`/`gross_coverage_pct`(纯函数 `_sum_gross_usd`,covered_net 作折扣分母);"Total AI spend" KPI footer「折扣前 $X · 省 Y%(覆盖 Z%)」;export.xlsx bucket sheet 加 list price/discount/coverage 三列;/help「折扣前 vs 折扣后」段(税前口径 + 覆盖率 + 各桶点亮状态)。**折扣前多桶重做 PR1-4 收官**。
>
> **安全合规 post-launch 复审修复 + 访问认证自动撤权 + cert/incident MCP twins** (2026-06-05, **migration 257** / **+4 MCP tools → hint 251** / **+1 cron** / 0 RBAC). Fixed a tab-load failure and, while auditing the whole module, 6 more real defects, then closed the two gaps the audit surfaced (revoke fulfilment + missing MCP twins). **Router-collision (the trigger)**: `main.py` bound `compliance_router` twice (own `app.compliance.router` = `/compliance-incidents` vs `app.domains.auditor.compliance_router` = `/compliance`); the second import shadowed the first so the data-freshness incident endpoints never mounted (前端「合规事件」tab 404). Renamed the auditor one `auditor_compliance_router`. **Anti-recurrence**: when adding a router, grep `main.py` for an existing same-named alias — a duplicate `import ... as X` silently drops the earlier router with no error. **Incident upsert** ([`compliance/service.py`](../../server/app/compliance/service.py)): `create_or_upsert_incident` dedup now matches `status IN (open, accepted)` — an **accepted** ("known exception") incident is refreshed in place and never re-opened by the next sweep (it used to spawn a fresh `open` ticket every cycle, defeating accept); `resolved` stays terminal/re-openable. **CCM upsert idempotent** ([`compliance_service.py`](../../server/app/domains/auditor/compliance_service.py) `recompute_control_status`): read-then-insert → `mysql_insert(...).on_duplicate_key_update` on `UNIQUE(control_id)`, so the manual button + hourly cron can't race into a duplicate-key 500 on first seed. **access_certification control** now reads `latest_campaign_status(campaign_type="software_seat")` (was any-type → the quarterly `privileged_role` campaign masked a missing user-access review = false PASS). **decide_finding input bounds** ([`findings_service.py`](../../server/app/domains/auditor/findings_service.py)): note(1–2000)/accepted_days(1–365) enforced at the **service layer** so the MCP twin is as safe as the Pydantic-validated HTTP route; MCP `decide_security_finding` now propagates trace_id. **DepartmentTreeSelect** value/onChange made optional so it drops into `<Form.Item name>` (Antd injects them) — removed a double-binding anti-pattern in CertificationsPage. **Cert detail page** got real server-side pagination + decision filter + complete-gate from backend counts (was page-1-only ≤200 → a >200-item campaign could never be completed) + a 撤权状态 column.
>
> **Access-cert revoke fulfilment (the IGA loop's missing half)** — a `revoke` decision used to only record `revocation_status='pending'`; the access was never actually removed. New coordinator [`access_certification_coordinator.py`](../../server/app/core/coordinators/access_certification_coordinator.py) is the cross-domain seam (auditor must not import asset/provisioning): on `decide_item(revoke)` it mirrors the admin seat-revoke path — flips `software_seat_allocations.status='revoked'` (Helix-authoritative) + `enqueue_revoke_for_seat(source='audit')` (inherits the provisioning queue's idempotency/retry/audit), storing the task id in `revocation_trace_id`. The decision (attestation) is committed even if the enqueue hiccups (left `pending` for the cron) so a vendor blip never loses the reviewer's call. New cron **`auditor.access_certification_revoke_fulfil`** (every 5 min, [migration 257](../../server/alembic/versions/257_access_cert_revoke_fulfil_cron.py) seeds it, `auditor_handlers.py`) reconciles: re-enqueues `pending` items whose enqueue never landed, and mirrors the provisioning task terminal state (completed/notified→completed, failed/cancelled→failed) back onto the item. `revocation_status` lifecycle: `none → pending → completed|failed`. Only `software_seat` is auto-enforced today (the only snapshot type); other access_types stay `pending` for manual handling. **MCP-sync (+4)**: `resolve_compliance_incident` (1:1 `POST /compliance-incidents/{id}/resolve`, `require_admin`) + the 3 cert write twins `create_access_certification` / `decide_access_certification_item` / `complete_access_certification` (`can_manage_security`, input bounds mirroring the HTTP Pydantic schemas) — the cert/incident MCP surface was read-only before. Regressions: cert decision→seat-flip+enqueue, fulfil mirrors task terminal state ([test_access_certification.py](../../server/tests/test_auditor/test_access_certification.py)), and employee-refused/admin-admitted RBAC on every new write tool ([test_compliance_tool_rbac.py](../../server/tests/test_mcp/test_compliance_tool_rbac.py)).
>
> **Frontend IA collapse 16→4 + unified worklist — 「安全合规」可用化 PR-C** (2026-06-05, frontend-only / 0 migration / 0 MCP / 0 cron / 0 RBAC). The 安全合规 menu went from **16 items to 4** (both the admin and auditor menu trees in [MainLayout.tsx](../../web/src/layouts/MainLayout.tsx)): **合规总览** (`/compliance`), **控制项目录** (`/compliance/controls`), **访问认证** (`/compliance/certifications`), **证据与事件** (`/compliance/evidence`). The 8 detective pages are now **drill-ins from the control catalog** (each catalog row → its `/auditor/*` page via a `CONTROL_KEY→route` map in [ComplianceControlsPage.tsx](../../web/src/pages/compliance/ComplianceControlsPage.tsx); wrapped in [`ControlDrillLayout`](../../web/src/pages/compliance/ControlDrillLayout.tsx) for a back-to-catalog link — they're no longer top-level menu items). The legacy `/auditor` dashboard redirects to `/compliance`. **合规总览 is now a tabbed landing** ([CompliancePosturePage.tsx](../../web/src/pages/compliance/CompliancePosturePage.tsx)): **待办 (hero, default)** = [`FindingsWorklist`](../../web/src/pages/compliance/FindingsWorklist.tsx) — the unified cross-control inbox (severity-ranked, severity/control/open-only filters, inline decide drawer: acknowledge / accept_risk[+days] / resolve / reopen, choices narrowed per source lifecycle) over the PR-A `/auditor/findings` API (new client fns `listFindings`/`fetchFindingsSummary`/`decideFinding` in [auditor.ts](../../web/src/api/auditor.ts)); plus tabs 合规态势 / 风险评分 (embeds RiskScorePage) / 安全洞察 (embeds InsightsSectionPage). **证据与事件** = evidence packs + compliance incidents as tabs ([EvidenceAndIncidentsPage.tsx](../../web/src/pages/compliance/EvidenceAndIncidentsPage.tsx)). **Ejected to 后台管理**: 通知队列 / 安全提示 / 制度公告 (back-office content config, not compliance review). This completes the 3-PR usability redesign (A: finding model, B: producers+posture, C: IA). i18n zh/en/ja for all new keys.
>
> **Native finding producers + posture wiring — 「安全合规」可用化 PR-B** (2026-06-05, 0 migration / 0 MCP / 0 cron / 0 RBAC). Completes the worklist: the 4 native controls now emit findings via [`findings_service.py`](../../server/app/domains/auditor/findings_service.py) `reconcile_{mfa,secret,cert,pat}_findings` (called from each sync after flush) — MFA (per active member×provider unenrolled; enforced→high; **leavers excluded**; MFA posture stays coverage-%-based, findings are the per-person worklist), secret (overdue→high), cert (expired→critical / 30d→high / 60d→medium), PAT (any risk_flag; owner_left/broad_admin→high). **Posture now derives from findings**: `compliance_service._evaluate_controls` reads `count_open_by_control` (open+acknowledged) for the 5 binary controls (pat_audit / public_exposure_scan / secret_rotation / cert_expiry_monitoring / signin_anomaly_monitoring) instead of raw `DashboardService` counts — so **accept_risk / resolve on a finding turns the control green** (decisions are honored). `mfa_coverage` (95% threshold) + `endpoint_risk_scoring` (critical-count) keep their own logic. Frontend swaps to the unified 待办 worklist in PR-C. **Cutover backfill** ([migration 256](../../server/alembic/versions/256_backfill_native_findings.py), PR-D): native findings only populate when each daily sync next runs, so 256 `INSERT IGNORE`s current violations from the 4 raw tables (dedup_key = `make_dedup_key`) — without it, posture would false-PASS those controls between deploy and the next sync (≤24h). PR-D review fixes also made sign-in `acknowledge` terminal-resolved (two write paths were inconsistent), gated `reopen` of auto-resolved findings, and scoped accept_risk expiry to native findings only.
>
> **Unified findings — 「安全合规」可用化重设计 PR-A** (2026-06-05, migration 255 / +2 MCP tools / 0 cron / 0 RBAC role). The cross-control **actionable worklist**: one `auditor_findings` table giving every detective control a uniform lifecycle (open → acknowledged → accepted_risk → resolved) so a single inbox spans all controls (the thing that made 安全合规 usable — before, only public-exposure + sign-in had any open-item notion). **Table** [`auditor_findings`](../../server/app/domains/auditor/models.py) ([migration 255](../../server/alembic/versions/255_auditor_findings.py), CHAR(36) + `id_type_for`/`table_kw_for`): `control_key` (ComplianceControl.key, NOT a FK so a finding outlives a disabled control) + `finding_type` + `dedup_key` (`UNIQUE(control_key,dedup_key)` idempotent upsert; >255 chars → sha1) + severity + `status` + `first/last_seen_at` + `decided_*` + `accepted_until` (accept_risk auto-reopen on expiry) + `auto_resolved` + `source_table`/`source_id` (mirrored controls). **Auto-resolve is gated on OBSERVATION, never on wall-clock alone** (#2217/#2231, 2026-07-31): a finding may only be auto-resolved by a round that actually covered its source. The three upsert-only ledgers (secret rotation / cert expiry / PAT) sweep rows nothing has re-stamped for `LEDGER_STALE_AFTER` (3d), but that sweep now takes `scanned_sources` — the list the calling sync built from providers that answered — and exempts every other source; `None` (manual reconcile) keeps the plain wall-clock behaviour, `[]` (total outage) sweeps nothing. The original safety argument ('a stopped sync fails loudly, #2136') was false for these three: `_raise_if_systemic_failure` deliberately does not alarm on a PARTIAL multi-provider failure, and its zero-progress branch is structurally unreachable for two of them (both open with an internal `HELIX_APIKEY` segment that ~cannot fail; CertExpiry unconditionally re-stamps manual rows). Same rule one layer up in the exposure scanner: a source enters `sources_scanned` only when the round covered it COMPLETELY, and since findings there are keyed per ATTRIBUTE, **either** S3 probe failing (`get_bucket_acl` / `get_bucket_policy_status` — AccessDenied or SlowDown) marks that bucket partial, not just both failing. **Engine** [`findings_service.py`](../../server/app/domains/auditor/findings_service.py): `reconcile_findings` (idempotent per sync — create / re-seen bump / disappeared → auto-resolve / native: preserve operator decisions & reopen expired accept_risk; `source_managed`: mirror the source row's status), `decide_finding` (**single human write path**: acknowledge / accept_risk[+days] / resolve / reopen; rejects decisions a narrow-lifecycle source row can't carry instead of silently dropping them), `list_findings`/`findings_summary` (severity-ranked; open+acknowledged = the needs-attention set). **Naive UTC throughout** (matches compliance_service). **Producers (PR-A = the 2 mirrored controls)**: `PublicExposureScanService.scan` + `SigninAnomalyService.pull_and_score` project into findings after flush; legacy `ExposureService.resolve` / `SigninAcknowledgeService.acknowledge` endpoints kept and mirror the finding immediately (non-breaking — frontend swaps to the unified worklist in PR-C). Migration 255 backfills current open/accepted_risk exposures + last-30-day high/critical unacked sign-ins so the worklist is populated on deploy. The 4 native controls (MFA / secret rotation / cert expiry / PAT) start emitting findings — and posture-fail derives from open findings — in **PR-B**. **HTTP** `/api/v1/auditor/findings` (list + `?open_only`/severity/control_key) / `findings/summary` / `POST findings/{id}/decide` (`can_view_security` read, `can_manage_security` write). **MCP** (+2, hint 244→246): `list_security_findings` (read) + `decide_security_finding` (`mutates=True`). Roadmap: PR-B native producers + posture wiring; PR-C frontend IA (16→4 menus, control drill-in, unified 待办 hero). See [[decision_grc_spine_control_first_class]].
>
> **Privileged review folded into the access-cert engine — 「安全合规」重设计 PR3** (2026-06-04, migration 252 / -1 MCP tool / 0 cron / 0 RBAC role). The standalone quarterly privileged-role review is now ``campaign_type='privileged_role'`` in the unified access-certification engine — **single-engine end state**. [`access_certification_service.py`](../../server/app/domains/auditor/access_certification_service.py) gained the `_snapshot_privileged_role` builder (Helix admins via `auth_user_roles` + Google/AWS/GitHub/Feishu/Atlassian admins, degrading gracefully when a connector isn't configured) and generalized `escalate_overdue` (14-day past-deadline → `access_review_overdue` compliance_incident) + `auto_open_quarterly_privileged`. The 2 quarterly crons, `DashboardService.summary`, the privileged evidence-pack module, the `privileged_access_review` compliance-control evaluator, and `/auditor/coverage` all now read `AccessCertification(campaign_type='privileged_role')`. **Dropped** (migration 252): `auditor_privileged_review_cycles` + `auditor_privileged_role_holders` tables, `PrivilegedReviewService` + `AccessReviewEscalationService`, all `/api/v1/auditor/privileged-review/*` endpoints, the desktop + mobile privileged-review pages/routes/menu entries, and the privileged api/auditor.ts functions. **MCP**: removed `assign_privileged_review_holder`, renamed `list_my_pending_privileged_reviews` → `list_my_pending_access_reviews` (hint 243→242). Privileged review is now reached via 身份治理 / 访问认证 (`/compliance/certifications`).
>
> **Access certification — 「安全合规」重设计 PR2:通用周期访问认证引擎** (2026-06-04, migration 251 / +1 MCP tool / 0 cron / 0 RBAC role). The IGA access-review primitive (SailPoint/Saviynt; SOC2 CC6.3 / ISO A.5.15 & A.5.18). **2 tables** ([models.py](../../server/app/domains/auditor/models.py), [migration 251](../../server/alembic/versions/251_access_certification.py)): `access_certifications` (campaign: `campaign_type` + `scope_config` + status/due/counts) + `access_certification_items` (one person×entitlement: holder, access_type/ref/label, assigned reviewer, keep/revoke decision, revocation_status, escalation). Generalizes the privileged-review cycle/holder shape. **Engine** [`access_certification_service.py`](../../server/app/domains/auditor/access_certification_service.py): a `campaign_type → snapshot-builder` registry. PR2 ships **`software_seat`-by-department**: snapshots every active software seat held by someone in the department subtree (reuses `org_scope.department_membership_subtree_clause`), reviewer = the holder's `manager_person_id`. `create_campaign` / `decide_item` (keep/revoke; revoke records `revocation_status='pending'` — attestation is the evidence, fulfilment is a separate step via a coordinator) / `complete_campaign` (blocked while items pending). **Additive — does NOT touch the running privileged-review engine yet**; folding privileged review in + dropping the old `auditor_privileged_review_*` tables is the immediate follow-up PR (safe staging: build new, prove, migrate, delete → single-engine end state). **GRC wiring**: catalog gains the `access_certification` control (`signal_type='cert_campaign'`, no job — status from the latest campaign's completion/due state) mapped to SOC2 CC6.3 / ISO A.5.15 + A.5.18, **closing the A.5.15 `not_covered` gap from PR1**; `compliance_service._evaluate_controls` gains a cert_campaign branch. **HTTP** under `/api/v1/compliance/certifications` (list/create/detail/items/decide/complete); **MCP** `list_access_certifications` (hint 242→243). **Frontend**: new "身份治理 / IGA" menu group → `/compliance/certifications` (list + create modal w/ shared `DepartmentTreeSelect`) + `/compliance/certifications/:id` (per-item keep/revoke + complete). See [[decision_grc_spine_control_first_class]].
>
> **GRC spine — 「安全合规」重设计 PR1:Control 为一等实体 + 多框架 crosswalk + CCM** (2026-06-04, migration 250 / +1 cron / +3 MCP tools / 0 RBAC role). The 安全合规 menu was rebuilt around a first-class **Control** entity (the Vanta/Drata/SailPoint model) so the platform can answer "are we SOC2 / ISO27001 ready and which clause is failing". **Lives in the `auditor` domain** (not a new domain — it reads auditor data + `scheduled_jobs`, so a separate domain would break the no-cross-import rule); reuses `can_view_security`/`can_manage_security` (no new RBAC role). **5 tables** ([models.py](../../server/app/domains/auditor/models.py), [migration 250](../../server/alembic/versions/250_compliance_grc_spine.py)): `compliance_frameworks` / `compliance_requirements` (clauses, UNIQUE(framework,ref)) / `compliance_controls` (1:1 with the 8 existing detective scheduled jobs via `signal_ref`=handler_key) / `compliance_control_requirements` (crosswalk M:N) / `compliance_control_status` (latest evaluated status per control, UNIQUE(control_id) — continuous control monitoring). All CHAR(36) + `id_type_for`/`table_kw_for` for TiDB-FK strictness. **Catalog single source of truth** = [`compliance_catalog.py`](../../server/app/domains/auditor/compliance_catalog.py) (pure data; SOC2 7 CC clauses + ISO27001:2022 9 Annex-A clauses + 8 controls + 25 mappings — IT-relevant subset only; unmapped clauses honestly `not_covered`). PCI + 等保 land in a later PR (seed-only). **Service** [`compliance_service.py`](../../server/app/domains/auditor/compliance_service.py): `sync_compliance_catalog` (idempotent upsert, FK-by-natural-key, never deletes) + `recompute_control_status` (CCM — reconcile catalog, then evaluate each control from `DashboardService.summary` + the feeding job's freshness, upsert status; **naive UTC throughout** to dodge aware/naive 500s) + read projections (a framework's readiness % is a PROJECTION of its requirements' controls' statuses, never stored). **HTTP** [`compliance_router.py`](../../server/app/domains/auditor/compliance_router.py) `/api/v1/compliance`: `posture` / `frameworks` / `frameworks/{key}` / `controls` / `controls/{key}` / `controls/recompute`; lazy-seeds catalog on first read so the UI is never empty. **Cron** `compliance.control_status_recompute` (hourly :15, [auditor_handlers.py](../../server/app/scheduled_jobs/auditor_handlers.py), seeded via `UUID()` not REPLACE). **MCP** (+3, hint 239→242): `get_compliance_posture` / `list_compliance_controls` / `get_compliance_framework` (read-only, `can_view_security`). **Frontend**: 安全合规 menu reorganized into GRC pillars via `type:'group'` (合规总览 / 安全态势 / 风险与证据 / 运营); new `/compliance` (posture dashboard), `/compliance/controls` (catalog + detail drawer), `/compliance/frameworks/:key` (per-clause coverage). See [[decision_grc_spine_control_first_class]] in DECISIONS. Roadmap: PR2 access-certification (folds in privileged review), PR3 SoD, PR4 PCI+等保.
>
> **工号系统 — standalone employee-number allocation ledger** (2026-06-04, migration 249 + **252** / +3 → **+5 MCP tools** / 0 cron / 0 RBAC role). New HR-tools feature under `/admin/hr-tools/employee-numbers`. **Table** [`hr_employee_numbers`](../../server/app/domains/hr/models.py) is an **allocation ledger NOT linked to `identity_persons`** — HR allocates a 工号 to a *name* before the person exists in HRIS (creating a row needs only name + optional hire_date + remark). Number format is `P` + zero-padded-5 (`P02388`); the integer `seq` column drives `MAX(seq)+1` allocation so we never string-sort, and is the *sole* allocation authority. **Seed**: [migration 249](../../server/alembic/versions/249_hr_employee_numbers.py) one-time copies every distinct `identity_persons.employee_no` (non-NULL, non-deleted) in as `source='seed'` rows — `^P\d+$` values get a `seq`, legacy formats are kept verbatim with `seq=NULL` (excluded from the max). Seeds only when the table is empty → idempotent re-run. **Service/router** [`employee_number_service.py`](../../server/app/domains/hr/employee_number_service.py) + [`employee_number_router.py`](../../server/app/domains/hr/employee_number_router.py) under `/api/v1/hr/employee-numbers`: list (5 independent filters: employee_no / name / hire-date range / source / requested-at range + pagination), `/next` preview, `/report`, `POST` allocate (unique-index + IntegrityError retry for concurrency), `/import` CSV (**skip-bad-rows + per-row error report**: missing_name / bad_hire_date / bad_format / duplicate; empty 工号 → auto-allocate, explicit → canonicalize + use, raises the cursor), `/export` CSV (BOM + CSV-injection guard). Reads gate `require_hr_read` (+auditor/management), writes `require_hr_admin`. **Frontend** [`EmployeeNumberPage.tsx`](../../web/src/pages/hr/EmployeeNumberPage.tsx): ledger tab + **工号维护 tab** + report tab (KPI cards / monthly-allocation trend / source pie / hire-year bar — trend & "new this month" exclude `seed`). **MCP**: `next_employee_number` / `list_employee_numbers` / `allocate_employee_number` / `set_next_employee_number` / `list_employee_number_overrides`. **Gotcha**: MySQL/TiDB has no `NULLS LAST` — the list order-by uses `seq IS NULL` as the leading key.
> **工号系统 — per-row 维护 + 删除 + 尾号回收** (2026-06-18, 0 migration / 0 cron / 0 MCP / 0 new RBAC role). Added two verbs on each ledger row, gated at the **row level**: `PATCH /api/v1/hr/employee-numbers/{id}` (维护 — edits only `name`/`hire_date`/`remark`/`bu`; the 工号 `employee_no`/`seq` is **immutable**, audit `hr.employee_number.update` carries a before→after diff) and `DELETE …/{id}` (硬删除, audit `hr.employee_number.delete`). **Authority**: `require_hr_admin` is the entry gate; the per-row check (`employee_number_service._get_editable`) admits the row's **creator** (`created_by == actor`) or any `global_admin`, else 403 — so a non-owner `hr` user cannot touch another's row, and `source='seed'` rows (`created_by IS NULL`) are admin-only. **Why HARD delete (not the platform soft-delete default)**: reclamation requires freeing the `employee_no` **global** unique index (MySQL/TiDB have no partial index, so a soft-deleted row keeps occupying its number and blocks re-allocation); this table has **no inbound FKs** (deliberately decoupled from `identity_persons`) and the full who/what/when survives in `audit_logs`, so a physical delete is both safe and necessary. **Reclaim semantics fall out of the unchanged `MAX(seq)+1` allocator**: delete the current-max number → `MAX(seq)` drops → the next allocation re-mints the same number; delete a number *below* a still-existing higher one → max unchanged → allocation continues from the surviving max and the gap is left open (NOT back-filled). The manual override floor (工号维护) still wins when set higher. Frontend [`EmployeeNumberPage.tsx`](../../web/src/pages/hr/EmployeeNumberPage.tsx) adds an 操作 column (编辑/删除 shown only to creator/admin — gated by `useAuthStore` user id + `global_admin`) with the reclaim rule spelled out in the delete confirm. Regression in [`test_employee_number.py`](../../server/tests/test_hr/test_employee_number.py) (tail-reclaim + no unique-index collision / middle-gap not reclaimed / number immutable on update / row-level owner authz / 404 / full HTTP create→edit→delete→reclaim loop).
> **工号系统 — migration 252 additions** (2026-06-04): **(a) 工号维护 / manual next-number override.** New table [`hr_employee_number_overrides`](../../server/app/domains/hr/models.py) is BOTH the audit trail and the source of a manual "next 工号" floor. HR sets the next number (`POST /api/v1/hr/employee-numbers/next` {next_employee_no, reason}, `require_hr_admin`); allocation computes `max(MAX(seq)+1, latest_override.next_seq)`, so a floor only ever moves the sequence **forward** past already-allocated numbers (set-time validation requires target `> MAX(seq)`; once allocations catch up the floor goes inert). "Latest override wins" is made tie-free by `created_at DATETIME(6)` (whole-second ties would make the floor ambiguous). History at `GET /next/history` (`require_hr_read`). MCP twins `set_next_employee_number` (mutates) + `list_employee_number_overrides`. **(b) 一级部门 (BU) field** on `hr_employee_numbers.bu` (nullable String(128), indexed). A free label mirroring the platform BU derivation ([`bu_helpers.bu_from_org_path`](../../server/app/identity/bu_helpers.py) — top segment of the person's primary-membership `org_path`); set on create / CSV import, validated against the live BU list (`GET /auth/business-units`) — unknown BU → 400 on create / `unknown_bu` skip-row on import. CSV gained a trailing `一级部门` column (backward-compatible: old files without it import fine). [Migration 252](../../server/alembic/versions/252_hr_employee_number_bu_and_overrides.py) backfills `bu` from `identity_persons` primary membership (only rows where `bu IS NULL` → idempotent). Service helpers `set_next` / `list_overrides` / `known_bu_map` / `resolve_bu`.
>
> **Post-merge audit of 2026-06-03's 29 PRs (#686–#714) — 12 confirmed-defect sweep** (2026-06-04, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). Cross-domain adversarial review of everything merged the prior day; 12 real defects fixed in one PR. Platform-level invariants worth remembering: **(a)** the prod cloud-write kill-switch now also gates **orphan-cleanup** (`cleanup_orphan_assignments`) — it was the one human-initiated provider-IAM-revoke path that bypassed `ensure_prod_scope_writable`; the guard is inside `orphan_cleanup.py` (covers both the HTTP endpoint and the MCP twin in one place). **(b)** New CI guard [`scripts/check_scheduled_jobs_uuid_seed.py`](../../server/scripts/check_scheduled_jobs_uuid_seed.py) (wired into `ci.yml` next to the alembic-chain guard) hard-rejects `REPLACE(UUID(),'-','')` inside any `scheduled_jobs` INSERT — the no-dash-id-strands-the-cron bug recurred 3× (migrations 059/162/248); the 8 live source offenders (041/064/114/115×2/134/135/155) were also corrected. **(c)** `app/core/org_scope.py::_subtree_path_clauses` now anchors every descendant arm on the full `" / "` segment separator (+ escapes LIKE wildcards), so a department filter on "Security" no longer leaks "Security & Compliance" — and `billing_service._resolve_department_user_ids`'s verbatim copy was deleted in favour of the shared `department_membership_subtree_clause`. **(d)** `app/core/masking.py::mask_phone` resolves the `+CC` country code against a known-prefix table (was greedily eating 3 digits); `auth/router._mask_phone` is now a thin wrapper over it (duplicate deleted). **(e)** `_DEFAULT_OWNER_ROLE_BY_PROVIDER` now sends **per-provider** owner role_keys — domestic `aliyun` = `System:AdministratorAccess` (RAM `grant_access`), intl `alicloud_intl` = `admin` (CloudSSO) — they are NOT interchangeable. See ROADMAP 2026-06-04 for the full 12-item list. Regression: 11 new/updated tests; ~3000 tests across cmdb/core/integrations/offboarding/hr_lifecycle/leaver_blocked/asset/auth/scheduled_jobs green.
>
> **GCP billing account as a grantable scope** (2026-06-06, governance PR5, 0 migration / 0 cron / 0 RBAC role / 0 MCP-tool-count; completeness hardening 2026-08-02). New `scope_type` **`gcp_billing_account`** (`SCOPE_TYPE_GCP_BILLING_ACCOUNT`, in `TENANT_LEVEL_SCOPE_TYPES`; external_id = `billingAccounts/XXXX-XXXX-XXXX`). GCP billing roles (`roles/billing.viewer` etc.) bind at the billing-account level, not a project — so "I want GCP-console billing visibility" is a grant on THIS scope. [`GcpCloudDriver`](../../server/app/integrations/drivers/gcp_cloud.py): `list_scopes` enumerates open billing accounts via `cloudbilling.billingAccounts.list`; `grant_access`/`revoke_access`/`list_scope_grants` branch on `scope_type` to `cloudbilling.billingAccounts:get/setIamPolicy` (shared etag RMW helper `_rmw_iam_member`); `list_scope_roles` returns the billing-account catalog (`billing.viewer` promoted). Scope discovery is a partitioned snapshot: the base driver contract [`CloudDriver.scope_snapshot_partitions`](../../server/app/integrations/driver_base.py) reports `CloudScopePartitionStatus` per independently enumerated scope type. GCP reports projects and billing accounts separately; billing API errors, repeated page tokens, or the page cap mark that partition incomplete. [`sync_scopes_for_integration`](../../server/app/domains/cmdb/lifecycle_service.py) refuses the entire write/sweep before any upsert when an incomplete partition was configured or historically observed, so a successful project list can never authorize closing billing scopes or disabling their grants. An unconfigured integration with no historical billing scope explicitly operates projects-only; a complete empty billing partition remains authoritative. Governance: `billing.viewer` is `normal` on a billing-account scope but `ungrantable` on a project. Prereq: the SA needs `billing.accounts.setIamPolicy` to grant. Blast radius (whole-billing-account cost visibility) governed by read-only-only promotion + cloud-admin approval. See [DECISIONS 2026-06-06](DECISIONS.md).
> **Billing-account requests route to the cloud-admin team (no per-scope owner)** (2026-06-08, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count). A GCP billing account has no owner-equivalent IAM role to mint, so a billing-account scope is **intentionally ownerless** — and the original "admin must `assign_scope_owner` first" gate left the billing 正路 unreachable (the scope never surfaced in the picker, and `submit_access_request` 409'd `cloud_scope_unowned`). Fixed so an ownerless `gcp_billing_account` scope is a first-class self-service target whose approval authority IS the cloud-admin team: **(1)** the `requestable=true` scope filter ([`lifecycle_router.py`](../../server/app/domains/cmdb/lifecycle_router.py)) admits ownerless scopes when `scope_type == 'gcp_billing_account'` (still `status=active` + non-prod). **(2)** `submit_access_request` + `owner_add_member` ([`lifecycle_service.py`](../../server/app/domains/cmdb/lifecycle_service.py)) skip the `ScopeUnownedError` raise for this scope_type only — every other ownerless scope is still a config gap and refused. **(3)** new `_resolve_request_approval_recipients(scope)`: owned scope → the owner (unchanged); ownerless → every **active** cloud admin (`it_admin`/`global_admin`/`cloud_admin`, departed/no-email filtered). The `[待审批]` notification fans out to that set (previously a single `db.get(owner_person_id)` that would `uuid.UUID('None')`-crash on an ownerless scope); the requester-confirmation message shows reviewer "云管理员团队". **(4)** approval authority needs no new gate — `approve_grant`'s router gate `_require_scope_owner_or_admin`, with no owner, already admits only `can_manage_cloud`. **(5)** frontend wired `gcp_billing_account` into the `ScopeType` union + `SCOPE_TYPE_COLOR` + the admin scope-type filter ([`cloudLifecycle.ts`](../../web/src/api/cloudLifecycle.ts), [`CloudScopesPage.tsx`](../../web/src/pages/cloud/CloudScopesPage.tsx)) — #763 added the backend scope_type but never taught the FE about it, so billing-account scopes rendered with an undefined tag colour. Operational prereq still stands: the integration SA needs `cloudbilling.billingAccounts.list` for the scope to sync at all (best-effort; absent → no billing scopes, projects unaffected). Tests: [`test_billing_account_admin_approval_2026_06_08.py`](../../server/tests/test_cmdb/test_billing_account_admin_approval_2026_06_08.py) (4). See [DECISIONS 2026-06-06](DECISIONS.md).
> **Cloud role governance — four-tier classifier (PR1: classify-only)** (2026-06-06, migration 263 / 0 cron / 0 RBAC role / 0 MCP-tool-count / zero behavior change). Every requestable cloud role is classified into `normal`/`elevated`/`restricted`/`ungrantable` by the central [`app/integrations/cloud_role_governance.py`](../../server/app/integrations/cloud_role_governance.py)::`classify_role` (precedence `ungrantable>restricted>elevated>normal`; driver explicit sets authoritative + keyword backstop for custom roles + bounded-admin allowlist). Each `CloudDriver` declares `restricted_role_keys`/`elevated_role_keys` + `ungrantable_role_keys(scope_type)` (`owner_role_keys` folds into restricted); `classify_role()` composes them. Persisted on `cmdb_cloud_roles` via new cols `governance_tier`/`governance_reason`/`governance_overridden_by_admin` ([migration 263](../../server/alembic/versions/263_cmdb_cloud_roles_governance_tier.py)); `sync_roles_for_integration` stamps the tier (admin override → sync stops recomputing, mirrors `is_promoted`). **PR1 is classify-only** — no request/approve behavior change yet; later PRs wire visibility (hide ungrantable/restricted) + request block (422/403) + **approval gating (elevated/restricted require `can_manage_cloud`, enforced in the service layer so MCP can't bypass)** + driver fail-fast. Closes a real escalation hole: `roles/owner`/`roles/editor`/Azure `Owner`/`Contributor`/AWS `PowerUserAccess` are owner-equivalent/IAM-write but have no "admin" token, so today they're self-requestable + owner-approvable. See [DECISIONS 2026-06-06](DECISIONS.md). Tests: [`test_role_governance.py`](../../server/tests/test_cmdb/test_role_governance.py) (36).
> **Per-cloud "申请即报错" / escalation audit** (2026-06-08, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count). Post-launch sweep of every driver's grant surface + the live prod role catalog against the four-tier model, answering "can any requestable role error on grant, or under-classify an escalation primitive?". Findings: **(a)** resource-level-mismatch (a real role the provider rejects at the scope, like GCP `roles/billing.*` on a project) is **GCP-only** — Azure lists role definitions *at the subscription scope* (so the list is already grant-assignable), AWS managed-policy ARNs / SSO permission sets attach at the account, Aliyun/Tencent system & preset policies attach to RAM/CAM users; none inject a global unfiltered predefined set the way GCP does, so no other driver needs an `ungrantable` set. **(b)** every driver's `grant_access` already returns a clean `CloudGrantResult(ok=False, error_message=…)` on a vendor failure (no raw exception leaks to the user) — GCP fail-fast, Aliyun `NotSupport.AliyunReservedPolicy` guard + `str(e)`, Tencent "policy not found" + `str(exc)`, AWS clean `str`. **(c)** one real under-classification fixed: Tencent CAM **role-management** presets `QcloudCamRoleFullAccess` (mint/manage CAM roles) and `QcloudCamSubaccountsAuthorizeRoleFullAccess` (authorize roles onto sub-accounts) are privilege-escalation primitives but carry no "admin" keyword, so the `FullAccess` token alone landed them at `elevated` (employee-visible + self-requestable). Added both to `TencentCloudDriver.restricted_role_keys` (now hidden from the employee dropdown, cloud-admin-only) — same explicit-set-beats-keyword pattern as the existing `QcloudCamFullAccess` / AWS `IAMFullAccess` / Aliyun `AliyunRAMFullAccess` entries. No new role names were added to any catalog (only existing, cloud-verified roles re-tiered).
> **Production cloud-account write lock — global kill-switch, default LOCKED** (2026-06-03, migration 246 / 0 cron / 0 RBAC role / 0 new MCP-tool-count). After someone *self-released a role on a production cloud account* (a high-blast-radius mis-operation), every write against a `CmdbCloudScope` with `environment == 'prod'` is now gated by a single platform setting. **Setting**: `platform_settings['cmdb.prod_cloud_writes_enabled']` (JSON bool) — `false`/missing/non-truthy = **LOCKED** (writes blocked), `true` = allowed. Default seeded `false` ([migration 246](../../server/alembic/versions/246_prod_cloud_write_lock.py), idempotent `ON DUPLICATE KEY UPDATE description`); the helper treats a missing row as locked too, so the safe default does not depend on the seed. **Role-independent by design** — even `global_admin`/`it_admin` is blocked while locked; this is NOT an RBAC-role change, it's an orthogonal kill-switch on top of existing role gates. **Single enforcement helper** [`app/domains/cmdb/prod_write_lock.py`](../../server/app/domains/cmdb/prod_write_lock.py)::`ensure_prod_scope_writable(db, scope)` (+ `prod_cloud_writes_enabled`/`prod_cloud_writes_locked`/`is_prod_scope`), called at every **human** write entry point: HTTP [`lifecycle_router.py`](../../server/app/domains/cmdb/lifecycle_router.py) (`/grants/request`, `/grants/owner-add`, `/grants/{id}/{approve,reject,renew,revoke}`, `PATCH /scopes/{id}`, `/scopes/{id}/{alerts,sync-now,decommission,snooze-idle}`) **and** MCP [`tools.py`](../../server/app/mcp/tools.py) (`submit_cloud_access_request`, `renew_my_cloud_grant`, `revoke_my_cloud_grant`, `transfer_cloud_scope_owner`, `snooze_cloud_scope_idle_reminder`). **The revoke self-release path is NOT exempt** — `is_self` does not bypass the lock, because self-release is exactly what triggered this. **System automation is deliberately NOT gated** (offboarding leaver sweeps + TTL-expiry sweeps call `CloudLifecycleService` directly with `suppress_driver_error=True`) so a departing employee still loses prod access regardless of the lock — the lock governs *discretionary human operations*, not *automated cleanup*. Blocked calls raise the new `ProdCloudLockedError` (code `PROD_CLOUD_LOCKED`, 403) in [`exceptions.py`](../../server/app/core/exceptions.py). **Frontend**: three serializers (`_serialize_scopes`, `_serialize_grants`, `list_my_subscriptions`) stamp a per-scope/per-grant `write_locked: bool`; the UI disables every mutating control (wrapped in a `Tooltip`) and renders a warning `Alert` banner on both scope-detail pages — covers MyCloudGrantsPage / MyCloudScopeDetailPage / CloudScopeDetailPage / CloudScopesPage / ScopeAlertsPanel / PendingGrantsTab / MyTodosPage; i18n `cloud.prodLock.*` (4 keys × zh/en/ja). `OwnerTransferModal` is DNS/server owner transfer (not scope) so it's out of scope and untouched. **Deliberately NOT locked**: new-prod-account creation via scope-request approve (a separate onboarding flow — unlock to create), and integration-level force-drift/roles-sweeps (reconciliation, not per-scope). **MCP-sync rule N/A** — only guards added to existing tools, no `@mcp_server.tool` added/removed → `_REGISTERED_TOOLS_COUNT_HINT` unchanged. **Anti-recurrence note**: a future per-scope cloud write endpoint MUST call `ensure_prod_scope_writable` (resolve the target scope first) or it silently bypasses the lock. **2026-06-04 follow-up**: the post-merge audit found one such miss — **orphan-cleanup** (`cleanup_orphan_assignments`, reached from `POST /cloud-grants/orphan-cleanup` + MCP `cleanup_cloud_orphan_assignments`) issued a real provider IAM revoke on prod scopes without the lock. Now gated per-target inside `orphan_cleanup.py` (a blocked prod target returns `ok=False` with the lock message and no revoke; non-prod targets in the same batch proceed). Regression: [`test_orphan_cleanup_prod_lock_2026_06_04.py`](../../server/tests/test_cmdb/test_orphan_cleanup_prod_lock_2026_06_04.py). **2026-06-04 carve-out — environment change is the one exempt write**: `PATCH /scopes/{id}` no longer calls `ensure_prod_scope_writable` unconditionally; it skips the lock when the request's *only* mutating field is `environment` (guard `mutates_beyond_environment`). Rationale: the lock blocking every write on a prod scope created a chicken-and-egg — a scope mis-marked `prod` could never be demoted, because the demoting PATCH was itself blocked. The environment label is a pure Helix-metadata edit that never reaches the cloud provider's IAM, and it stays admin-gated (`can_manage_cloud`). A PATCH that touches `environment` **plus** any other field (owner/budget/status/notes/ttl/auto-suspend) re-trips the lock. Frontend `CloudScopeDetailPage` keeps the whole config form disabled when locked but enables the environment `Select` + a dedicated "保存环境" button for cloud admins (sends `environment` alone). Regression: [`test_scope_environment_prod_lock_exempt_2026_06_04.py`](../../server/tests/test_cmdb/test_scope_environment_prod_lock_exempt_2026_06_04.py). **2026-06-12 carve-out — admin owner transfer**: a `can_manage_cloud` caller (cloud_admin / global_admin) may transfer scope ownership while the lock is engaged — `assign_scope_owner` is Helix-metadata-only (DB rebind + audit + governance timeline, no provider IAM call), and admins need to re-seat ownership (e.g. off a leaver) without break-glassing the whole lock. Applies to both surfaces: `PATCH /scopes/{id}` skips the lock when the only lockable mutation is `owner_person_id` AND the caller is `can_manage_cloud` (guard `mutates_lockable`), and MCP `transfer_cloud_scope_owner` skips `ensure_prod_scope_writable` for `can_manage_cloud` callers. An owner SELF-transfer on a locked prod scope still trips the lock; bundling any other config field re-trips it. Frontend: `CloudScopeDetailPage` enables the transfer button for cloud admins on locked prod scopes; its `TransferOwnerModal` sends `owner_person_id` alone (no `notes`) when `write_locked`; `MyCloudScopeDetailPage`'s owner self-transfer button stays disabled. Regressions: [`test_scope_owner_transfer_prod_lock_exempt_2026_06_12.py`](../../server/tests/test_cmdb/test_scope_owner_transfer_prod_lock_exempt_2026_06_12.py) + [`test_cloud_owner_transfer_prod_lock.py`](../../server/tests/test_mcp/test_cloud_owner_transfer_prod_lock.py). **2026-07-06 — DNS record writes DECOUPLED from this lock (own switch, default ALLOWED)**: self-service DNS requests (`cmdb_dns_record_requests`, the Feishu-approval → execute path) previously gated their cloud write on `cmdb.prod_cloud_writes_enabled`, so a prod-IAM freeze also failed every already-approved DNS change — a DNS record edit is low blast-radius and trivially reversible, so coupling it to the IAM kill-switch was wrong. DNS now has its **own** setting **`cmdb.dns_writes_enabled`** (new helpers `dns_writes_enabled`/`dns_writes_locked` in `prod_write_lock.py`), default **`true` = ALLOWED** — the INVERSE default of the IAM lock; a missing row reads as enabled, seeded `true` by [migration 386](../../server/alembic/versions/386_dns_writes_control.py) (idempotent, for admin-UI discoverability). `DnsRequestService._execute_approved` gates on `dns_writes_enabled` instead of `prod_cloud_writes_enabled`; a locked DNS switch still FAILS the row (not silent park) with audit `cmdb.dns_request.execute_deferred_dns_lock`, but the row is now RESUMABLE. New **`resume_after_write_lock_cleared`** re-drives an approved-but-lock-failed row on the SAME approval once the switch is re-enabled (no new approval, no bypass) — gated `can_manage_cloud`, refuses while still locked or on a genuine cloud-write failure (double-fire risk), matches BOTH the new `dns_writes_enabled` and legacy `prod_cloud_writes_enabled` error fragments so historically-frozen rows recover; posts a success comment to the original Feishu instance. Entry points: `POST /cmdb/dns-record-requests/{id}/resume` + MCP **`resume_blocked_dns_request`** (`_REGISTERED_TOOLS_COUNT_HINT` 404→405) + a 「重跑」 button on write-lock-failed rows in `MyDnsRequestsHistory` (mirrors the 4-eyes recovery UI). Regressions: [`test_dns_write_lock_resume_2026_07_06.py`](../../server/tests/test_cmdb/test_dns_write_lock_resume_2026_07_06.py) + updated `test_dns_request_self_approval_guard.py` (prod-IAM freeze no longer blocks DNS; DNS switch does). **2026-08-14 — the cloud write MOVED OFF the Feishu webhook, and `executing` finally has an owner** (migration 469 / **+2 cron** / 0 RBAC / 0 new MCP tool). Incident: an approved `pingcap.net` CNAME was rejected by DNSPod (`LimitExceeded.RecordTtlLimit` — the requester picked TTL=300; **every zone on that tenant has a 600 floor**, 900 records, none below 600), and the fail-path meant to record that raised on its OWN: it called `db.rollback()` — which expires every ORM attribute — then read `row.id`, triggering a lazy refresh outside greenlet context → `MissingGreenlet`. The handler died before marking the row, so the request sat at `executing` **permanently**: 申请人 saw 处理中 forever, no `failed` state, no audit, no Feishu comment, and **nothing in the product could move it** (`feishu.approval_pending_reaper` / `approval_drift_reconciler` only scan `status='pending'` ApprovalRequests — this one was already `approved`; admin resume only accepted write-lock failures). Four fixes: **(1) fail-path** — `_execute_approved` snapshots `row_id`/`requester_person_id` as plain values BEFORE the cloud write and `_mark_execution_failed` never touches the expired instance (**never read an ORM attribute after `rollback()`**); every step individually guarded so a secondary failure logs loudly instead of stranding. **(2) TTL floor at SUBMIT** — new [`providers/base.py`](../../server/app/domains/cmdb/providers/base.py)::`MIN_DNS_TTL_BY_PROVIDER` (`tencent: 600`, others inherit 60) + `min_dns_ttl(provider, config_public=…)` with a per-integration `config_public.min_dns_ttl` override for paid plans; `DnsRequestService._validate_ttl_floor` rejects at submit so a doomed value never burns three levels of human approval. Floor check only — **no clamping**, or the executed record would differ from what the approvers signed off on. FE `DnsRequestFormModal` TTL input `min` 60→600 (its hint had claimed 600 all along while the input let 300 through). **(3) execution off the webhook** — `handle_feishu_callback` now commits `approved` and RETURNS; `cmdb.dns_request_executor` (every minute) performs the write in the worker. Feishu gives a webhook ~3s before redelivering, and instance 0778DA79 was delivered **four times in 26 seconds** while the first delivery was still inside the DNSPod call. Admin paths (`bypass_execute` / `resume_after_write_lock_cleared`) still execute inline — a human is waiting and nothing redelivers. **(4) `cmdb.dns_request_execution_reaper`** (every 10 min) — `executing` is the one state whose owner is by definition gone. `create` is reconciled against the LIVE provider first (present ⇒ the interrupted write landed ⇒ `_finalize_create` completes the bookkeeping, **never a duplicate**; extracted from `_execute_create` so the two paths can't drift on owner/recert/owner-history provenance); other actions are idempotent ⇒ re-armed to `approved`. `cmdb_dns_record_requests.execution_attempts` bounds the loop; past `max_attempts` (3) the row lands `failed` with `STRANDED_EXECUTION_ERROR`, which `_is_stranded_execution_failure` recognises so **resume accepts it** (safe: create is provider-verified absent each pass, everything else is idempotent) — mirrored in `MyDnsRequestsHistory.isResumableFailure`. Regression: [`test_dns_request_execution_plane_2026_08_14.py`](../../server/tests/test_cmdb/test_dns_request_execution_plane_2026_08_14.py) — **note the mutation-verification lesson baked into it**: stubbing `_execute_create` with a bare `side_effect` leaves no open transaction, so `rollback()` expires nothing and the test passes against the BROKEN code; the stub must issue a query first (as the real `_load_dns_provider` does) for the crash to reproduce.
> **Cost-facts month-close re-sync job + billing-cron "four-piece set" documentation** (2026-06-03, +1 cron / migration 248 / 0 schema / 0 RBAC role / 0 MCP-tool-count). The daily `cmdb.cost_facts.<slug>` cron re-fetches only a **rolling 3-day window** ([`sync_cost_facts_for_integration`](../../server/app/domains/cmdb/cost_service.py) default `end=yesterday, start=yesterday-2`). Any usage day older than 3 days is never re-pulled — it freezes at the value the vendor reported ~2-3 days post-usage. AWS Cost Explorer / other billing APIs keep re-rating after that (month-end finalization, late-arriving untagged usage, daily-accruing infra like S3/ECR/Cost Explorer API), so those late revisions never reach Helix → a small systematic drift vs the cloud console (observed: AWS SG May 2026 off by **$3.84**, entirely in the untagged `Owner=None` bucket; tagged Bedrock usage finalized within 3 days and matched to the cent). **Fix**: new SINGLE global monthly job **[`cmdb.cost_facts_monthclose`](../../server/app/scheduled_jobs/handlers.py)** (`run_cost_facts_monthclose`, seeded by migration 248, cron `0 7 5 * *` Asia/Shanghai) iterates every *enabled cloud* integration and re-pulls the **entire previous calendar month** once the vendor has finalized it (~5th), reconciling `cmdb_cost_facts` to the cent. Reuses the backfill path under the shared `cloud_sync:cost_facts:{id}` lock (never races the daily tick); window overridable via `config={start_date,end_date}`. **Single global row** (handler loops internally), NOT per-integration — does not add to the per-account fan-out. **Orthogonal to `cmdb.cost.nightly_recompute_allocation`**: that re-APPLIES allocation rules to existing facts (re-attribution); this re-PULLS amounts from the vendor (data freshness) — neither subsumes the other. **Billing cron taxonomy** (the "账单四件套", now spelled out in each row's `description`, refreshed onto prod rows by `sync_jobs_for_integration` on worker-boot reconcile): ① `cmdb.billing.<slug>` (monthly → `cmdb_billing_monthly`, account×month total, multi-cloud report); ② `cmdb.billing_daily.<slug>` (daily 06:xx → `cmdb_cloud_billing_daily`, account×scope×day total + anomaly alert); ③ `cmdb.cost_facts.<slug>` (daily 04:xx, 3-day rolling → `cmdb_cost_facts`, day×scope×resource×service×tag line items, the sole source for owner/department attribution); ④ `cmdb.cost_facts_monthclose` (monthly month-close re-pull). Audited the 3×N fan-out for consolidation — **deliberately NOT merged**: each writes a distinct table at a distinct granularity via a distinct vendor API and they cross-check each other; collapsing them would remove the reconciliation safety net for a large refactor's blast radius. Regression: [`test_cost_facts_monthclose_handler.py`](../../server/tests/test_scheduled_jobs/test_cost_facts_monthclose_handler.py) (prev-month window / enabled-cloud-only filter / explicit-window override / raise-on-failure). **Follow-up (2026-06-18) — the month-close re-pull silently failed for AWS until the BQ query was chunked by calendar month + the bytes cap raised**: on the CUR 2.0 `aws_billing_no_payer` dataset (all 60+ linked accounts in one DAY-partitioned month-table, `line_item_usage_account_id` NOT a partition/cluster key), a 30-day per-scope re-pull scans **73.57 GB / 68.5 GiB** (prod dry-run) — over the original **50 GiB** `_BQ_MAX_BYTES_BILLED`, so every AWS scope's month-close aborted with `bytesBilledLimitExceeded`. Fix in [`aws_cloud.py`](../../server/app/integrations/drivers/aws_cloud.py): (1) `_list_cost_facts_bq_sync` now splits the window by **calendar month** via `_bq_cost_facts_windows` and runs the extracted `_bq_cost_facts_one_window` per month — a single-month window (the month-close case) stays one query, a multi-month admin backfill becomes one query per month instead of one ~900 GB scan; (2) `_BQ_MAX_BYTES_BILLED` raised **50 GiB → 128 GiB** (fits a one-month per-scope scan + ~1.8× growth headroom, still aborts a true runaway). **Why calendar-month and not arbitrary ≤15-day chunks**: the fact `date` grain is `DATE(start_date)` but the table is DAY-partitioned on `end_date` (prune predicate clamps `end_date` to `window_end + 2d`); a month-boundary spanning row (SP/RI recurring fee: start = month-1st, end = next-month-1st) belongs to an early chunk by start-day yet lives in a partition a tight mid-month prune excludes → a mid-month boundary would silently DROP it. Month boundaries make each chunk identical to a standalone month-close, where the existing +2d pad already handles spanning rows — zero new drop risk, reconciliation preserved (validated by prod dry-run: chunk-union vs whole-month differ only on 10,519 zero-cost rows the driver discards anyway). **Secondary, unsolved (upstream-owned)**: the per-scope full-partition scan is inherently redundant (60 scopes each scan the whole ~70 GiB month); clustering the CUR export by account or a single tenant-wide pass would cut it, but the table is produced by pchub Data Exports, not Helix. **Follow-up (2026-07-05) — the "single global row iterates every account inline" design NEVER completed in prod and was rebuilt as a fan-out DISPATCHER**: the inline loop ran 8 enabled cloud accounts × ~205 tenant scopes × the whole month of per-resource line items under ONE 30-min handler timeout. That's hours of vendor-bound work (prod: each heavy account's *3-day* daily cost_facts already runs 400–1600s, so a full month per account is multi-thousand seconds; the aggregate exceeds even the 7200s clamp). It literally never succeeded once — 2026-06-04 ran **>6h** and was reaped by the 6h stale-running sweeper, 2026-07-04 hit the new **1800s** timeout — so the "reconcile to the cent" never actually happened. `run_cost_facts_monthclose` is now a **monthly dispatcher**: for each enabled cloud integration it splits the month into `chunk_days`-day (default 7) sub-windows and runs each through that account's own **`cmdb.cost_facts.<slug>`** job via [`execute_job`](../../server/app/scheduled_jobs/service.py) (config override carrying the window + `timeout_secs`). Chunks of one account run sequentially (they serialize on that job's `sched_job:{id}` + inner `cloud_sync:cost_facts:{id}` locks — same guard that keeps them off the daily 04:xx tick); different accounts drain in parallel up to `max_parallel` (default 3). The dispatch runs in a `background.spawn` task and the handler returns the plan in seconds, so each account's timeout/lock/run-row/failure-notifier is REUSED and a slow/failing account is isolated + independently retryable; the per-account `cmdb.cost_facts.<slug>` run history is the source of truth for completion, and this handler's short-lived run row can never be reaped by the 6h sweeper again. **Key trap fixed**: [`_resolve_recurring_billing_window`](../../server/app/scheduled_jobs/handlers.py) deliberately DROPS any window pin whose `end_date < yesterday` (the 2026-06-16 Azure "stale pin freezes daily ingest" guard) — the month-close window (all of last month) is exactly that, so a new **`allow_past_window`** flag bypasses the drop. It is only ever set on the dispatcher's ephemeral `config_override` (never persisted to the daily job's row), so the daily cron still self-heals a forgotten pin. Overrides: `start_date`/`end_date` (arbitrary range), `chunk_days` (1–31), `chunk_timeout_secs` ([60,7200]), `max_parallel` (1–8). Regression rewritten: [`test_cost_facts_monthclose_handler.py`](../../server/tests/test_scheduled_jobs/test_cost_facts_monthclose_handler.py) (chunks tile the prev month contiguously / enabled-cloud-with-enabled-job filter → others to `skipped` / explicit window + chunk_days / account failure doesn't raise or abort siblings / no-job no-op / `allow_past_window` bypass).
>
> **CONVENTION — every department picker is the shared hierarchical `DepartmentTreeSelect`; picking a parent rolls its whole subtree up** (2026-06-03, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). Do NOT build a new flat department `<Select>`. The one component is [`web/src/components/DepartmentTreeSelect.tsx`](../../web/src/components/DepartmentTreeSelect.tsx): an Antd `TreeSelect` fed by **`GET /auth/departments/tree`** ([`AuthService.get_department_tree`](../../server/app/auth/service.py), `require_directory_read` gate, returns flat `{source_id,name,parent_source_id,org_path,level,count}` rebuilt into a tree client-side). Its **external contract is a department *name* string** (what every `*_department` column / filter param matches on); internally it keys on the unique `source_id` because department names aren't globally unique. Backend subtree roll-up lives in **[`app/core/org_scope.py`](../../server/app/core/org_scope.py)**: `department_membership_subtree_clause(dept)` (an `OR` predicate over `IdentityOrgMembership` via the `org_path` breadcrumb — exact leaf / `"… / dept …"` / root segment / single-segment) and `resolve_department_subtree_names(db, dept)` (parent name → set of descendant leaf names, for denormalised leaf-name columns like `cmdb_cost_facts.cost_owner_department` via `.in_(...)`). Wired through: users list ([`list_users_filtered`](../../server/app/auth/service.py) → also `/directory/people`), cloud overview ([`resolve_department_person_ids`](../../server/app/domains/cmdb/admin_overview_service.py) + `_cost_by_scope_ranking`), software cost ([`_compute_cost_stats`](../../server/app/domains/asset/router.py)). All four now agree on "what does this department contain"; selecting a *leaf* is unchanged (pure superset — parents just work now). `CloudDepartmentFilter` is a thin labelled wrapper over the shared component. Regression: [`test_department_subtree_filter.py`](../../server/tests/test_cmdb/test_department_subtree_filter.py). Memory [[reference_department_tree_select]].
>
> **Cost-breakdown `group_by=owner` now resolves person UUIDs → display names; 云成本洞察 folded into 云资产总览** (2026-06-03, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). The 账单 → 多维下钻 (drilldown) tab already supported `group_by=owner`/`department` — but [`cost_service.breakdown()`](../../server/app/domains/cmdb/cost_service.py) + [`timeseries()`](../../server/app/domains/cmdb/cost_service.py) returned the bare `cost_owner_person_id` **UUID** as the group `label`, so the「归属人」pie / table / stacked-series rendered a wall of UUIDs and finance couldn't tell "这个钱是哪些人花的". New helper **[`_resolve_owner_labels`](../../server/app/domains/cmdb/cost_service.py)** (`cost_owner_person_id → display_name`, fallback `work_email`, fallback raw id) flips only the `label` for `group_by=="owner"` (the `key` stays the UUID = stable rowKey). The **department dimension needs no change** — `cost_owner_department` is already denormalised readable text. **Gotcha** (caught by the existing `test_breakdown_owner_pins_savings_plans_out_of_null_bucket`): `breakdown()`'s pinned "Savings Plans" discount group has a literal-string `key`, not a UUID — a `_is_person_group()` predicate excludes both it (`is_discount`) and the empty `未分摊` bucket before the `id IN (...)` lookup, else UUID coercion 500s. Regression: `test_breakdown_owner_resolves_uuid_to_display_name` + `test_timeseries_owner_resolves_uuid_to_display_name` (test_subordinates_cost.py); 73/73 cost tests green. **Nav merge**: the standalone `/admin/cloud/cost-insights` page (nav-split PR3, just the [`CloudModule`](../../web/src/pages/admin/insights/CloudModule.tsx) insight dashboard — largely redundant with the overview's Hosts/Network/Reports/Subscriptions tabs) is folded into [`CloudOverviewPage`](../../web/src/pages/cloud/CloudOverviewPage.tsx) as a new **「洞察」tab** (own range Segmented; module fetches `/dashboard/insights/cloud` fleet-wide and ignores the page tenant filter by design). Admin + finance menu entries removed; old route `Navigate`-redirects to `?tab=insights` (saved links / onboarding deep-links survive); both routes shared the identical `global_admin/cloud_admin/finance` gate so **no role loses access**. `OnboardingTour` finance highlight repointed to overview; `cloudOverview.tabs.insights` i18n added (zh/en/ja). Reduces the 云资产 sidebar by one entry per the user's "菜单太多了". NOT cloud-lifecycle (no decommission/IAM/scope-sync/driver).
>
> **`finance` view can read + search cloud scopes (sub-accounts / subscriptions)** (2026-06-03, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). [`can_view_cloud_resources`](../../server/app/core/rbac.py) now admits `FINANCE_ROLE` — **the exact same narrowed-view bug class as the 2026-06-02 cloud_admin fix** (see callout below). Finance already reads the cloud surface via `can_read_cloud` (cost attribution needs the resource catalogue) and day-to-day passed `can_view_cloud_resources` incidentally through the `employee` bit; but clicking the「财务」view chip narrows `role_names` to `[finance]`, dropping `employee`, so `GET /cmdb/scopes` (list + search) 403'd and finance could not look up which sub-account/subscription a given owner holds. The `list_scopes` membership filter is keyed off `can_read_cloud` (already includes finance) so finance sees the **full org-wide** list, not a self-scoped subset; the `get_scope` finance bypass (2026-05-16) becomes reachable in finance view. **Frontend**: mirror [`canViewCloudResources`](../../web/src/lib/rbac.ts) adds `finance`; a new read-only **[`ScopeLookupCard`](../../web/src/pages/cloud/cloudOverview/ScopeLookupCard.tsx)** (search-by-name → owner label, row-click → role-aware `/cloud/scopes/:id`) is embedded in the FinOps overview's Subscriptions tab ([`SubscriptionsTab`](../../web/src/pages/cloud/cloudOverview/SubscriptionsTab.tsx)) — the surface finance already has, NOT the cloud_admin-only `/admin/cloud/scopes` management page. Same change also fixes a latent finance/management dead-link: the owner-health "unowned" alert button navigated to the cloud_admin-only `/admin/cloud/scopes?unowned=true` (403 for them); it now drives the in-tab lookup for non-`canManageCloud` viewers. Regression: [`test_finance_view_keeps_cloud_scope_read_access`](../../server/tests/test_auth/test_view_role_header.py) pins `X-View-Role: finance` → `/cmdb/scopes` 200. **Anti-recurrence reminder still stands**: a role-semantic change must walk the full `can_*` helper list — `can_read_cloud` had finance, `can_view_cloud_resources` didn't.
>
> **One-off signed action tokens + no-login one-click task completion** (2026-06-09, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count; **+2 public routes**). **New core primitive [`app/core/security.py`](../../server/app/core/security.py)** `create_action_token` / `decode_action_token` — a capability JWT (`type="action"`, distinct from access/refresh so it can never be replayed as a session) that grants exactly ONE named `purpose` on ONE `resource_id` to ONE `subject_id` until `exp` (default 14d). Decode pins `iss=helix`, the HS256-only allowlist, AND requires the caller to pass `expected_purpose` (a token minted for one action can't be redirected to another). Use it for "click the button in the email/Feishu card to finish — no Helix login" flows; the signature IS the authorization. **First consumer**: manual_notify offboarding revokes (Slack/Nexuscrm etc. that have no auto-revoke driver). `_notify_manual_assignees` now mints a per-recipient token and adds a 「✅ 我已回收,标记完成」 button (Feishu via `card_actions`, email via the single `url=`); the public **`GET /api/v1/provisioning/quick-complete?token=`** renders a self-contained bilingual confirm page (GET = no side effect, prefetch-safe) whose button **`POST`**s back to complete the task. Both routes are intentionally unauthenticated — the router-level `require_active_employee_for_mutations` lets a credential-less POST fall through to the endpoint's own (absent) auth, and the token is the auth. `complete_manual_task(via_token=True)` skips the assignee re-check + `REVOKE_PROOF_REQUIRED` (the click is the attestation that the recipient removed the access externally), stamps a default `external_ref`, audits `via='token'`, and is idempotent on an already-terminal task. **Trust model (owner-confirmed)**: one-click = the recipient attesting they did the external revoke; the full-proof in-console `complete_task` path is unchanged. MCP-sync N/A (a token-link action isn't an AI-callable surface; the authenticated `complete_manual_task` MCP/HTTP path is unchanged), so `_REGISTERED_TOOLS_COUNT_HINT` is untouched. **Also (force-bypass UX)**: `ProcessAdvanceService._advance_single(bypass_grace=True)` now pulls the `not_before` of still-pending software-revoke ProvisioningTasks forward to NOW (`_expedite_software_revokes`, idempotent, audited `provisioning.not_before_expedited`) so 「强制立即推进」 actually finishes everything the platform can do immediately instead of parking software seats until the leaver's local-midnight schedule; the regular `advance_now` still respects the schedule. Tests: `test_provisioning/test_quick_complete_token_2026_06_09.py`, `test_offboarding/test_force_bypass_expedites_software_2026_06_09.py`. **Update (2026-06-13)**: this human-confirm gate is now **scoped to ad-hoc revokes only**. For **offboarding-sourced** seat/permission revokes that resolve to `manual_notify` (no auto-revoke driver), `execute_task` no longer parks the task in `notified` waiting on a click — it sends the DM as a *reminder* (new honest template `provisioning.offboarding_auto_release_reminder`, **no** confirm button), then immediately completes the task + runs `_post_success_side_effects` (local seat/permission → `revoked`, offboarding blocker → `revoked_auto`, process `BLOCKED→READY`), `external_ref="auto-released on reminder (vendor cleanup pending IT)"`. Gate = `source==offboarding && action==revoke && target_type∈{seat,permission}`; mid-employment manual revokes keep the full confirm-then-release path. Backlog caveat: only **newly-dispatched** tasks auto-release; pre-existing `notified` blockers need a one-shot convergence (dispatcher only re-runs `pending`). Test: `test_offboarding/test_manual_notify_seat_auto_release_2026_06_13.py`. **Update (2026-07-15) — optional single-use gate**: action tokens are valid for their whole TTL by default (fine for idempotent confirm-once flows), but a magic-link *button* re-fires on every click. New reusable helpers `consume_action_jti(payload)` / `release_action_jti(payload)` in `security.py` claim the token's `jti` in Redis (`SET NX`, TTL = token's remaining life — same store family as the MCP refresh-jti rotation) so exactly the FIRST click performs the side effect; later clicks see it consumed and the consumer returns an idempotent "already done" response. **Fails OPEN** (missing jti / Redis outage → allow), and `release_action_jti` un-claims on a failed action so transient errors stay retryable. **First consumer**: cloud-grant one-click renewal (`POST /cmdb/grants/renew-by-token`) — claim taken after the holder/lifecycle checks; a replay returns `already_renewed:true` + current expiry (frontend `GrantRenewPage` renders a friendly "该链接已使用" state instead of re-renewing or racing the per-grant lock into a 409 that looked like "link invalid/expired"). Opt-in per flow — the quick-complete task path above still relies on terminal-state idempotency, not jti consumption.
>
> **Legal-entity 开票信息 is employee-readable, masked by default + audited reveal** (2026-06-03, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). `GET /assets/legal-entities` + `/{id}` ([`asset/router.py`](../../server/app/domains/asset/router.py)) moved from `require_foundation_read()` to `get_current_user()` — every employee can read 开票信息 (the readonly `/company/directory` page was 403-crashing for them because its `Promise.all` legal-entities fetch was foundation-gated). The sensitive financial fields (`tax_identifier` / `phone` / `bank_account` / `swift_code`) are **masked in the backend** for non-foundation-admin callers (`_can_read_legal_entity_plaintext` = global_admin/asset_admin/hr — the stewards who edit the row read plaintext so the admin edit form hydrates with real values); the response carries `sensitive_masked: bool`. New **`POST /assets/legal-entities/{id}/reveal`** hands out plaintext to any authenticated employee but writes an `asset.legal_entity.sensitive_reveal` audit row per call — the same masked-default + audited-reveal pattern as `identity.person.contact_reveal`. **New shared core primitive [`app/core/masking.py`](../../server/app/core/masking.py)** — `mask_phone` (+CC, last 4), `mask_tail` (last N, for bank account / tax id), `mask_head_tail` (SWIFT). Mask in the backend, never ship plaintext + mask in the browser. (`server/app/auth/router.py` still has its own private `_mask_phone`/`_mask_email`; consolidating those onto this module is a future no-behavior-change cleanup, not done here to keep blast radius tight.) Frontend: the readonly `LegalEntityTab` detail modal shows a「显示完整信息」button + masked-hint `Alert` when `sensitive_masked`. **No legal-entities MCP tool exists** (only an FK reference), so the MCP-sync rule is N/A; reveal is a sensitive-PII disclosure and — like `contact_reveal` — deliberately gets no MCP tool.
>
> **Cost-breakdown Savings-Plans pin generalised to all `group_by`** (2026-06-03, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). [`cost_service.breakdown()`](../../server/app/domains/cmdb/cost_service.py) used to pin the AWS "Savings Plans" row out of the top-N slice **only** for `group_by='service'`. For the allocation dimensions (`owner` / `department` / `cost_center`) SP rows are NULL-owner by design, so they folded into the NULL/`未分摊` bucket — the `unallocated_usd` KPI scalar and the chart's 未分摊 slice both carried the (usually negative) SP amount and disagreed with each other / with `recompute_allocation`'s `sp_skipped` bucket. **Fix**: SP is now identified by the canonical signal `auto_owner_source='savings_plan'` (NULL-safe via `coalesce(auto_owner_source,'')`, not the brittle `service=='Savings Plans'` string), **excluded** from both the main grouped query and the `unallocated_usd` scalar, summed once via a dedicated SP-sum+count query, and re-added as a single pinned group (`is_unallocated=False`, `is_discount=<sp_sum<0>`) for **every** `group_by`. `total_usd` is unchanged — it nets the SP discount back in (`non_sp_total + sp_sum`), so gross-vs-net reconciliation and the `sum(groups)+other==total` invariant both hold. Excluding SP from the main query also makes the top-N edge case trivial (SP can never land in `other_usd` or the NULL bucket regardless of rank). The `unconverted_by_currency` block (FX-missing rows) is untouched. **Frontend** ([`CloudCostAnalysisPage.tsx`](../../web/src/pages/cloud/CloudCostAnalysisPage.tsx) + [`cost.ts`](../../web/src/api/cost.ts) `CostBreakdownGroup.is_discount?`): the SP group renders a green `折扣` tag + green-italic amount (matching `CloudBillingPage`'s existing SP style), never the orange 未分摊 tag; the pie filters to `cost_usd > 0` so the negative SP slice doesn't skew ECharts percent math (also fixes the pre-existing service-view negative-slice glitch). New `cost.discount` i18n key (zh/en/ja). **NOT changed** (deliberate, tight-blast-radius): `timeseries()` still folds SP into the NULL/`未分摊` daily series for allocation dims — a separate surface outside this KPI↔chart-consistency scope. P2 finance-dashboard-consistency cleanup, split out of the P0 Tencent cross-month cron hotfix. Regression: [`test_cost_service.py`](../../server/tests/test_cmdb/test_cost_service.py) `test_breakdown_unallocated_excludes_savings_plans` (service baseline) + `test_breakdown_owner_pins_savings_plans_out_of_null_bucket` (`group_by='owner'` generalisation); 64/64 green; FE `tsc` + ESLint clean.
>
> **JML pre-launch hardening sweep — onboarding/offboarding reliability** (2026-06-03, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). A 3×(code-review + logic-scan + security-scan) sweep over the whole JML surface (onboarding, offboarding, leaver_blocked, hr_lifecycle, provisioning, hr, coordinators) closed five real defects, all transaction-isolation / failure-surfacing class: **(1)** [`OnboardingService.start_process`](../../server/app/domains/onboarding/service.py) replaced a bare `self.db.rollback()` on the unique-constraint race path with a `begin_nested()` SAVEPOINT — the method runs inside `hr_sync_coordinator.provision_for_new_hire` *before* the HRIS batch's per-event commit, so a session-wide rollback silently discarded the whole event's person upsert + `hr_lifecycle_events` row, dropping the new hire from the pipeline. **(2)** [`reconcile_all_active_processes`](../../server/app/domains/onboarding/service.py) (the 5-min cron) now wraps each process in a per-process SAVEPOINT — previously one process whose rescan raised poisoned the AsyncSession and discarded the entire tick (mirrors `ProcessAdvanceService.run_tick`). **(3)** [`process_webhook_events`](../../server/app/domains/hr/service.py) now isolates the lifecycle-coordinator dispatch in a try/except (rollback + `summary.errors++` + continue) — the offboard branch's `start_offboarding_for` deliberately doesn't swallow, so a raise dropped every *remaining* event in the webhook batch (SF then replayed the whole batch, wedging forever); the already-committed person upsert self-heals via the T+0 advance cron (`_advance_single` creates the missing process at termination). **(4)** [`run_process_advance`](../../server/app/scheduled_jobs/handlers.py) + `run_hire_day_it_notify` now `raise RuntimeError` when `summary["errors"]` is non-empty (per `feedback_handler_status_must_raise`) — they were the only two of the family that swallowed genuine per-person exceptions, landing `runs.status='ok'` while a leaver retained access / a new hire got no Day-1 IT info (grace_held / skipped_no_email correctly do NOT trip the raise). **(5)** offboarding api_key transfer create-gate + accept-gate unified on `_API_KEY_TRANSFERABLE_STATUSES = ("active","expired","pending_release")` — the create-gate's old `["active","approved"]` produced transfers that were permanently un-acceptable (every accept 409'd), stranding the linked offboarding blocker. Plus a re-hire reopen now drops the prior cycle's `OnboardingBlocker` rows so a boomerang hire re-scans every Day-1 dimension fresh (was silently inheriting a prior `skipped` decision), the lone UTC future-hire gate in `enqueue_identity_sync_for_hire` switched to local-TZ `_is_future_hire` for parity, and `BulkExecuteResponse` now surfaces its already-counted `errored` field. 8 new regression tests; 464 JML tests green. **Explicitly NOT changed** (deliberate, after review): the onboarding T+0/SLA Asia/Shanghai date basis (internally consistent across the interlocking `_recompute_status`/`advance_all_due`/`evaluate_sla` state machine; the symptom is only an early/late *notification*, never access loss — changing the basis across a tuned state machine is high-risk/low-reward) and `post_leaver_service`'s `("active","pending")` seat/key residual filters (a mid-sweep "dead value" cleanup was caught by adversarial diff-review as a regression — `pending` is a live seat status the offboarding blocker scan flags too — and reverted to the production-proven predicate).
>
> **`auto_owner_grant_backfill` no longer re-scans unresolvable AWS scopes every 10 min** (2026-06-02, **migration 244** / 0 cron / +1 schema col / 0 RBAC role / 0 MCP-tool-count). New nullable `cmdb_cloud_scopes.auto_owner_grant_unresolvable_at` backoff marker. When an AWS scope's owner permission-set is DETERMINISTICALLY unresolvable (Identity Center has no perm-set matching `cloud.aws.scope_request_default_owner_role_name` — distinct from a transient driver crash), no `source='helix'` grant ever landed, so the backfill cron kept it a candidate forever and re-ran the paid `list_scope_roles` call + wrote a `status=skipped` audit row every tick. `_resolve_default_owner_role` now raises `_OwnerRoleUnresolvable`; `_post_provision_finalize` stamps the marker (de-noising the skipped-audit to once per window); the candidate predicate skips marked scopes within `unresolvable_retry_hours` (default 6h) then re-qualifies for self-healing; `provision_initial_owner_grant` (success) and `assign_scope_owner` (manual) clear it; (re-)marked scopes surface ONCE per window via the existing `raise → failure-notifier`. Full detail in the `cmdb.auto_owner_grant_backfill` row in the scheduled-jobs table below. Migration chain head **243 → 244**.
>
> **`cloud_admin` view no longer 403s on cloud reads** (2026-06-02, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). [`can_view_cloud_resources`](../../server/app/core/rbac.py) — the baseline read gate for 30+ `/cmdb/*` GET endpoints (servers / scopes / domains / load-balancers / DNS / CDN / per-id GETs / search) — now admits `CLOUD_ADMIN_ROLE`. Background: the 2026-05-20 cloud_admin "promotion" added the role to `can_manage_cloud` / `can_read_cloud` / `can_view_directory` / `can_view_ai_spending` / `can_view_relationship_graph` but **missed this helper**, which has the widest call-site fan-out. Day-to-day the dual-role pattern (`employee + cloud_admin` — every active person carries `employee` via `HrService._grant_default_employee_role`) hid the gap: the `employee` bit alone satisfied the gate. The gap surfaced only after [`apply_view_role_narrow`](../../server/app/core/rbac.py) (X-View-Role header → request-scoped role narrowing) collapses the role set to `[cloud_admin]` — exactly what happens when a cloud_admin clicks the「云管理员」chip in the header. Result was a paradox: in "act as cloud admin" view, the cloud admin lost read access to every cloud resource they manage. Frontend mirror [`canViewCloudResources`](../../web/src/lib/rbac.ts) updated lock-step. Regression test [`test_cloud_admin_view_keeps_cloud_read_access`](../../server/tests/test_auth/test_view_role_header.py) pins the invariant: with `X-View-Role: cloud_admin` narrowing `role_names` to `[cloud_admin]`, `/cmdb/scopes` + `/cmdb/servers` + `/cmdb/domains` MUST all return 200. **Anti-recurrence**: any future role-semantic change must walk the full `can_*` helper list — not just the obvious management gates. The widest helper (`can_view_cloud_resources` here) is the easiest to overlook.
>
> **Access TTL 30min → 240min (4h) + feed celebration-card chrome unification** (2026-06-02, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). **(1)** `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` default in [`core/config.py`](../../server/app/core/config.py) bumped from 30 to 240 — employees were being silently kicked to `/login` when a release-time backend container restart collided with the every-30-min refresh hop (502 from a transitional nginx upstream → axios interceptor `logout({silent:true}) + redirect`). 8× the refresh frequency was 8× the surface for that race; cutting it to 4h drops the collision probability by the same factor. Refresh-cookie TTL stays 7 days so PTO returners still resume silently. `LEAVER_PENDING_ACCESS_TTL_MINUTES = 5` ([`security.py`](../../server/app/core/security.py)) is UNCHANGED — leaver offboarding security is independent of the comfort knob. Prod inherits via Pydantic Settings default; no SSM mutation. **(2)** [`web/src/pages/feed/PostInteractions.tsx`](../../web/src/pages/feed/PostInteractions.tsx) extracts the like-toggle + comment-drawer state + UI that used to be inlined in `FeedPostCard`; same component now wraps user posts (Edit / Delete in the `trailingActions` slot) AND celebration cards (`查看 →` / `送祝福 →` in the `leadingActions` slot), so system-authored birthday / anniversary rows on `/feed`'s public-tab now show `formatRelative(post.created_at)` + 💖 + 💬 just like user posts. Mobile already had this since `MFeedPostCard` always shared one shell — desktop was the only surface missing it. Backend `_can_view_post` already permitted likes / comments on system posts, so no service-layer change needed.
>
> **Feed Feishu card visual overhaul** (2026-06-02, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). All five feed dispatchers ([`server/app/domains/feed/notifications.py`](../../server/app/domains/feed/notifications.py) — `notify_blessing_received` / `notify_celebration_self` / `notify_celebration_lead` / `notify_comment_added` / `notify_mentions_on_post|comment`) now emit interactive cards with: festive header colours (carmine for birthday, indigo for anniversary, yellow for manager/HRBP heads-up, turquoise for comment, violet for @mention — deliberately distinct from the red/orange operational palette), sticker emoji woven into the subtitle, `heading_3`-sized body markdown (via inline `<font size='heading_X'>` since the markdown element has no `text_size` field), `tone="hint"` auxiliary sections that render as Feishu `note` elements (small grey, same weight as footer), and a 6-line randomly-rotated footer pool to avoid wallpaper fatigue. The card builder ([`_build_interactive_card`](../../server/app/notifications/service.py)) accepts a new `body_size` parameter and `_render_sections` now branches on `tone="hint"` to emit `note` elements. `_CARD_HEADER_COLOR_BY_TEMPLATE` gained 11 feed template-key entries (`feed.blessing.received.{birthday|anniversary}`, `feed.celebration.self.{birthday|anniversary}`, `feed.celebration.{manager|hrbp}.{birthday|anniversary}`, `feed.comment.added`, `feed.mention.{post|comment}`) so each event gets its own colour. `NotificationEnvelope` gained `card_body_size`. The footer's leading `hr` is suppressed when the previous element is already a `note`, eliminating the double-divider case. System text is English-only per the 2026-06-01 decision; only user-typed blessing / comment / post bodies retain author language.
>
> **Feed `send_blessing` MissingGreenlet 收尾 + dashboard `my_celebrations`** (2026-06-02, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). [`FeedService.send_blessing`](../../server/app/domains/feed/service.py) now stamps `created_at = updated_at = datetime.now(UTC)` Python-side on BOTH the `FeedBlessing` row and the mirrored `FeedComment` — same MissingGreenlet class PR #658 fixed for `add_comment`/`create_user_post`/`create_announcement`/`create_bundle`, but `send_blessing` was missed in that pass. `server_default=NOW()` left in async session: after `db.commit()` the router accessed `blessing.created_at` to serialise the response, which triggered a server-default lazy refresh and raised MissingGreenlet → 500 — but the blessing row had already committed, so the UX surfaced as "send button toast errors but the blessing actually arrived" (precisely the 2026-06-02 user-reported bug). Regression test `test_blessing_creates_mirror_comment` now asserts `b.created_at is not None` + `mirror.created_at/updated_at is not None`. Same PR adds `my_celebrations` to [`dashboard._employee_section`](../../server/app/dashboard/router.py): selects the viewer's own `FeedCelebration` rows with `occasion_date ∈ [today-7d, today]` and outer-joins `feed_blessings` for a per-celebration `blessing_count`. [`DashboardPage` `EmployeePanel`](../../web/src/pages/dashboard/DashboardPage.tsx) renders an amber-left-border Card above the offboarding banner: "Your {birthday | N-year anniversary} — X blessing(s) received" with a "Open" CTA linking straight to `/feed/celebrate/{id}`. Empty-state hint shifts to "be the first surprise of the day" when `blessing_count == 0`. FK bind uses `str(person_id)` to avoid the asyncmy UUID→`String(36)` silent-zero-rows pitfall.
>
> **cmdb scope_request — `云供应商` radioV2 字段不再丢** (2026-06-02, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). prod `6721A06B…BEA` 提交后审批人看到 `云供应商` 必填空白:Helix 侧 `provider="aws"` 在 payload 里,但 [`scope_request_service`](../../server/app/domains/cmdb/scope_request_service.py) 双重 miss —— 关键词只配了 `云厂商`(模板里叫 `云供应商`,零字符重合 → 关键词 substring 不命中),而且 `_SCOPE_SKIP_WIDGET_TYPES` 把所有 `radioV2` 整体 skip(2026-05-12 为了绕开 `类型` widget 自由文本无法解析到 option key 加的),误伤了干净 enum 的 `provider`。修复:关键词表加 `云供应商/供应商/厂商/Cloud Provider` 多种 variant;从 skip 集合移除 `radioV2`;新增 inline `_resolve_single_choice_value`(镜像 [`sso/service.py`](../../server/app/domains/sso/service.py) 同名 helper,大小写不敏感比 option.text/value);`_serialize_scope_widget` 对 radioV2 走 resolver,解析到就提交、解析不到就 drop —— `cloud_account_type` 自由文本仍得到「字段空白让审批人手填」的旧行为,零 regression。Fixture + 4 个回归测试([`test_scope_request_form_payload.py`](../../server/tests/test_cmdb/test_scope_request_form_payload.py))钉 `云供应商` widget 行为:厂商/供应商/英文三种命名都路由到 provider field / `provider=gcp` resolve 到 `opt_gcp` / 四家 provider 全部大小写不敏感 round-trip / 自由文本 radioV2 仍 drop。21/21 绿。教训重述 `feedback_radiov2_option_set_guard` memo:radioV2/select 拿自由文本进去会被静默丢,但 *blanket-skip* 也是错的 —— 干净 enum 应该 per-widget resolve + drop-on-miss,而不是按 type 全 skip。
>
> **CI perf — per-test reset TRUNCATE→DELETE + per-request `get_current_user` memoization** (2026-06-02, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). CI was >10min; two fixes, no matrix change. **(1) Backend per-test DB reset.** [`tests/conftest.py::db_engine`](../../server/tests/conftest.py) wipes data between tests. It used `TRUNCATE` on all 143 tables — but InnoDB `TRUNCATE` is a DDL that drops+recreates the tablespace file *even when the table is empty* (~1.3s for the full set, measured), and that ran before every test in every shard = the dominant CI cost. Now: `DELETE` for the 141 ordinary tables (≈0.01–0.05s on empty tables; test tables only ever hold a few rows) and `TRUNCATE` only for the 2 `AUTO_INCREMENT` tables (`cmdb_cost_facts`, `cmdb_resource_creators`) so their counter still resets to 1. The auto-increment set is discovered once per session in `_prepare_test_schema` from `information_schema` (self-maintaining). The test schema is built via `create_all` (no triggers) so `DELETE` has no trigger side-effects `TRUNCATE` would skip. Effect: per-test setup ~1.5s→~0.05s; e.g. `auth-core` shard 632s→24.6s. **NB for parallelism**: each CI shard runs on its own runner with its own MySQL service, so *splitting* shards gives true parallelism; `pytest-xdist -n` within one shard does NOT (workers share one MySQL container, and the bottleneck is MySQL I/O, not Python CPU — measured: `-n 4` gave ~no speedup). **(2) Auth chain.** [`get_current_user`](../../server/app/core/dependencies.py) is now memoized per request via `request.state.current_user`/`auth_via`. The router-level leaver gate [`require_active_employee_for_mutations`](../../server/app/core/dependencies.py) calls `get_current_user` imperatively before the path-op's own `Depends(get_current_user)`, so every mutating request on the asset/cmdb/apikey/… routers authenticated the bearer twice — for a PAT, two `last_used` bookkeeping writes from independent sessions that contend on the same token row (and lock-wait the full `innodb_lock_wait_timeout` under the test harness). The bearer is now resolved once per request (a production win too: one fewer user-load + PAT write per mutation). `auth_via` is the sentinel — set only after the user is fully resolved in both PAT and JWT branches.
>
> **Round-63 review-sweep — 近两日 ~57-commit 全量复审, 5 P1/P2 修复** (2026-06-02, 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool-count). 6 路并行 domain-finder(onboarding/provisioning · offboarding/leaver/role-change · reconciliation/health/observability · feed+migrations 227-238 · cmdb/seat-billing/integrations · auth/sso/rbac/approval/audit)+ 1 路 adversarial 复审 over the post-#619 main; confirmed findings fixed + prod-data-verified. **(R63-A, P1 状态机)** [`OnboardingService._recompute_status`](../../server/app/domains/onboarding/service.py): a re-pend now demotes only `scanning`/`ready` → `preparing`; a `live` process (T+0 advance already fired — person in seat) is NO LONGER demoted. Old code demoted `live → preparing` on any pending blocker "regardless of hire_date", and since [`OnboardingT0AdvanceService.advance_all_due`](../../server/app/domains/onboarding/advance_service.py) re-flips `[ready,preparing]+hire_date≤today → live` daily with no blocker check, a seated-but-still-blocked hire oscillated `live ↔ preparing` every 5-min reconcile tick AND was starved by the T+7 verify sweep ([`t7_verify_service.py`](../../server/app/domains/onboarding/t7_verify_service.py) selects `status==live`) → never `completed`. Pending blockers on a live process are an SLA concern, already handled by the 30-min escalation tick (selects `[preparing,live]`) + `first_ungated_at`. There is no documented `live → preparing` transition. **(R63-B, P1 越权)** [`OffboardingService.list_my_team_leavers`](../../server/app/domains/offboarding/service.py): removed the skip-level (level+2) eligibility sub-query. It admitted leavers whose manager reports to the viewer, justified by a FALSE "`_dm_manager_chain` walks 2 levels" claim — [`sla_service._dm_manager_chain`](../../server/app/domains/leaver_blocked/sla_service.py) DMs the DIRECT manager only, skip-level deliberately excluded per user decision ("经理链上级 / level+2 不需要知道这个事情"). The clause leaked indirect reports' leaver PII (name / work_email / blocker counts) to a non-admin level+2 viewer; the list now exactly mirrors dispatcher reach (HRBP + direct manager). **(R63-C, P1 数据丢失)** [`celebration_service._upsert_celebration`](../../server/app/domains/feed/celebration_service.py): bare `db.rollback()` on IntegrityError → `begin_nested()` savepoint. `feed.scan_today_celebrations` accumulates the whole tick's celebrations + system posts + aggregate banners in one session committed once at the end, so a single concurrent-insert collision wiped the entire batch. Mirrors `toggle_like`/`send_blessing`/backfill. **(R63-D, P1 幂等)** [`compliance_incidents.resource_id`](../../server/app/compliance/models.py): `CHAR(36)` → `GUID` decorator (same CHAR(36) on the wire ⇒ 0 migration; every sibling UUID col already uses it). The bare CHAR lacked `uuid.UUID→str` bind coercion, so cmdb callers passing a raw `uuid.uuid5` dedup key ([`owner_confirmation_service`](../../server/app/domains/cmdb/owner_confirmation_service.py), [`orphan_cleanup`](../../server/app/domains/cmdb/orphan_cleanup.py), [`lifecycle_service`](../../server/app/domains/cmdb/lifecycle_service.py)) bound the wrong representation → idempotency SELECT `resource_id==<uuid>` silently matched 0 rows → a fresh duplicate incident every sweep instead of bumping `detected_at`. Prod verified: all 23 existing `compliance_incidents.resource_id` are valid UUID format → read path (`process_result_value` → `uuid.UUID`) has no regression. `resource_id` now consistently returns `uuid.UUID` (matches the `Mapped[uuid.UUID]` annotation + `IncidentResponse.resource_id: UUID`). **(R63-E, P2 防御)** [`slo_evaluator`](../../server/app/scheduled_jobs/slo_evaluator.py): the "one away from limit" warn now guards `max_consecutive_failures > 1` — a future target with `max==1` (permitted by `SLOTarget.__post_init__`, none shipped today) would otherwise warn on a handler with ZERO failures (`consecutive == 1-1`). **Anti-recurrence**: (1) a writable status that two crons both drive must have a single owner of each transition — never let one cron (T+0 advance) re-create a state another cron (reconcile) tears down. (2) Any list/aggregate that "mirrors a dispatcher's recipients" must be diffed against the dispatcher's ACTUAL code, not its docstring. (3) every UUID column uses the `GUID` decorator — a bare `CHAR(36)`/`String(36)` for a UUID is the asyncmy-bind footgun ([`feedback_no_getattr_default_for_model_columns` sibling note]). **Dismissed (false-positive / out-of-threat-model)**: `require_admin_read` it_admin alias (✗ — `IdentityPerson.role_names` is always canonical via `normalize_role_names`); password-login deactivated-vs-invalid message (local-dev-only endpoint, prod-disabled, distinct message aids the dev); `/metrics` SLO N+1 (~13 indexed q/s, batching rewrite risk > reward); onboarding SLA WARN day-granularity band (borderline / tuned escalation math not rewritten).
>
> **Feed domain — 动态广场 + birthday/anniversary celebration + announcement sidebar** (2026-06-01, migration 235). Brand-new social-feed domain at `server/app/domains/feed/` (`models.py` / `service.py` / `router.py` / `schemas.py` / `notifications.py` / `celebration_service.py`). Nine new tables — `feed_posts` (user + system + aggregate banner; visibility `public|department`, category `general|work_plan|other`; soft-deleted; index `visibility,created_at` + `author,created_at` + `post_type,created_at` + `department_snapshot,created_at` + `category,created_at` + `is_pinned,created_at` + `celebration_id`), `feed_celebrations` (UNIQUE `(person_id, occasion_date, celebration_type)` → cron idempotency), `feed_comments` (flat + optional `reply_to_comment_id` for display-only threading + `sticker_key`/`blessing_id` back-pointer for mirrored blessings), `feed_likes` (UNIQUE `(post_id, person_id)`), `feed_mentions` (denormalized index for @-mention notifications; post OR comment), `feed_attachments` (S3-key + dimensions, max 9/post), `feed_blessings` (UNIQUE `(celebration_id, sender_person_id)`; mirrored into `feed_comments` so the unified thread renders), `feed_announcements` (Markdown body, schedule `publish_at`/`expire_at`, pin, category, color, draft/published/archived). One identity-scoped table: `identity_person_preferences` (generic per-person KV; first keys `feed.birthday_visibility ∈ {month_day,hidden}`, `feed.notify_comment_feishu`, `feed.notify_like_aggregate`). One column added to `identity_persons`: `birthday DATE NULL` (SF `User.dateOfBirth` extends `_USER_SELECT_FIELDS`; `_apply_employment` writes ONLY when current value is NULL — never overwrites a self-edit).
>
> **Three scheduled jobs** (migration 235 seeds, enabled at install, Asia/Shanghai schedule): `feed.scan_today_celebrations` (hourly @ :00) walks active employees, computes per-person local-TZ today via `resolve_timezone(person)`, upserts the celebration row + system post + refreshes the day's aggregate banner — Feb 29 birthdays fall back to Feb 28 only in non-leap years (`_matches_md`). `feed.notify_celebrants_and_leads` (hourly @ :10) emits Feishu DM + in-app inbox to the celebrant + their `manager_person_id` + their `hrbp_person_id` — per-(celebration, role) idempotency via JSON `notify_state_json` so 24h of hourly ticks deliver each notification exactly once. `feed.expire_pinned_announcements` (every 10 min) flips `draft → published` once `publish_at` passes and `published → archived` + `is_pinned=false` once `expire_at` passes. All three are registered in `app/scheduled_jobs/feed_celebrations_handlers.py`, raise on failure (per [`feedback_handler_status_must_raise`](../../docs/architecture/PLATFORM.md)), and import-mirrored into both `main.py::_sched_feed_celebrations_handlers` and `worker.py::_run` (per [`feedback_worker_handler_imports`](../../docs/architecture/PLATFORM.md)).
>
> **Notification model**: 7 new template_keys — `feed.mention.post` / `feed.mention.comment` / `feed.comment.added` / `feed.like.aggregated` all route through [`NotificationService.push_inbox_and_feishu`](../../server/app/notifications/service.py) (added 2026-06-01, migration 237 era) — a bell-first dispatcher that writes a single `notification_outbox` row with `status=DELIVERED` immediately (so the bell lights up regardless of external-channel result) and only then fires Feishu as best-effort (no retry queue, no audit log per attempt). This replaces the original `dispatch + _write_inbox_only` two-row split, which couldn't both retry Feishu AND keep the bell lit when Feishu failed. `feed.notify_comment_feishu` pref still gates the optional Feishu DM (defaults True; pref=False = inbox-only). `feed.like.aggregated` is always inbox-only — Feishu pings on every like would be noise. The original `feed.notify_like_aggregate` opt-in pref was dropped (defaulted False, meaning the bell never lit up for likes at all — see PR fix entry). `feed.blessing.received` / `feed.celebration.self` / `feed.celebration.manager` / `feed.celebration.hrbp` still go through `NotificationService.dispatch` because the cron's once-a-day cadence makes the retry + dead-letter machinery worth the latency. Every dispatch carries `url=/feed/celebrate/{id}` or `/feed/p/{id}` so the recipient's click lands on the right page.
>
> **MCP**: 10 new tools mirror the HTTP surface 1:1 — `feed_list_posts`, `feed_create_post` (mutates), `feed_react_post` (mutates), `feed_add_comment` (mutates), `feed_send_blessing` (mutates), `feed_search_posts`, `feed_today_celebrations`, `feed_list_announcements`, `feed_get_my_preferences`, `feed_update_my_preferences` (mutates). `_REGISTERED_TOOLS_COUNT_HINT` 205 → 215.
>
> **Storage**: `feed_post_image` and `feed_announcement` purposes added to `_ALLOWED_PURPOSES` in `storage_router.py` — image uploads ride the existing S3 presign path (`presign_upload_put` + `helix-prod-uploads-ap-southeast-1`), no new bucket.
>
> **Frontend**: `/feed` (FeedPage with 5 tabs — 我的/部门/公开/工作计划/其它), `/feed/celebrate/:id` (CelebrationDetailPage — branded hero + sticker picker modal + 祝福留言板), `/admin/announcements` (HR + IT, Markdown editor). MainLayout nav adds 动态广场 next to Dashboard; HR admin nav adds 公告管理 alongside notification-template / request-help-urls. `/me/settings` adds a 5th tab `动态与隐私` (`FeedPrefsTab`) where employees self-edit `birthday`, choose `month_day` (default — displays MM-DD only, never year/age) or `hidden`, and toggle the two notify preferences. Assets are all inline SVG / emoji in `web/src/pages/feed/assets.tsx` — 8 birthday scenes + 8 anniversary scenes + 8 emoji stickers per kind; the celebration row picks `illustration_key` via `sha1(person_id + date + type) % len(pool)` so the same person/day always shows the same artwork, but the second year rotates. Zero admin asset upload.
>
> **Tests**: `tests/test_feed/test_feed_service.py` — 12 cases pin the contracts (department-visibility hides cross-dept, soft-delete removes from list, like UNIQUE idempotent, @-mention insertion + self-mention drop, cron `scan_today_celebrations` idempotent rerun, Feb-29 leap-year fallback, `_years_since` math, blessing UNIQUE returns ConflictError, blessing-to-self forbidden, blessing creates mirror comment + bumps system-post comment_count, hidden-birthday pref skips celebration, aggregate banner count reflects N celebrants). Added to the `jobs-notifs` CI shard so the dir isn't a silent coverage hole (per [`feedback_ci_shard_explicit_paths`](../../docs/architecture/PLATFORM.md)).
>
> **AI Spending dashboard perf + monthly-summary cron TZ fix + worker overdue-cron rewrite** (2026-06-01, PR #626 — 0 migration / 0 cron schedule change / 0 schema / 0 RBAC / 0 MCP-tool-count). One bug-cluster diagnosed by an "open page takes 30s + May summary missing" report, three root causes, one PR. **(1) Page slowness** — [`/admin/ai-spending`](../../web/src/pages/admin/aiSpending/AiSpendingPage.tsx) fans out to ~10 sibling endpoints in parallel and most of them internally call [`AiSpendingService._collect_period_rows`](../../server/app/domains/ai_spending/service.py); overview / movers / forecast each redo it for `current` + `previous` + `yoy`, totaling ~13 redundant invocations per page load. The hot path is [`_bucket_cost_facts`](../../server/app/domains/ai_spending/service.py) which scans `cmdb_cost_facts` (prod: 934k rows for May alone, 2.7M for 90d; TiDB picks full table scan because the ILIKE service filter is non-sargable). Fix: process-level period-row cache keyed by `(period_start, period_end_excl)` with a 60s TTL and per-key `asyncio.Lock` (thundering-herd defence — the 13 sibling endpoints all miss simultaneously on cold cache; without the lock they each spawn a scan). `bu` filter is applied AFTER the cache lookup so different `bu` callers share the underlying fetch. Cache busts on FX-rate or whitelist edits via `invalidate_period_rows_cache()` from `update_settings`. **(2) May 2026 summary "missing"** — the monthly-summary cron is `5 3 1 * *` Asia/Shanghai, but [`run_ai_spending_monthly_summary`](../../server/app/scheduled_jobs/ai_spending_handlers.py) computed "previous month" via `datetime.now(UTC).date()`. At 03:05 SH on day 1 = 19:05 UTC on the LAST day of the just-ended month, the UTC-anchored "previous month" rolls one further back — on 2026-06-01 03:05 SH the handler resolved to `period="2026-04"` and overwrote the April summary instead of generating May. The same UTC bug lived in [`ai_spending/router.py::get_summary`](../../server/app/domains/ai_spending/router.py) default-period path. Fix: both anchor on `ZoneInfo("Asia/Shanghai")` (new `_PERIOD_TZ` constant in the handler; reuse the router's existing `_APP_TZ`). The May row is already in prod (manually backfilled at 2026-06-01 02:31 UTC = 10:31 SH); no data fix required. **(3) Monthly cron fired DAILY** — [`worker.py::_register_scheduled_jobs`](../../server/app/worker.py) had a flat `last_run_at > 25h` overdue heuristic that wrongly treated long-cadence crons as overdue on every hourly reload: a monthly cron's `last_run_at` is 30 days ago by design, so `now - last_run > 25h` was True every hour for 29 of 30 days. In production `scheduled_job_runs` shows `ai_spending.monthly_summary_generate` firing daily through 2026-05-23..05-31, each run overwriting April's summary because of bug #2. Fix: replace the flat heuristic with `CronTrigger.get_next_fire_time(None, last_run + 1s) < now_utc` — ask the cron expression itself when it next *should* fire, and backfill only when that timestamp is already in the past. Monthly cron 29-day reloads now correctly classified as not-overdue; daily and weekly genuinely-missed cases still backfill. **Frontend** — `AiSpendingPage` default `periodMoment` flipped from `dayjs()` (current month) to `dayjs().subtract(1, 'month')` (previous month): MoM / YoY / monthly summary all read as closed-period semantics, defaulting to the partial current month showed "summary unavailable" by default and read as broken; the previous month is also more cache-friendly (it's a stable window so the 60s TTL effectively becomes 60s of free repeat hits). **Tests**: [`test_period_rows_cache.py`](../../server/tests/test_ai_spending/test_period_rows_cache.py) (7 cases — cache reuse / bu filter post-cache / non-collision across windows / TTL expiry / invalidate wipes / thundering-herd 13-parallel-callers collapse to 1 compute / empty window doesn't pollute) + [`test_monthly_summary_handler.py`](../../server/tests/test_scheduled_jobs/test_monthly_summary_handler.py) (8 cases — `_PERIOD_TZ` pin / no-API-key skip / disabled-config skip / 4 parametrized TZ-boundary period resolutions) + [`test_worker_overdue_cron_backfill.py`](../../server/tests/test_scheduled_jobs/test_worker_overdue_cron_backfill.py) (5 cases — `get_next_fire_time` present in source / `timedelta(hours=25)` absent from source / monthly 29-day reloads all classified not-overdue / monthly genuinely-missed classified overdue / daily not-overdue normal cadence + daily genuinely-missed overdue). **Anti-recurrence**: per-cron-cadence overdue heuristics are now mandatory — a flat hour threshold ignores the cron's natural period. Period strings derived from `now()` must use the same TZ as the cron schedule that drives them (corollary to [`feedback_cron_must_respect_local_tz`](../../docs/architecture/PLATFORM.md)).
>
> **HRIS small-delta auto-confirm** (2026-06-01, 0 migration / 0 cron / 0 schema / 0 RBAC / 0 MCP-tool). `POST /hr/sync/preview` now evaluates each fanned-out batch against `SuccessFactorsLifecycleService.evaluate_auto_confirm(batch)` and, when eligible, runs `auto_confirm_if_eligible` → `confirm_batch(confirmed_by="auto:<actor>")` + audit `action='hris.batch_auto_confirmed'` + a best-effort Feishu summary card to the triggering admin (card failure never rolls back the confirm). Gate (all must hold; master switch OFF by default): `hris.auto_confirm_small_delta` on, new-hire count ≤ `hris.auto_confirm_threshold_count` (default 3), `hris.auto_confirm_block_on_termination` (default true) ⇒ 0 terminations, `hris.auto_confirm_block_on_manager_change` (default true) ⇒ 0 org moves (manager OR department change — one setting governs both). Manager/dept change flags are computed in `_stage_event` for update/reactivate events and stored on `person_data` (`manager_changed`/`dept_changed`): manager = incoming `managerId` vs the current `manager_person_id`'s `hris_person_id`; dept = incoming `department` code vs the current primary `identity_org_memberships.org_node_source_id`. The scheduled-incremental path keeps its own pre-existing `auto_confirm=True` logic — untouched. Admin surface: `GET/PUT /hr/sync/auto-confirm/settings` + `GET /hr/sync/auto-confirm/history` (30-day audit rows), rendered by `AutoConfirmSettingsCard` atop `HrisSyncPage`.
>
> **Renewal-decision panel** (2026-06-01, 0 migration / cron / schema / RBAC / MCP-tool). New insights tab `GET /api/v1/dashboard/insights/renewals` + `/admin/insights?tab=renewals` (RenewalsModule). Lists software whose `procurement_contracts.expiry_date` (status active/signed) falls within the next 365 days, linked via `software_procurement_meta.current_contract_id`; software with no current contract is excluded. Per row: vendor (`procurement_suppliers.name`), product, `max_seats` (sum of the software's plans), current vendor holders (`software_seat_sync_stat.vendor_license_holders` summed), a 12-month month-end active-seat sparkline (12 point-in-time `software_seat_allocations` snapshots gated on `allocated_at <= month_end AND (revoked_at IS NULL OR revoked_at > month_end)`), a 12-month cost sparkline (seats × current per-seat monthly price), `days_to_expiry`, colour tier (green ≥90d / yellow 30–90d / orange 7–30d / red ≤7d incl. overdue), and `under_licensed` (= holders > max_seats, per the reconciliation definition). **Honest by design**: surfaces the 12-month seat MAX + under_licensed flag, NOT a computed "recommended renewal seats". Gate: `_require_insights_section(user, "renewals")`. `tests/test_dashboard` added to the core-auth CI shard (the dir previously ran in no shard).
>
> **Asset-fallback matrix UI ↔ backend contract aligned — `default_handover_person_id` now actually consumed** (2026-05-31, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count / 0 schema). User pointed out the lifecycle-tab matrix lied: it showed IT/Office Admin as the fallback for API Key / Email Group / Resource-ownership (FK), but the user-promised rule is "有交接人给交接人,无交接给直属经理(lead)" (IT/Office only backstops hardware). Audit found the gap was deeper than UI copy — [`OffboardingProcess.default_handover_person_id`](../../server/app/domains/offboarding/models.py) (set by the IT console's "指定接手人" modal, prefilled from `manager_id` since migration 211) was being written by `OffboardingService.set_default_handover_person` and read back by the admin UI, but the T+0 cron paths [`ProcessAdvanceService._handle_one_blocker`](../../server/app/hr_lifecycle/advance_service.py) and [`LeaverEscalationService._escalate`](../../server/app/domains/offboarding/escalation.py) **never consumed it**. Both jumped straight to `_resolve_fallback_person` (manager-chain → IT-admin-pool → `OFFBOARDING_FALLBACK_EMAIL`) and `_walk_to_active_manager` respectively. So an IT admin could click "transfer this leaver's API keys to person C", click save, and T+0 would silently route them to manager B — the UI was a lie, the column an audit-only field. Fix splits into three pieces, all in one PR. **(1)** `_handle_one_blocker`: non-hardware branch short-circuits to `process.default_handover_person_id` when the row is active, not soft-deleted, and ≠ leaver; otherwise threads through `default_new_owner` (the existing `_resolve_fallback_person` result) unchanged. Hardware path still routes through `_resolve_hardware_recipient` (Office Admin by `asset.location` strict-city match, else `default_new_owner`) — explicitly bypassing handover person, as the matrix promises laptops never enter the handover/manager chain. The `_resolve_fallback_person` docstring is rewritten to document its two roles (gate decision for step-1/step-3 + default recipient for step-3 non-hardware) and explicitly note it does NOT honour `default_handover_person_id` — keeping the gate orthogonal from per-blocker recipient so a misconfigured handover person can't break the gate. **(2)** `_escalate`: identical handover-preferred resolution before falling back to `_walk_to_active_manager` → IT admin pool. The two cron paths must agree on recipient — otherwise escalation opens an `AssetTransferRequest` to manager B at T+0-1d, then advance writes the FK to handover C at T+0, leaving two ledgers describing one resource. Audit detail key renamed `manager_id` → `recipient_id` + `recipient_role` ("handover_person" / "manager"); notification reason text dynamically reads "交接人" vs "经理" based on which path won. No external consumer depends on the old `manager_id` key (verified via repo + test grep). **(3)** [`OffboardingLifecycleTimeline.tsx`](../../web/src/pages/offboarding/OffboardingLifecycleTimeline.tsx): adds a 5th asset row `cloudAccess` (Cloud Access grant — direct revoke, no successor) separate from the generic `revoke` row (SSO / VPN / AI / software seat etc.). API Key / Email Group / Resource-ownership(FK) all get `manager: 'fallback'` and `itOffice: 'na'` (was `itOffice: 'fallback'`); `revoke` row's examples list drops Cloud Access Grant (moved to dedicated row). i18n: `tierDesc.handover` / `.manager` / `.itOffice` rewritten in zh + en lock-step to reflect the new rule; per-row `rule` strings call out "IT/办公室资产员不进此链路". Test: [`test_handover_priority_routing_2026_05_31.py`](../../server/tests/test_offboarding/test_handover_priority_routing_2026_05_31.py) — 8 cases pinning the contract: API Key force-transfer routes to handover (not manager) / handover inactive → manager / handover soft-deleted → manager / handover NULL → manager / hardware ignores handover even when set / handover == leaver corruption defense → manager / escalation routes to handover / escalation handover-inactive → manager. 205 offboarding tests green. **Anti-recurrence pattern**: when a feature exposes a writable field to admins, both UI display AND T+0 (or equivalent execution-time) code paths must read it on the next refactor pass. Audit-only fields are a class of silent lie users will eventually catch.
>
> **Round-61 big-PR re-audit — T+0 grace window restored to 24h (history-wipe P0) + budget-scope ambiguity + jira seat false-alarm** (2026-05-31, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count / 0 schema). 10 parallel domain finders over `git diff a132c2d..main` (procurement rework #583/#585/#587 + offboarding #595/#596/#598/#601/#602 + billing #584/#593/#599 + asset #597 + sso/feishu #586) + one adversarial skeptic per finding. 4 raw → 3 confirmed, 1 refuted. **(R61-A, fixed, P0)** [`ProcessAdvanceService._advance_single`](../../server/app/hr_lifecycle/advance_service.py): Round-59 changed the grace **HOLD** gate from the time-only `if hours_since_discovery < 24` to reuse `grace_card_will_fire` (= `grace_eligible and <24h and not grace_card_already_burnt`). The `hr.process_advance` cron re-selects a BLOCKED process every 15 min — tick 1 stamps `REMINDER_IT_GRACE` and holds; tick 2 finds the card already logged → `grace_card_already_burnt=True` → `grace_card_will_fire=False` → Step 2's hold is skipped and control falls into Step 3 `_force_transfer_all`, overwriting physical-asset / network owner FKs (`RESO_TRANSFERRED`) ~15 min after discovery. The documented 24h IT grace window (P01121 HRIS-late-detection protection) collapsed to a single tick. Fix: split a time-only `grace_window_open = grace_eligible and hours_since_discovery < 24` (the ONLY thing that holds the force-transfer back) from `grace_card_will_fire = grace_window_open and not grace_card_already_burnt` (drives card emission + Step-1 bulk-revoke suppression only). `_notify_it_grace_window` is itself idempotent per process, so the card still fires once; the process holds until the 24h clock genuinely expires. Test: [`test_round61_grace_window_hold_2026_05_31.py`](../../server/tests/test_offboarding/test_round61_grace_window_hold_2026_05_31.py) (tick-2-within-24h must stay `grace_held` with blocker still `RESO_PENDING` — a spied `_force_transfer_all` raises if called; >24h must force-transfer so the fix doesn't pin the hold open). **(R61-B, fixed, P2)** [`update_budget`](../../server/app/domains/procurement/service.py) accepted a raw dict and rewrote scope columns with no dedup, while `create_budget` rejects a duplicate active `(year, department, cost_center, category_id, currency)` scope (DB UNIQUE is loose on MySQL/TiDB — NULLs compare distinct). An admin could widen budget B's scope (cost_center/category → NULL) into budget A's, leaving two same-effective-scope active pools; `find_matching_budget` (`order_by created_at desc limit 1`) then silently picks one and ignores the other's ceiling. Fix: compute the effective post-update scope from payload-or-current and run the same dup-check (`id != obj.id`) **before** any `setattr` (so a rejected edit never autoflushes a dirty value into the open transaction); only enforced when the result is active. Test: [`test_round61_budget_update_dedup_2026_05_31.py`](../../server/tests/test_procurement/test_round61_budget_update_dedup_2026_05_31.py). **(R61-C, fixed, P2)** `jira.seat_sync`'s per-plan `except SeatReconciliationIdentityError` records the duplicate-email directory mismatch into `summary['errors']` and continues, but the string was NOT in `_DEFENSIVE_REFUSAL_FRAGMENTS`, so `raise_if_total_failure` escalated it. On a stable ~500-emp tenant the daily run is routinely 0-import/0-revoke (zero progress) → the run flips to `status='failed'` every steady-state tick and re-pages on-call on ack-then-resurge cycles until a human edits the Atlassian directory. Fix: whitelist the `vendor/observation identity mismatch` fragment in [`_seat_sync_helpers.py`](../../server/app/scheduled_jobs/_seat_sync_helpers.py) — stays visible in `summary['errors']` (partial status) but no longer escalates; a genuine systemic miscount violates the identity for EVERY plan and still surfaces. Test: [`test_round61_seat_identity_mismatch_whitelist_2026_05_31.py`](../../server/tests/test_scheduled_jobs/test_round61_seat_identity_mismatch_whitelist_2026_05_31.py). **(Refuted)** Asset inventory `moved_to` write-back skip when `ManagedLocationResolver.resolve()` returns None is a deliberate, documented tradeoff (the production UIs constrain `new_location` to a dropdown sourced from the active `asset_locations` master, so any user-pickable value resolves; out-of-map values are API-bypass-only and the raw value is already persisted to `item.new_location` + logged + surfaced as `issue_reported`) — not the drift-loop regression it was reported as.
>
> **Round-60 big-PR re-audit — offboarding compliance incidents now dedup per process (forensics-loss fix)** (2026-05-31, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count / 0 schema). 7-agent sweep (5 domain + 2 cross-cutting RBAC/spam) over PRs #594..#601. **(R60-A, fixed)** `ComplianceIncidentService.create_or_upsert_incident` only joins `resource_type`/`resource_id` into its dedup `WHERE` when non-None — `person_id` is NOT part of the key. Four offboarding callsites ([`advance_service`](../../server/app/hr_lifecycle/advance_service.py) step-1 + step-3 `MANAGER_MISSING`, `advance_service` `LEAVER_ORPHAN` force-transfer failure, [`sla_service`](../../server/app/domains/leaver_blocked/sla_service.py) `LEAVER_ORPHAN` 72h breach) passed only `person_id`, so the dedup key collapsed to `(incident_type, status=OPEN)`. During a **batch offboarding** (~500-person scale) the second stuck leaver's upsert matched the first leaver's still-open incident, returned `created=False`, and overwrote its title + merged-over its detail — the on-call admin saw one incident and never learned the others were stuck (silent forensics loss). PR #595 added the step-1 callsite, raising fire frequency. Fix: all four pass `resource_type="offboarding_process"` + `resource_id=str(proc.id)` + `resource_label`. **`str(proc.id)` not the raw `uuid.UUID`** — `compliance_incidents.resource_id` is `CHAR(36)` and asyncmy binds a `uuid.UUID` as bytes → silent 0-row WHERE match → dedup would NEVER fire → a fresh incident every 15-min tick = the inverse failure (alert storm). Same-process `MANAGER_MISSING` (step1↔step3) and `LEAVER_ORPHAN` (force-transfer↔SLA) intentionally collapse to one ticket/leaver; they don't cross with `post_leaver_service`'s per-resource `LEAVER_ORPHAN` (resource_type=asset/api_key). Test: [`test_incident_per_process_dedup_2026_05_31.py`](../../server/tests/test_offboarding/test_incident_per_process_dedup_2026_05_31.py) (3 cases — two stuck leavers → two distinct incidents each keeping its own name; same leaver re-ticked → one row; `str(uuid)` dedups against CHAR(36)). 204 offboarding+leaver_blocked tests green. **(R60-B, flagged for user decision — NOT changed)** Multi-site Confluence seat under-count: prod (2026-05-30 sync) has two active `product_key='confluence'` plans with disjoint paid-site holder sets — `Confluence Standard (agent)` tidb vlh=105, `Confluence US (full member)` US vlh=30 (two separate paid Atlassian subscriptions). #599 made per-plan `vendor_license_holders` site-scoped; #593's family `max`-dedup (`_stat_family_key` keys on `product_key` only) collapses them to max(105,30)=105, dropping the 30 paid US seats. Pre-#599 both plans carried the org-wide ~135 so `max` was correct. **Left unchanged in this round** because #599's ROADMAP and `_seat_count_from_lines`' docstring both document "confluence counts once = 105" as a deliberate design choice; flipping to 135 (key dedup on `product_key`×paid-site) is a high-blast-radius change to multi-round-tuned billing math whose correct target (105 primary-subscription vs 135 all-subscriptions) depends on business intent — surfaced to the user with prod evidence rather than silently shipped. The `_stat_family_key` docstring's "ORG-WIDE, site-agnostic, IDENTICAL per product_key" invariant is now stale post-#599 (the `max` is load-bearing, not cosmetic) — worth a docstring correction when R60-B is resolved.
>
> **Round-59 follow-up — 10-agent re-audit of PRs #592..#600, 3 silent regressions fixed** (2026-05-31, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count). User asked for an extra "4 code-review + 3 logic + 3 security" sweep over the last 9 large PRs; 16 raw findings → 4 adversarially confirmed (3 distinct root causes — #1 and #4 same root). All three findings fall in the "permanently kill an escalation path" or "spurious / spam notifications" categories — exactly what PR #598 trimmed on the manager axis but with the SSO and savepoint-retry axes still open. **(R59F-A)** [`LeaverEscalationService._escalate`](../../server/app/domains/offboarding/escalation.py) used to stamp `REMINDER_ESCALATION` from THREE no-op branches (`not blockers` / hardware-only / `not created`). The top-of-method guard `_already_logged(REMINDER_ESCALATION)` then refused to fire if a later API_KEY blocker materialised (idle-reclaim cron / HRIS resync / manual creation) — manager escalation was silently locked out forever. Fix: drop the no-op log writes; only the success path stamps. Cost: one extra indexed `_pending_must_transfer_blockers` query per hourly tick on hardware-only leavers (negligible). **(R59F-B)** [`ProcessAdvanceService._force_transfer_all`](../../server/app/hr_lifecycle/advance_service.py) appended to `transferred_items` BEFORE the synthetic-transfer INSERT flush inside a per-blocker SAVEPOINT. A flush failure (FK / NOT NULL / deadlock / cloud_access_grant RBAC AppException) rolled back the DB writes and restored blocker attrs from `pre_savepoint_snapshot` — but the speculative entry stayed on the in-memory list, and the post-loop notification fan-out then paged the recipient about a transfer that never committed. A 15-min retry tick repeated the spurious page. Same pattern was introduced by R59F-C for the new `sso_revoked` collector. Fix: snapshot list lengths pre-savepoint (`pre_transferred_len` / `pre_sso_revoked_len`), truncate both in the except block. A static-source `test_force_transfer_all_source_carries_speculative_rollback_truncates` asserts the four invariant lines stay together — refactors that silently drop the truncate trip the test. **(R59F-C — same root as #1)** PR #595 (Round-58) moved `RES_SSO_GRANT` into `IMMEDIATE_REVOKE_RESOURCE_TYPES` so every SSO grant T+0 runs through [`SsoGrantService.revoke`](../../server/app/domains/sso/service.py). `revoke`'s per-grant for-loop dispatched one Feishu+email card per active `global_admin`/`it_admin`. A leaver with 5 SSO grants × 4 IT admins minted **20** admin cards in one tick (and grew unboundedly with N × M) — exactly the regression PR #598 was trimming on the manager axis. Fix: add `notify_admins: bool = True` kwarg to `revoke` (default-True keeps router + MCP + admin manual paths unchanged). [`_force_transfer_all`](../../server/app/hr_lifecycle/advance_service.py) passes `notify_admins=False`, accumulates revoked grants into a `sso_revoked` list, and emits ONE coalesced "[Action Required] Disable SSO for {leaver} — N grants" card per IT admin per leaver per tick. **Redundancy guard**: when `_notify_it_grace_window` will fire in the same tick (24h grace window + grace_eligible blockers exist), the grace card already enumerates immediate-revoked grants in its `immediate_revoked_lines`, so `_force_transfer_all` is called with `emit_sso_bulk_revoke_summary=False` to suppress the duplicate. Computed via a hoisted `hours_since_discovery_pre` check before step 1. The per-grant system-owner card (`system_owner_person_id`) inside `revoke` always fires — it's the per-system routing, not the per-admin fan-out, and is naturally bounded. Tests pinning the contract: [`test_round59_followup_2026_05_31.py`](../../server/tests/test_offboarding/test_round59_followup_2026_05_31.py) — `test_hardware_only_escalation_does_not_burn_idempotency_slot`, `test_late_api_key_blocker_escalates_after_hardware_only_tick` (the regression integration test — hardware-only tick then API_KEY tick MUST escalate), `test_sso_bulk_revoke_emits_one_coalesced_admin_card_per_leaver` (3 SSO × 2 admin → 2 cards total, not 6), `test_sso_revoke_router_path_still_fans_out_to_admins` (single-revoke path unchanged), `test_sso_revoke_with_notify_admins_false_skips_admin_cards` (opt-out works). `test_round58_immediate_revoke_2026_05_30.py::test_mixed_blockers_revokes_permission_immediately_holds_physical` and `::test_partial_immediate_revoke_failure_does_not_overclaim` rewritten to find envelopes by `template_key` rather than hardcoded `await_args_list[0]` — defensive against ordering shifts from future notifications. **Anti-recurrence pattern**: the broader lesson is "any time you move a resource type into the IMMEDIATE_REVOKE bucket, audit the service-layer notification path for per-admin fan-out". The R58 move + R59F coalescing applies to SSO; VPN / AI tool / cloud_access_grant revoke services were spot-checked and do not have the same per-admin fan-out today.
>
> **Manager-notification fan-out trimmed + skip-level removed + asset-fallback matrix on /admin/offboarding?tab=lifecycle** (2026-05-30, 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count). User feedback on PR #594's lifecycle visualization: "直属经理要收到这么多通知吗" + "经理链上级 关掉 他不需要知道" + "完整提醒·经理软兜底 资产和经理没有任何关系". Three real changes, all behind the "经理 ≠ 资产" principle: **(1)** [`escalation.py`](../../server/app/domains/offboarding/escalation.py) `_send_reminder` adds the manager to the recipient set ONLY when `kind == REMINDER_3D` — T-7 / T-1 fan out to leaver + IT admins only (manager card count over the whole lifecycle drops from 5 → 2). **(2)** New `MANAGER_AUTO_TRANSFER_RESOURCE_TYPES = {RES_API_KEY}` constant — a proper subset of `MUST_TRANSFER_RESOURCE_TYPES` (which stays `{RES_HARDWARE, RES_API_KEY}` because the leaver's own reminder cadence still lists hardware to chase). `_escalate` filters by the new subset before auto-opening manager `AssetTransferRequest` rows, so a hardware-only leaver at T+0 generates zero manager cards and stamps a `hardware_only_no_manager_action` reminder-log marker. Hardware return stays end-to-end with IT + Office Admin via [`HardwareDispositionPanel`](../../web/src/pages/offboarding/HardwareDispositionPanel.tsx) executed at T+0 by [`_apply_hardware_disposition`](../../server/app/hr_lifecycle/advance_service.py) (`keep` → held_by_leaver, `recycle_to_office`, `transfer_to_person`). **(3)** [`sla_service.py`](../../server/app/domains/leaver_blocked/sla_service.py) `_dm_manager_chain` now walks `range(1)` (direct manager only — skip-level no longer notified) and `run_tick` gates the WARN-tier DM on a new `_has_non_hardware_pending(proc)` check (laptops alone don't ping the manager). **Frontend** ([`OffboardingLifecycleTimeline.tsx`](../../web/src/pages/offboarding/OffboardingLifecycleTimeline.tsx)) adds a dedicated **Asset Fallback Matrix** section (user-requested: "什么样的资产 什么的人 以什么的判断来兜底") — 5 rows × 6 tier columns, every row carrying examples + the actual code path comment. Recipient matrix drops the skip-level column, introduces a "conditional" cell state with tooltip explaining when the manager is in/out. Scenario-card `outcomeTag` rewritten to the `.v2` keys (no more "经理软兜底" framing). Tests: [`test_manager_notify_trim_2026_05_30.py`](../../server/tests/test_offboarding/test_manager_notify_trim_2026_05_30.py) — 9 cases pinning all the new behaviours (manager not in T-7/T-1, manager in T-3, MUST_TRANSFER vs MANAGER_AUTO_TRANSFER invariant, hardware-only T+0 no transfer, API Key T+0 yes transfer, mixed blocker only-API-Key-routes, skip-level not pinged on WARN, hardware-only WARN suppressed, non-hardware WARN fires). i18n: 25+ new keys, zh-CN.ts + en-US.ts in lock-step. Same PR also retunes `test_sla_dispatch.py` — `_make_blocked_process` fixture attaches a pending API_KEY blocker by default so the new `_has_non_hardware_pending` gate doesn't short-circuit WARN on bare processes, and the old `test_warn_at_24h_uses_manager_chain_not_admin_pool` + `test_warn_manager_who_also_holds_global_admin_gets_admin_url` were rewritten to reflect the skip-level removal.
>
> **`vendor_license_holders` sourced from Atlassian's own `approximateLicenseCount` endpoint (preferred) + Helix paid-site fallback** (2026-05-30, 0 migration / 0 cron / 0 MCP-tool-count; display-accuracy + identity refactor, no 越权). Round-58 final form — after the first cut still showed Jira Software off by 11 (361 vs Atlassian 372), a second prod read-only probe discovered the right endpoint: **`GET /rest/api/3/license/approximateLicenseCount/product/{key}`** (Basic-auth on the paid Jira site) which Atlassian documents as "the approximate number of users assigned a license for the product on this site". Prod 2026-05-30: `jira-software` → 367 (Atlassian Apps page 372, delta **5**), `jira-servicedesk` → 73 (Apps page 71, delta **2**). This is the closest public-API match to Atlassian Admin UI — closer than any of: site-level group count (`jira-software-users` active=284, off by **88**); per-(family, paid-site) product_access count (`jira-software|tidb`=360, off by 12); all of `/admin/v1/orgs/{orgId}/{products,subscriptions,seats,entitlements,licenses,…}` (404/403 on this plan). Atlassian gives **no equivalent endpoint for Confluence** (`/wiki/rest/api/license` etc. all 404 / 400) — Confluence stays on the Helix-computed paid-site bucket sum. **Two-layer sourcing in [`jira_handlers.py`](../../server/app/scheduled_jobs/jira_handlers.py)**: (1) call [`JiraSoftwareDriver.get_approximate_license_count(product_key)`](../../server/app/integrations/drivers/jira_software.py) — if it returns int > 0, use it and stamp `vendor_license_source='atlassian_approximate'` on the per-plan summary; (2) else fall back to 5-bucket paid-site sum (matched + external + no_email + leaver_residual + vendor_inactive) — `holders_off_paid_site` is ALWAYS excluded from the headline because Atlassian doesn't bill for sandbox + personal sites. The driver method catches every error (HTTP, JSON parse, missing field, unsupported product) and returns `None` so the fallback path is robust. **Identity check loosened** ([`_seat_sync_helpers.py:725`](../../server/app/scheduled_jobs/_seat_sync_helpers.py)): from "strict equality with 5-bucket sum" to "vendor_license_holders ≥ holders_matched AND |vendor − 5bucket| ≤ max(50, 30%×sum)". Atlassian's own license_assigned diverges from Helix's product_access by 1–5 holders even when both are correct; strict identity would falsely reject valid Atlassian numbers. Gross divergence (>30%) still raises `SeatReconciliationIdentityError`. **Effect on prod 2026-05-30 (next jira.seat_sync run)**: JSM 85→**73** (Atlassian 71, delta +2 — was +14); Jira Software family 363→**367** (Atlassian 372, delta -5 — was -9); Confluence (tidb) 134→**105** (off_paid_site exclusion alone). Slack / Zoom / Google / Feishu still source vendor count their own way (Slack: in-tenant active member count; Zoom: licensed user count; Google: free trial license count; Feishu: tenant_users) — those handlers don't touch the Atlassian endpoint and pass `holders_off_paid_site=0`, so their numbers and behavior are unchanged. i18n: `seatRecon.equation6` rewritten in zh/en/ja — off_paid_site moved from equation addition to a "另含 X 未计入计费坐席" trailing clause; the drawer's separate `Statistic` card for off_paid_site stays. Tests: `test_atlassian_approximate_license_count_overrides_bucket_sum` pins the priority order; `test_record_seat_reconciliation_raises_on_identity_violation` tests both reject branches (vendor < matched and gross drift); 25 paid-site case all pass; Slack/Zoom/Feishu 21 cases unchanged. **Anti-recurrence**: PRs #538 → #584 → #589 → #593 all fine-tuned Helix's own counting without ever asking "what specific Atlassian-page number are we matching, and which Atlassian endpoint exposes it?". This PR's process — two prod probes, table of candidate endpoints with their actual prod numbers, pick the one closest to user's screenshot — is the template for future vendor-count alignment work.
>
> **Offboarding Lifecycle visualization + Procurement P2P stage chart rework** (2026-05-30, 0 backend / 0 migration / 0 cron / 0 RBAC / 0 MCP-tool-count; frontend display-only). Two admin surfaces gained visual information density after the user called them "丑得没话说,你在糊弄人啊". (1) **离职** — [`OffboardingManagementPage`](../../web/src/pages/offboarding/OffboardingManagementPage.tsx) gets a third tab "生命周期 / Lifecycle" rendering the new [`OffboardingLifecycleTimeline`](../../web/src/pages/offboarding/OffboardingLifecycleTimeline.tsx) component. Four read-only sections all sourced from code (not approximated): three HRIS-arrival timing mini-axis cards (early / same-day / late-arrival a.k.a. P01121); the main 8-milestone × 3-lane timeline (T-7 / T-3 / T-1 / T+0 / +24h / +48h / +72h / T+7d × action / notify / cron); the 8×6 recipient matrix (leaver / direct manager / skip-level / HRBP / IT Admin / fallback owner); the 8-cron table (key + default cadence + purpose). All numbers — SLA 24/48/72h, the 24h IT grace window, post_leaver_verify default N=7 — read straight from [`sla_service.py`](../../server/app/domains/leaver_blocked/sla_service.py) (`SLA_WARN_DELTA` / `SLA_URGENT_DELTA` / `SLA_BREACH_DELTA`), [`advance_service.py`](../../server/app/hr_lifecycle/advance_service.py:404) (`hours_since_discovery < 24`), and [`handlers.py`](../../server/app/scheduled_jobs/handlers.py:1038). (2) **采购** — [`OverviewPage.tsx`](../../web/src/pages/procurement/OverviewPage.tsx) P2P stage chart redesigned: the legacy "9 uniform small boxes + grey connector lines" replaced with three phase containers (需求与审批 / 采购执行 / 财务结算) each holding 3 richer stage cards (numbered badge + icon + owner-system tag for Helix / 汇联易 / Oracle NetSuite / 线下 + state pill + click-through). Live counts from the existing `getOverview()` API hang off PR / PO / Contract / Receipt / Payment cards (no fake zeros on metric-less cards). i18n: 25+ new keys in zh-CN.ts + en-US.ts in lock-step (the i18n hard-rule parity invariant).
>
> **Retroactive-process guard on `_dispatch_offboarding_start_notifications` + procurement migration-review surface retired** (2026-05-30, post-#588 fix). Prod report: `pan.liu` (left 9 days ago) — manager received "请关注下属的离职交接 / 督促下属在离职日前完成" card today. Cascade root cause: PR #588's historical-leaver backfill inserted 48 `identity_persons` + `hr_lifecycle_events('offboard')` for SF `status='f'` employees; the next HRIS confirm wave opened an `OffboardingProcess` per person with the historical `leaver_date`; `dispatch_post_commit_actions`'s future-leaver defer-gate (`local_today <= leaver_date`) doesn't fire for past-dated leavers, so it fell straight through to `_dispatch_offboarding_start_notifications` ([service.py:3646](../../server/app/domains/offboarding/service.py)) which had no past-leaver guard — leaver+manager FYI cards fired with present-tense copy and "X 天前已离职" labels. **Fix discriminator: `(proc.created_at.date() - proc.leaver_date).days > RETROACTIVE_OPEN_GRACE_DAYS (=1)`** — backfill scenario has `created_at = today` for `leaver_date = 9 days ago` → 9-day retroactive gap → suppress + audit + stamp `start_skipped_retroactive` reminder-log marker (so re-fires of the suppression itself don't amplify audit rows). Normal T+1 catch-up has `created_at` weeks BEFORE `leaver_date` → negative gap → notifications fire as designed. One-day grace covers HR recording a same-day departure the next morning. Force-logout above the dispatcher still runs unconditionally — historical leavers still get their session/access cut. New audit action: `offboarding.start_notifications_skipped_past_leaver` (`detail` carries `leaver_date / local_today / created_at / past_days / retroactive_open_days / reason` for forensics). Sibling audit confirmed via 5-lens adversarial review + Round-2 + dedicated security scan: reminder T-7/T-3/T-1 ([escalation.py:157-181](../../server/app/domains/offboarding/escalation.py)) gated by `delta > 0` + non-empty blockers; T+0 escalation ([escalation.py:185-187](../../server/app/domains/offboarding/escalation.py)) gated by `delta <= 0 AND status == PROC_BLOCKED AND blockers`; SLA escalation gated by `first_blocked_at` timer — **this bug class is single-site, not systemic**. CI sharding hole closed: `tests/test_offboarding` was in NO ci.yml matrix bucket (silent coverage gap per `feedback_ci_shard_explicit_paths`) — added to the `asset-hr` shard so future offboarding regressions catch in CI. Same PR retired the **procurement `/admin/procurement/migration-review` admin page + its full backend stack** (`/migration-review` GET + `/migration-review/{type}/{id}/clear` POST endpoints removed from `procurement/router.py`; `list_migration_review` / `clear_migration_review` removed from `service.py`; `MigrationReviewRow` schema removed; `migration_review_pending` field dropped from `/procurement/overview` response; `procurement/MigrationReviewPage.tsx` deleted; OverviewPage banner Alert removed; 17 i18n keys removed across zh + en) — the page was a temporary post-v1→v2 migration review queue, migration is complete, page was dead surface. The `legacy_unresolved_fields` JSON column on `procurement_orders` + `procurement_payments` stays (forensic value, no schema change). Same PR strips the "(v2)" suffix from the suppliers menu label (zh + en). 6 new tests in `test_past_leaver_skip_start_notify_2026_05_30.py` (retroactive suppress / T+1 catch-up fires / audit shape / 1-day grace / 2-day strict-fire / dedupe-on-refire). **0 migration, 0 cron, 0 RBAC, 0 MCP-tool-count, 0 schema. 2 docs same PR (this file + ROADMAP).**
>
> **Billing seat double-count fix — `vendor_license_holders` deduped by product family** (2026-05-30, 0 migration / 0 cron / 0 MCP-tool-count; display-accuracy only, no 越权 / data-loss). The Round-56/57 deferred follow-up, now fixed in its own PR. `software_seat_sync_stat.vendor_license_holders` is an ORG-WIDE, site-agnostic count of a vendor product *family* (keyed by `product_key`; the count side uses `atlassian_activity.all_license_holders(family)` — no site filter), written once per *license plan*. The read layer SUMmed it across plans and software, so a family carried by multiple catalog rows was counted multiple times. **Prod 2026-05-30 verified**: top "生效坐席" card read **3241 vs the real 2119** (1122-seat / **53% inflation**) — dominated by the `jira-software` family modelled as **four** catalogs (Jira Software + Tempo / ScriptRunner / eazyBI marketplace addons, each carrying the identical org-wide 364 → 1456), plus Confluence split per site (tidb 107 + PingCAP US 30). Fix collapses by `product_key` instead of summing: per-software is `SUM` over `MAX(vendor_license_holders) per product_key within software`, and the cross-software top stat dedups the family so each contributes once; NULL `product_key` (e.g. Slack) keys per-plan so distinct unkeyed products still sum. Touches [`billing_service`](../../server/app/domains/asset/billing_service.py) (`_vendor_seats_by_software` now returns `{software: {family: count}}`, new `_flatten_vendor_seats` for the unchanged flat `vendor_seats_by_software` response field, family-deduped `_seat_count_from_lines`) + the 运营洞察 reconciliation overview/drawer in [`router.py`](../../server/app/domains/asset/router.py) (`_compute_seat_reconciliation_overview` / `_detail` dedup per-software, keeping the six-bucket identity intact). Vendor-truth invariant (`feedback_seat_count_aligns_with_vendor_not_bill_lines`) preserved — it just stops counting one family twice. Tests: [`test_billing_seat_family_dedup.py`](../../server/tests/test_asset/test_billing_seat_family_dedup.py) (5) + [`test_seat_reconciliation_family_dedup.py`](../../server/tests/test_asset/test_seat_reconciliation_family_dedup.py) (2).
>
> **Round-57 sweep — same-day big-PR re-audit (#583–#591), 8-agent 2-wave (4 code-review + 3 logic + 3 security), 4 root-cause fixes** (2026-05-30, 0 migration / 0 cron / 0 MCP-tool-count; **procurement read RBAC gate widened**). Each finding re-confirmed against source + adversarially re-reviewed. **越权: zero new** (all procurement writes stay global-admin-only). Fixed: **(P1, reverse-越权)** the procurement v2 read endpoints had regressed from `require_software_read` (the legacy `asset_*` gate + /help matrix row 3: global_admin + asset_admin + finance + auditor + management) to `require_admin_read` (only global_admin + auditor + management), so finance + asset_admin — who still get the full procurement nav in `App.tsx` RoleRoute + `MainLayout` — hit a 403 `loadFailed` on every procurement page; 12 read endpoints restored to `require_software_read` (additive, no role loses access; writes untouched). **(P1)** `/procurement/budgets/me` was gated `require_admin_read` although it self-scopes to `owner_person_id == current.id`; a budget owner is an arbitrary person FK (a roleless dept head), so the `/me/budget` page 403'd its own audience → now self-scoped (`get_current_user`, no role gate, no leak). **(MEDIUM)** `delete_category` only checked `ProcurementOrder` refs though `category_id` is also an FK from contracts + budgets (all RESTRICT) → opaque 500; now checks all three. **(MEDIUM)** PO/line-item money+qty fields (`total_amount`/`unit_price`/`quantity`/`line_total`/`received_qty`) had no `ge=0` guard → a negative `line_total` rolls into `total_amount` which `lock_budget_for_order` silently skips (`<=0`), so a PO looks committed while locking no budget; added `Field(ge=0)`. Tests: `TestProcurementReadGateAndValidation` (7 cases); `test_procurement/` + `test_mcp/` 40/40. **Deferred to its own PR (now FIXED — see the "Billing seat double-count fix" entry at the top)**: the `vendor_license_holders` `SUM…GROUP BY software_id` cross-software double-count — Wave-2 confirmed it manifests today on the workbench top "生效坐席" card because Confluence is two catalogs (migration 206: tidb + PingCAP US) sharing `product_key='confluence'` while the count is family-wide/site-agnostic. It was tuned billing math needing `product_key`-dedup + dedicated tests + prod verification, and is display-accuracy only (not 越权 / data-loss / alert-storm), so it was split out rather than hot-patched into this deploy. Prod verification (2026-05-30) found the real inflation was larger than the Confluence case alone — the `jira-software` family on four marketplace catalogs drove the top card to 3241 vs the real 2119.
>
> **Round-56 三轮 sweep — procurement deep-audit (#583/585/587/589/590), 12-agent multi-pass (4 code-review + 3 logic + 3 security), 11 root-cause fixes (migration 225)** (2026-05-30). **越权: none** (HTTP + MCP RBAC verified intact). Fixed: **(P1)** budget-threshold alert re-fired every ISO week forever → edge-triggered via `procurement_budgets.last_alerted_threshold`; **(P1)** budget lock never re-synced on amount edit / cancel-then-reopen / scope change → new `reconcile_budget_for_order`; **(P1)** `find_matching_budget` crashed on NULL-scoped duplicate budgets → deterministic `limit(1)` + `create_budget` dedup guard; **(P1)** supplier edit silently wiped secondary contacts → preserve-on-edit; **(P2)** reminders dispatched to left employees → `employment_status='active'` gate; **(P2)** supplier/contract/order pickers silently missed entries past the prefetched 200 → shared server-search `EntitySelect`; **(P2)** contract `change_summary` never submitted; **(P3)** `delete_order_item` had no audit snapshot; **(P3)** `create_budget` 500'd on missing year; **(P3)** CategoriesPage error envelope. Follow-up adversarial review caught + fixed 3 issues this diff itself introduced (scope-change release currency mis-tag → uses lock-time currency; cancel+scope-change losing budget trace → pool migration only for active POs; OrdersV2 getContract async race → edit-token guard). Tests: procurement 29/29. See migration 225 row + ROADMAP digest.
>
> **SF bootstrap filter fix + `identity_persons` mutation audit listener + 48 historical leavers backfill (wangdi P0175 case)** (2026-05-30). Pre-launch P0 closed by this PR. **(A) `SuccessFactorsClient.list_all_users()` added** in [`server/app/identity/successfactors_client.py`](../../server/app/identity/successfactors_client.py) — no `$filter` clause, returns both `status='t'` (active) and `status='f'` (forfeited) users in one shot. `HrSyncService.preview_full_employee_sync` ([`hr/service.py`](../../server/app/domains/hr/service.py)) switches to `list_all_users` and routes by SF status: `'t'` → onboard event; `'f'` → offboard event with `endDate` looked up via the new `get_employments_for_users(user_ids)` helper (chunked by 50 on the `userId eq ... or` filter to stay under SF's URL-length budget). Without this, a freshly-deployed Helix instance permanently misses every SF leaver — incremental sync only pulls `lastModifiedDateTime ≥ since`, so any user whose SF row hasn't been touched recently lives in a sync blind spot. **(B) `_upsert_person` lands new INSERTs of historical leavers directly at `employment_status='left'`** when `endDate` is ≥ 30 days past — the leaver_blocked listener explicitly exempts `session.new` ([`leaver_blocked/listener.py:86-89`](../../server/app/domains/leaver_blocked/listener.py)), so this is safe. Recent leavers (≤ 30 days) still go through `leaver_pending` → `hr.process_advance` so in-flight offboarding actions (account revoke, asset return) aren't bypassed. UPDATE writes always continue to land at `leaver_pending` (unchanged contract). **(C) `app/identity/audit_listener.py` — every `IdentityPerson` ORM mutation is audited.** SQLAlchemy `before_flush` collects mutations + `after_flush` emits `audit_logs` rows via `session.add()` in the SAME transaction as the mutation, and `after_soft_rollback` / `after_rollback` clears the pending bucket so a failed flush + later successful flush never leaves a ghost audit row. `_INTERESTING_FIELDS` whitelist filters `last_synced_at` / `updated_at` so every-tick sync no-ops don't flood the audit table. Registered in both [`main.py`](../../server/app/main.py) (API) and [`worker.py`](../../server/app/worker.py) (background jobs). Closes the wangdi forensic gap: prior to this PR `audit_logs` carried zero `identity_persons.*` rows across all of prod, so any out-of-band cleanup left no trace of who/when. **(D) `HR_SKIP_EMAIL_DOMAINS` default gains `sap.com`** ([`config.py`](../../server/app/core/config.py)) — covers the `v4admin / dummy@sap.com` SAP implementation service ghost that would otherwise be backfilled as a noise row. **(E) One-shot backfill script [`backfill_sf_leavers_2026_05_30.py`](../../server/scripts/backfill_sf_leavers_2026_05_30.py)** — pulls SF `status='f'` from every enabled `hris` integration, INSERTs missing persons + `identity_external_identities` + `hr_lifecycle_events('offboard')` + an explicit `audit_logs('identity_persons.backfill', actor_type='script')` row, then re-links three classes of anchor: `cmdb_servers.owner_person_id` ← `JSON_EXTRACT(raw_data,'$.owner')` (only NULL slots, refuses to overwrite a real prior owner); `cmdb_network_owner_history.new_owner_person_id` ← `context.legacy_owner` (resolves the 55 unresolved DNS rows from `restore_dns_owners_from_legacy.py` 2026-05-20); `software_external_holder.person_id` ← `email` match (anchors only, does NOT call any SaaS revoke). Per-user SAVEPOINT, dry-run by default. Reached green after 3 rounds of adversarial code-review (pass-1 fixed 9 findings, pass-2 fixed the after_flush-bypass ghost-row leak, pass-3 0 findings). 15 new tests / 129 pre-existing pass. 0 migration (reuses PR #581's 222 schema) / 0 cron / 0 RBAC / 0 MCP-tool count change.
>
> **Round-56 二轮 sweep — 4 more real bugs (scoped cost-stats leak, dead procurement reminders, duplicate welcome credential email, jira recon whole-cron abort)** (2026-05-30, 0 migration / 0 schema / 0 cron-added / 0 MCP-tool-count change). A second multi-agent sweep over the same PRs found 4 high-confidence bugs the first Round-56 commit missed or deferred: **(1)** the scoped `/cost-stats/*` endpoints ([`asset/router.py::_compute_cost_stats`](../../server/app/domains/asset/router.py)) leaked the org-wide vendor seat count exactly like `list_period_bills` did — the sibling page the first fix didn't touch; same `scope_is_global` gate applied. **(2)** the `procurement.reminders` cron ([`procurement_handlers.py::_push_inbox`](../../server/app/scheduled_jobs/procurement_handlers.py)) called a nonexistent `NotificationService.enqueue_inbox(coalesce_key=…)` swallowed by a blanket `except` → enabled in prod (migration 224) but silently dispatched nothing since #587; rewritten to a real `dispatch(NotificationEnvelope(channels=[]))` (**in-app bell only, no Feishu/email push** so the first real run doesn't burst) with a Python-side per-ISO-week dedup over outbox `context['coalesce_key']` (there is **no `coalesce_key` column** — that was a fiction in the original docstring). **(3)** the welcome email (carries the Google first-login password) had no idempotency guard in [`hr_sync_coordinator.send_welcome_notification`](../../server/app/core/coordinators/hr_sync_coordinator.py); for a hire confirmed ON their start date both `confirm_batch` and the `hr_lifecycle.hire_day` cron fire the hook the same day → duplicate credential email. Now skips if a prior `welcome` outbox row exists. **(4)** **The Round-56 "jira duplicate-email reconciliation crash is unreachable" claim was WRONG** — [`atlassian_activity.fetch_atlassian_activity`](../../server/app/integrations/atlassian_activity.py) explicitly handles a "rare duplicate-email collision (last-write-wins)", proving `by_account_id` can hold two same-email accounts; the `record_seat_reconciliation` call in [`jira_handlers.py`](../../server/app/scheduled_jobs/jira_handlers.py) sat in the `for plan` loop with no try guard, before `db.commit()`, so a `SeatReconciliationIdentityError` aborted the whole daily sync + skipped every remaining plan + paged on-call. The tuned reconciliation math was NOT changed; the call is now wrapped so one plan's violation records a per-plan error and the loop continues (`raise_if_total_failure` still escalates on zero progress). Also fixed in-PR: `create_order` now does a currency-safe `total_amount` roll-up from line items (the budget pool silently locked 0 when omitted), and two stale/misleading admin-facing strings (`feishu.seat_sync` job description "仅导入,不清理" → "导入 + 漂移回收"; the `create_procurement_order` MCP `total_amount` description). Tests +6: `test_scoped_cost_stats_does_not_leak_org_wide_vendor_seats`, `test_reminder_dispatch.py`, `test_welcome_email_idempotent.py`, `test_reconciliation_identity_error_isolated_to_plan`.
>
> **Round-56 review sweep — procurement MCP RBAC + scoped-billing vendor-seat leak** (2026-05-30, 0 migration / 0 schema / 0 cron / 0 MCP-tool-count change). Multi-pass (4 code-review + 3 logic + 3 security) sweep over the same-day large PRs #565–#587. Two real privilege/disclosure bugs fixed: **(A) procurement MCP tools had no RBAC.** The 10 procurement tools in [`server/app/mcp/tools.py`](../../server/app/mcp/tools.py) (`list/get/create_procurement_*` + the `list/get_procurement_budgets` tools added by #587) only passed through `tool_runtime` (active-employee check only) while their HTTP twins are gated `require_admin_read` (reads) / `require_admin` (writes). Any employee can self-mint a PAT, so via MCP an ordinary employee could read every supplier's tax_id / banking / contacts, all PO amounts, and budget pools, and create suppliers + draft POs — data the HTTP layer 403s them. New helper `_require_admin_read(role_names)` mirrors `require_admin_read` (global_admin / auditor / management); the 8 read tools call it, the 2 write tools (`create_procurement_supplier`, `create_procurement_order`) call the existing `_require_global_admin`. The MCP surface must always enforce the same authz as its 1:1 HTTP flow (`feedback_mcp_sync_rule`). **(B) scoped billing views leaked the org-wide vendor seat count.** [`billing_service.list_period_bills`](../../server/app/domains/asset/billing_service.py) used the org-wide `vendor_license_holders` snapshot for `active_seats` and returned `vendor_seats_by_software` for every caller, including the self-scope `/software-billing/{period}/my` (gated only on `get_current_user`) and manager `/subordinates` + `/department`. The vendor-authoritative count is now gated on `scope_is_global = effective_user_ids is None`: only the whole-company view (`/all` without a `bu` filter) reports it; every scoped view (`/my`, `/subordinates`, `/department`, `/all?bu=`) falls back to the in-scope distinct-user count and omits `vendor_seats_by_software`. **CI coverage hole closed**: the test matrix used explicit path lists and `tests/test_procurement`, `tests/test_mcp_tools_surface.py`, and the new `tests/test_mcp` were in no shard (procurement PRs #583/#585 shipped tests CI never ran) — all added to the `core-auth` bucket. Tests +7: [`test_procurement_tool_rbac.py`](../../server/tests/test_mcp/test_procurement_tool_rbac.py) + [`test_billing_scope_vendor_seats.py`](../../server/tests/test_asset/test_billing_scope_vendor_seats.py). Deliberately NOT changed (documented in ROADMAP Round-56): the theoretical jira duplicate-email reconciliation crash (unreachable — Atlassian enforces per-account email uniqueness), the Confluence multi-subscription vendor-count double-count (now FIXED — see the family-dedup entry at the top; prod confirmed the real inflation was `jira-software` × 4 marketplace catalogs, not just Confluence), and already-applied immutable migration 223 nits.
>
> **Feishu seat-binding default flipped from `tenant_users` (read-only mirror) to `user_suspend` (real freeze)** (2026-05-30). Production incident: P01452 pan.liu offboarding `provisioning_task 904d6c79-…` hard-errored `Feishu tenant_users binding is read-only`. The `feishu_software` driver (`server/app/integrations/drivers/feishu_software.py`) has supported real revocation via `_user_freeze` (PATCH `is_frozen=true`) / `_user_delete_with_freeze_fallback` / `_modify_member` since PR #254 (user_create mode) + PR #483 (deadlock fix), but the driver's `binding_schema.operation.default` was still `_OP_TENANT_USERS` — the read-only billing-mirror mode that hard-errors on grant/revoke by design (loud failure instead of silent no-op). Help-center docs ([`web/src/i18n/{zh-CN,en-US,ja-JP}.ts`](../../web/src/i18n/zh-CN.ts) `helpCenter.admin.feishuSeatSync.binding` + `.modes`) compounded the misconfig by recommending `tenant_users` as the default. **Default flipped to `_OP_USER_SUSPEND`** — new bindings now PATCH-freeze on offboarding revoke and PATCH-unfreeze on onboarding grant (account / chats / file ownership preserved; opt in to `hard_delete_on_offboard=true` to DELETE instead). `tenant_users` survives as an explicit opt-in for billing-mirror-only deployments (where Feishu HR or a third-party IDP owns account lifecycle and Helix must not touch accounts). **Runtime strictness — no silent fallback anywhere**: the dispatch path got a new `_resolve_operation(extra)` helper that returns `(op, error)` and refuses an empty/unknown/wrong-type value with a clear message naming every valid mode. The three legacy `... or _OP_TENANT_USERS` fallbacks in `grant_seat` / `revoke_seat` / `revoke_requires_successor` are gone; the matching `or "tenant_users"` defaults in [`feishu_seat_coordinator.py`](../../server/app/core/coordinators/feishu_seat_coordinator.py) and [`scheduled_jobs/feishu_handlers.py`](../../server/app/scheduled_jobs/feishu_handlers.py) are also removed so a malformed binding (operation key missing in JSON) no longer silently mirrors the full tenant on every cron. **Misconfig visibility surface**: new `get_feishu_misconfigured_seat_sync_plans` returns malformed Feishu rows with reasons; the `feishu.seat_sync` handler emits them into `summary['errors']` so operators see the config bug on the scheduled-jobs admin page on every tick. Whitelisted via `_DEFENSIVE_REFUSAL_FRAGMENTS` ("misconfigured Feishu seat-sync binding") so steady-state mirror runs (0 imports + 0 revokes is normal; Feishu seat-sync is import-only by design) don't escalate a misconfig to status=failed and page on-call every tick. `user_suspend` / `user_create` bindings (legitimate non-seat-sync modes) are silently excluded — not reported as misconfig, because the admin explicitly chose them. Help-center docs rewritten across 3 languages: `feishuSeatSync.binding` recommends `user_suspend`; `feishuSeatSync.modes` expanded from 3-mode listing to 4-mode (the `user_create` advanced mode was previously omitted from docs). Driver `hint` text and module docstring updated to explain all four operations with their real grant/revoke semantics. **No automatic migration of existing `tenant_users` rows** ([memory `no_speculative_backfill`](../../../.claude/projects/-Users-ucjmh-Desktop-Helix/memory/feedback_no_speculative_backfill.md)) — the choice between billing-mirror and real-revoke is a deliberate one per environment; only the default for new bindings changes. Prod's single Feishu binding (`software_connectors.867162f0-…`) was manually flipped + the wedged P01452 task reset to pending → `provisioning.dispatch` cron picked it up within 30s → driver hit `resolve_user_id_by_email` → Feishu returned user-not-found (pan.liu already removed from Feishu by HR sync) → driver short-circuited via the `_FEISHU_USER_NOT_FOUND_CODES` idempotency path → task `status=completed`, `external_ref=feishu:pan.liu@pingcap.com:already_absent`. Real API call confirmed reaches Feishu; tenant token + scopes verified ok. 0 migration, 0 cron, 0 RBAC, 0 MCP, 0 schema. 3 docs in same PR. **Tests +3** ([`test_feishu_user_suspend.py`](../../server/tests/test_integrations/test_feishu_user_suspend.py)): `test_operation_default_is_user_suspend` (default + option order + complete option set), `test_missing_operation_fails_loud` (empty extra → grant/revoke both fail with the four valid values enumerated), `test_unknown_operation_fails_loud` (typo `user_freeze` → clear unknown-operation error).
>
> **Mail-group owner-transfer notification coalesce + cloud-grant driver historical incident** (2026-05-29). **(A) `email_group/service.py::transfer_group_owner` coalesces same-leaver same-recipient bursts within a 5-minute window when `initiator='offboarding'`.** Prod 2026-05-13 P0092 huohao offboarding round transferred 10 mailing lists to the CEO (selected as fallback owner) — each transfer fired its own `mail_group_owner_transfer` Feishu push → 10 pings in one offboarding round, all delivered. Now the first call dispatches normally; follow-up calls find the prior outbox row by `(recipient_person_id, template_key, created_at >= now-5min, context.previous_owner_id == from_person.id)` and update its title / body_text / `context.groups` to a digest ("X 的 N 个邮件组 owner 权限已转交给你") in place, returning before any new dispatch. Net: one Feishu push for the burst (recipient still informed in real time via the first group), inbox tile shows the full list under one row. `initiator='on_demand'` is exempt — admin/owner UI transfers each deserve their own push since the recipient hasn't clicked anything. Distinct leavers landing on the same recipient stay distinct (the `previous_owner_id` gate). Stale rows outside the 5-min window do not coalesce (the `created_at` gate). 4 regressions in [`test_owner_transfer_coalesce.py`](../../server/tests/test_email_group/test_owner_transfer_coalesce.py) pin all four invariants. **(B) Historical incident: UCloud + Aliyun cloud-grant driver bugs from the same 2026-05-13 round are now fixed and documented inline.** UCloud `DetachPoliciesFromUser` API requires a `Scope` parameter (values `"All"` tenant-wide or `"Specified"` project-scoped — NOT `"Account"` despite the legacy Attach code using that). Pre-fix the driver omitted `Scope` entirely → RetCode=220 "Missing params [UserName or PolicyURNs or Scope]" (270 retry-storm rows in prod audit). Mid-day Round-23 attempt added `Scope="Account"` → RetCode=230 "Invalid Scope" (120 more rows). Final fix sends `"All"` — see the in-source comment block in [`ucloud_cloud.py:559-577`](../../server/app/integrations/drivers/ucloud_cloud.py). Aliyun `DetachPolicyFromUser` rejects internal `role_key` values like `poweruser` with `InvalidParameter.PolicyName.InvalidChars`; the driver now requires `role_key` to be `"<PolicyType>:<PolicyName>"` (e.g. `Custom:HelixPowerUserPolicy`) and explicitly splits on `:` ([`aliyun_cloud.py:781-797`](../../server/app/integrations/drivers/aliyun_cloud.py)). Both 5/13 retry storms (390 UCloud + 57 Aliyun = 447 failed audit rows for 7 distinct grants) are residual noise from before each fix landed — the grants themselves all reached `status='revoked'` by 19:16 local on that day. 0 migration / 0 cron / 0 RBAC / 0 MCP tool count change.
>
> **`ChangeHolderModal` 闲置 → 离职持有 backfill path** (2026-05-29, 0 migration / 0 cron / 0 RBAC / 0 MCP). Asset admin reported the unified `变更持有` Modal hid 「离职员工持有」 when the asset was `idle` (no holder to attribute to) — correct logic for the original semantic, but real-world admins had pre-Helix data fixes to record ("this idle laptop is actually still with departed employee X" — never went through claim/return in Helix). The Modal's `allowedTargets()` switch ([`web/src/pages/asset/ChangeHolderModal.tsx`](../../web/src/pages/asset/ChangeHolderModal.tsx)) now offers `leaver` for `idle` / `available` rows too, and renders a `UserSelect` scoped to `employment_status ∈ {left, terminated, leaver_pending}` so the admin must explicitly resolve attribution to a real Person row (no free-text — refuses to fabricate audit chains; memory `feedback_classification_requires_proof_not_pattern`). The backend service `AssetService.mark_held_by_leaver` ([`server/app/domains/asset/service.py`](../../server/app/domains/asset/service.py)) accepts the new `leaver_person_id: UUID | None` kwarg: when `status ∈ {idle, available}` it is REQUIRED + must resolve to a non-deleted `identity_persons` row (else `LEAVER_PERSON_REQUIRED` / `LEAVER_PERSON_NOT_FOUND` 400). Service stamps `asset.assigned_to = leaver_person_id` and the `AssetOperation` carries `from_user_id=NULL, to_user_id=leaver_person_id, detail={prior_status: idle, prior_assigned_to: null, backfilled_leaver_person_id: <uuid>}` so `recover_from_leaver` later restores to the pre-backfill idle shape (assigned_to → NULL) and undo is mechanical. In-use → leaver path (assigned/claimed/borrowed) is unchanged — `leaver_person_id` is ignored when the row already has a holder. Pydantic `AssetMarkHeldByLeaverRequest` + TS `AssetMarkHeldByLeaverRequest` gain matching optional `leaver_person_id` fields. 4 new tests in [`test_held_by_leaver_2026_05_12.py`](../../server/tests/test_offboarding/test_held_by_leaver_2026_05_12.py) pin: backfill happy path, recover wipes assigned_to back to NULL, missing person_id 400, non-existent person_id 400; existing in-use path tests untouched.
>
> **Happiness easter-egg layer** (2026-06-03, feat(easter-eggs): a dependency-free joy primitive. [`web/src/features/easterEggs/celebrate.ts`](../../web/src/features/easterEggs/celebrate.ts) exports `celebrate({titleKey, bodyKey?, emojis?, count?, mode?, origin?})` + `heartBurst(x,y)` which dispatch `helix:celebrate` / `helix:heartburst` CustomEvents; the single globally-mounted [`<EasterEggLayer/>`](../../web/src/features/easterEggs/EasterEggLayer.tsx) (mounted at the top of `<AntApp>` in [`App.tsx`](../../web/src/App.tsx), so it's live pre- and post-auth) listens and renders an emoji-confetti burst (imperative DOM, removed on `animationend`, capped at 90 pieces, `prefers-reduced-motion` skips it) + an auto-dismissing "happy card" carrying a random `easterEgg.quote.N` line. Decoupling via events means trigger sites (login mascot/cat/sticky, header logo) just import `celebrate` — no shared context. Helpers `tapStreak(id, needed, withinMs)` (N-rapid-taps on a far-away element, e.g. header logo ×7) and `oncePerSession(key)` (sessionStorage-guarded one-shots). **20 eggs** — login: sticky-note click / cat (heart burst + meow) / mascot ×5 / double-click `Helix` brand title / collect all 3 chalk sticks / click books 📚 / click plant 🪴; global: Konami code / header `Helix` logo ×7 / typed-word eggs (happy·joy·smile·kaixin·shiawase, plus `lgtm` / `coffee`·`tea` / `helix`, all ignored inside inputs — see `WORD_EGGS`) / palindrome clock minute (11:11, 12:21, 22:22… → "make a wish", once per occurrence) / Friday-afternoon TGIF (once/session) / triple-click anywhere (heart burst at pointer) / scroll-to-page-bottom (once/session) / switch UI language ×3 (via `i18n.on('languageChanged')`) / return to tab after ≥2 min away (once/session) / ~90 s idle nudge (once/session). All copy in 28 `easterEgg.*` keys × zh/en/ja; the login sticky note `login.buddy.stickyNote` changed from 「别忘了打卡」 to 「开心每一天」 (company doesn't clock in). **Documented in `/help`**: a new `easter-eggs` topic under the employee tab ([`bodyEasterEggs` in HelpCenterPage.tsx](../../web/src/pages/help/HelpCenterPage.tsx)) lists how to trigger all 20 (`helpCenter.fun.easterEggs.{title,intro,outro,e1..e20}` × 3 langs; anchor auto-registered by `buildAnchorMap()`). 0 backend / 0 migration / 0 cron / 0 RBAC / 0 MCP-tool change. **To add an egg**: import `celebrate`/`heartBurst` at the trigger site (or add a `WORD_EGGS` row for a typed word), add an `easterEgg.<name>.title` key to all three bundles, and a `helpCenter.fun.easterEggs.eN` line so the manual stays complete.)

> **Festival / seasonal skin layer** (2026-06-22, feat(web): the "有温度/有爱" series finale — extends the easter-egg engine with an ambient festival skin. [`festivals.ts`](../../web/src/features/easterEggs/festivals.ts) is the festival calendar: fixed solar-calendar festivals by MM-DD (新年/劳动节/国庆/冬至/圣诞) + lunar festivals (春节/元宵/端午/中秋) listed as explicit Gregorian dates for 2026–2028 (hand-maintained, like the egg triggers; extend yearly). `resolveTodaysFestival(date)` is a pure date→festival lookup. [`<FestiveLayer/>`](../../web/src/features/easterEggs/FestiveLayer.tsx) (globally mounted beside `<EasterEggLayer/>`): on a matching day it lazily resolves the festival (pure initial state — no sync setState in effect), then once-per-session plays a brief sparse drifting-emoji ambient (one-shot, ~12s, `animationend` self-cleans, z-index below the celebration burst) + a greeting card with a dismiss + a "turn off festive effects" persistent mute (`localStorage helix.festive.off`). **Respects `prefers-reduced-motion`** (skips the drift). Config lives in code (consistent with egg triggers); the per-user mute makes it turn-off-able; zero-disturbance (one brief moment per session, then quiet). 9 festival greetings + mute/dismiss copy × zh/en/ja. 0 backend / 0 migration / 0 cron / 0 RBAC / 0 MCP. **To add a festival**: add a `FESTIVALS` entry + a `FIXED`/`LUNAR` date + a `festive.<id>` greeting in all three bundles.)
>
> **借用延期 (Borrow Extension) — no-approval `expected_return_date` adjust + 3-party push** (2026-06-23, feat(asset borrow extension), 0 migration / 0 cron / 0 new RBAC role / +1 MCP tool). Employees borrow gear (`status='borrowed'`, `expected_return_date` set by `borrow_asset`) but had no way to push the return date out before it came due. New endpoint **`POST /assets/{id}/borrow-extension`** ([`AssetService.extend_borrow`](../../server/app/domains/asset/service.py) + `AssetBorrowExtensionRequest{expected_return_date, reason}`) is **dual-authorized**: the **borrower themselves** (self-service) OR an **asset admin** (`can_manage_assets`; region-gated via `_enforce_asset_region` when acting on someone else's loan). It's the FIRST asset lifecycle endpoint gated on `get_current_user` rather than `require_asset_admin` — the borrower path is intentional. `allow_earlier` = `is_admin and not is_borrower`: a borrower may only push the date LATER (else `INVALID_RETURN_DATE` 400); an admin may set any future date (incl. earlier). Asset must be `borrowed` (else `ASSET_NOT_BORROWED` 400). Writes `AssetOperation(operation_type='borrow_extension', detail={reason, previous_expected_return_date, expected_return_date, actor_kind})` for audit. **No approval** — the date mutates immediately; the existing daily `cron.asset.borrow_overdue` reaper naturally stops escalating once the date moves, no extra wiring. **3-party notification fan-out** (`AssetService._notify_borrow_extension`, lazy-imports the notification stack to keep it off `AssetService` import) — this intentionally OVERRIDES the standing `feedback_no_new_pushes_to_employees` "IT-only" rule (user-approved 2026-06-23; event-driven on the actor's own click, no backfill storm): borrower (self → confirmation card; admin-acted → FYI card) + direct lead (`manager_person_id`, active + work_email, deduped, skip if == actor) + asset admins (`list_admin_recipients`, **only on borrower self-service** — an admin's own action doesn't fan out to the whole admin pool). FE: employee 我的申请「硬件资产类」static card #5 → [`BorrowExtensionModal`](../../web/src/pages/asset/BorrowExtensionModal.tsx) (asset dropdown lists only `status==='borrowed'`, default-selects first; date picker floors at current due date+1); admin [`AssetListPage`](../../web/src/pages/asset/AssetListPage.tsx) `borrowed`-row ⋮ adds 「借用延期」. MCP `extend_asset_borrow` (mutate, 1:1 with the HTTP route, dual-auth mirrored). Tests: [`test_borrow_extension_2026_06_23.py`](../../server/tests/test_asset/test_borrow_extension_2026_06_23.py) (self-extend + outbox row, can't-shorten 400, admin any-date, non-borrower 403, non-borrowed 400). i18n zh/en/ja.
>
> **Cloud-grant approve/renew 500: aware-vs-naive `expires_at` + approver deep-link to 待审批 tab** (2026-06-03, fix(cloud grant approve), 0 migration / 0 cron / 0 RBAC / 0 MCP-tool). Prod incident (trace `fe188c39…`, Alex.Quan): `POST /cmdb/grants/{id}/approve` 500'd with `TypeError: can't subtract offset-naive and offset-aware datetimes` **after** the grant was already committed — so the access was granted but the response errored. Root cause is the `expire_on_commit=False` interaction: the approve/renew dispatch in [`lifecycle_service.py`](../../server/app/domains/cmdb/lifecycle_service.py) assigned `datetime.now(UTC) + timedelta(...)` (tz-**aware**) to `grant.expires_at`, and the router's post-commit `db.get(grant)` returns that same in-memory object (not a fresh DB load), so [`_serialize_grants`](../../server/app/domains/cmdb/lifecycle_router.py)'s `(r.expires_at - now).days` subtracted a naive `now` from an aware value. It only reproduced on the approve/renew response itself — any later list fetch reloads the row from MySQL/TiDB (tz stripped → naive), which is why it looked intermittent / "time-related". **Fix** = belt-and-suspenders: (a) source now stores **naive UTC** at both approve-dispatch and renew (aligns with the codebase-wide "DateTime columns round-trip to naive" convention — see memory `mysql_datetime_naive_on_fresh_load`); (b) `_serialize_grants` AND the my-scopes summary serializer both coerce `expires_at` to naive UTC before any `- now` / `< earliest` arithmetic, so no caller can re-introduce the crash. Regression: [`test_serialize_grants_aware_expires.py`](../../server/tests/test_cmdb/test_serialize_grants_aware_expires.py) (aware + naive both serialize a correct `days_until_expiry`). **UX half**: the grant-request approver notification URL changed from bare `/cloud/scopes/{id}` to `/cloud/scopes/{id}?tab=pending` (`CloudScopeRedirect` preserves `location.search` into `/admin` or `/my`); both detail pages ([`CloudScopeDetailPage`](../../web/src/pages/cloud/CloudScopeDetailPage.tsx) / [`MyCloudScopeDetailPage`](../../web/src/pages/cloud/MyCloudScopeDetailPage.tsx)) consume `?tab=pending` once (only when the pending tab actually exists, then strip the param) and fall back off the `pending` tab when it unmounts after the last approval (controlled `activeKey` pointing at a removed tab renders blank otherwise).
>
> **Shared loading-skeleton primitives `<SkeletonTable>` / `<SkeletonCard>` / `<SkeletonKpiRow>`** (2026-06-08, [`web/src/components/Skeletons.tsx`](../../web/src/components/Skeletons.tsx)). Structural first-paint placeholders that match the eventual layout (table header+rows / chart card / KPI tile row), replacing the "centred `<Spin>` = blank screen" pattern on large list/analytics pages. Pure presentation, zero domain logic — drop into any page. Introduced by the frontend-visualization uplift (PR1/5); being rolled out page-by-page. **Reuse these, don't reinvent a per-page skeleton.** Pairs with the existing ECharts report kit [`dashboard/reportsShared.tsx`](../../web/src/pages/dashboard/reportsShared.tsx) (`ChartCard/Donut/HBar/BarTrend/LineTrend/GradientKpi/DeltaTag/toDonutData/toHBarData/PALETTE`) — the canonical way to add charts/KPIs to ANY page (not just admin/insights). Operational (non-insights) consumers so far: `AssetListPage` (hero `GradientKpi` row + collapsible overview: status/category `Donut` + brand/location/usage-company `HBar`, from the existing `AssetStats` payload); `SoftwareCostPage` (12-month seats/cost `LineTrend` from the `/software-billing/periods` index + billing-cycle `Donut` + seats-by-software/department `HBar` — distributions count **seats/lines not money** to honour the page's never-sum-across-currencies invariant, and the cost trend omits mixed-currency months); `ProcurementOverviewPage` (orders-by-status `Donut`); `CompliancePosturePage` (controls-by-status `Donut` + framework-readiness `HBar` + coverage stacked `BarTrend`, from the existing `Posture` payload); `RiskScorePage` (risk-level `Donut` + score histogram `BarTrend`). Most derive charts client-side from already-loaded payloads — **no new endpoints**. The one exception: `GET /auditor/risk-scores` `summary` gained **`by_score_bucket`** (FLOOR(score/10) full-table group-by, score==100 folds into the 90-100 bucket) alongside the existing `by_level`, so the histogram reflects the whole scored population, not the current 50-row page — a read-response augment to an existing endpoint (no new route, no MCP tool, MCP-sync N/A). Regression: [`test_risk_score_summary.py`](../../server/tests/test_auditor/test_risk_score_summary.py). JML workflow pages (`ProvisioningQueuePage` / `OffboardingAdminPage`) also chart from two new read-only aggregate endpoints — **`GET /provisioning/analytics`** ([`ProvisioningService.analytics_summary`](../../server/app/domains/provisioning/service.py)) and **`GET /offboarding/analytics`** ([`OffboardingService.analytics_summary`](../../server/app/domains/offboarding/service.py)) — both admin-read gated, single-trip multi-GROUP-BY, sharing the [`day_axis`](../../server/app/core/timeseries.py) trend helper. They mirror the existing 定时任务/审计/审批 analytics-tab pattern (no MCP tool — read-only analytics, not a self-service flow). `ProvisioningQueuePage` replaced its former 4× narrow count probes with the single `/provisioning/analytics` call. Regressions: [`test_provisioning/test_analytics_summary.py`](../../server/tests/test_provisioning/test_analytics_summary.py), [`test_offboarding/test_analytics_summary.py`](../../server/tests/test_offboarding/test_analytics_summary.py). `EmployeeRequestPage` charts from **`GET /approval/asset/requests/summary`** ([`ApprovalService.asset_request_summary`](../../server/app/approval/service.py)) — scoped identically to `GET /asset/requests` (self for non-admin, domain-narrowed for admin) so chart population matches the list; returns by_status/by_request_type + windowed daily trend (regression [`test_approval/test_asset_request_summary.py`](../../server/tests/test_approval/test_asset_request_summary.py)). The page is also the first real consumer of the `SkeletonCard` primitive (first-paint placeholder while the summary loads). **5-PR uplift complete** (#793/#794/#797/#799 + this): the ECharts kit now serves 8 operational pages, not just admin/insights.
>
> **`/help` is a docs portal, not Tabs** (2026-06-18, feat(help)). [`HelpCenterPage.tsx`](../../web/src/pages/help/HelpCenterPage.tsx) was rebuilt from "audience Tabs + Collapse topics" into a **docs portal**: full-width search Hero + left category sidebar (`Menu` groups, always-expanded — derived from the shared [`helpTopicIndex.ts`](../../web/src/pages/help/helpTopicIndex.ts) via `buildSections()`) + a right single-article content pane (`TopicArticle` with breadcrumb + prev/next over the flat topic order). The search box filters the sidebar by translated title. **Topic bodies, the Quick-start overview (role cards / permission matrix / architecture + AI-copilot Mermaid diagrams), and the `TopicItem.id` ⇄ anchor invariant are unchanged** — deep links (`/help#<id>`, the `#roles`/`#permission-matrix`/`#architecture`/`#ai-copilot`/`#quick-start` overview anchors, `#faq`, `#contact`, and section aliases `#employee`/`#admin`/`#hr-finance`/`#ai-integration`/`#manager`) resolve via `resolveAnchor()`, so PageHelp's "查看完整说明" and the ⌘K palette still land correctly. The `manager` audience was removed: team-lead topics (team-view / team-approvals / escalation) now render as a "如果你是团队负责人" subsection inside the Employee section and are tagged `audience:'employee'` in the flat search index (decision in DECISIONS.md). HR/Finance content was refreshed (Finance Hub + Employee-number topics added; "finance is read-only" corrected). A new **`求助 / 工单` landing** links the AI copilot (`/me/support/ai`), ticket flow (`/me/support`), and the Feishu Q&A group — group URL from the **new `help_center.qa_group_url` platform setting**, falling back to the IT Service Desk applink exported from `ITServiceDeskMascot` (`FEISHU_IT_DESK_URL`). FAQ grew 3 → 12. **To add a topic**: add it in `helpTopicIndex.ts` AND the matching `*_TOPIC_PARTS` map (TS enforces both directions), with i18n in all three bundles.)
>
> **`<PageHelp>` inline page-help primitive + `PageHeader.helpId`** (2026-06-03, feat(help/UX): reusable per-page "?" guidance affordance. [`web/src/components/PageHelp/PageHelp.tsx`](../../web/src/components/PageHelp/PageHelp.tsx) renders a `QuestionCircleOutlined` that opens an Antd `Popover` (one-line desc + 2–4 bullet tips + a "查看完整说明 →" deep link to `/help#<anchor>`). All content is **data-driven** from [`pageHelpRegistry.ts`](../../web/src/components/PageHelp/pageHelpRegistry.ts) (`PAGE_HELP[id] = { bullets, anchor? }`) + flat i18n keys `pageHelp.<id>.{title,desc,b1..bN}` — the component itself has zero copy, so adding a hint never edits TSX. [`PageHeader`](../../web/src/components/PageHeader/PageHeader.tsx) gained an optional **`helpId` prop** that auto-renders the affordance beside the title, so any of the ~106 PageHeader-based pages opts in with one prop (pages without PageHeader, e.g. HealthConsolePage, mount `<PageHelp>` directly). **24 major landing pages wired** (8 employee + 16 admin). Each `anchor` MUST match a `TopicItem.id` in [`HelpCenterPage.tsx`](../../web/src/pages/help/HelpCenterPage.tsx) (deep link auto-expands that topic) — a CI-grep-style invariant the PR validated for all 20 anchors. Same PR enriched the manual with 7 previously-undocumented topics (employee: 我的入职; admin: 用户管理 / RBAC / 采购 / 定时任务 / 平台健康台 / 数据对账中心) AND added a reusable `<HowToSteps t prefix count>` numbered-`<ol>` block (shared heading `helpCenter.howto.stepsTitle`, step copy at `<prefix>.step1..N`) appended to **22 highest-traffic how-to topics** — 19 employee (login / settings / my-IT-universe nav / mobile-Feishu / submit-request / asset-transfer / offboarding / Google self-service / email-alias / mail-group-directory / cloud-access / cloud-sub-account / DNS-record / API-key end-to-end / feed post+celebrate / notifications / software-costs / cloud-billing / my-onboarding) + 3 manager (team-view / approvals / fallback-transfer) — each step written for a zero-knowledge first-time user, naming the exact menu+button to click. i18n: 154 + 98 step keys × zh/en/ja. 0 backend / 0 migration / 0 cron / 0 RBAC / 0 MCP-tool change. **To extend**: add a registry entry + the i18n keys, pass `helpId` on the page.)
>
> **Early corporate-email reservation — decoupled from account creation** (2026-06-25, 0 migration / 0 cron / 0 RBAC / 0 MCP). The planned work email is now derived + written back to SF the moment an `onboard` event arrives (even for a future hire), so HR sees the address before day-1 — separate from minting the live Google account (still deferred to `hire_date`, see the not_before entry below). [`account_provisioner.reserve_work_email_for`](../../server/app/domains/google_workspace/account_provisioner.py) reuses `create_account_for`'s derivation precedence (HRIS email → given.family pinyin → employee_no), collision-checks against Helix-known addresses (`_known_taken_emails`: every `work_email` + Google `provider_email`, so two same-named pending hires get distinct planned addresses), persists `person.work_email`, and calls `notify_hris_email_assigned`. Wired into [`_dispatch_lifecycle_coordinators`](../../server/app/domains/hr/service.py)'s onboard branch via [`hr_sync_coordinator.reserve_and_writeback_email`](../../server/app/core/coordinators/hr_sync_coordinator.py) — NOT future-hire-deferred. Gated on `auto_create_on_hire`; skips if a live Google identity already exists; never raises. A rare same-name collision at actual creation on `hire_date` is self-correcting — `create_account_for`'s writeback is unconditional and overwrites with the final address. Address is "planned/best-effort" until creation confirms against Google (the authority). Tests in [`test_future_hire_gate_2026_05_29.py`](../../server/tests/test_google_workspace/test_future_hire_gate_2026_05_29.py). **(2026-06-26)** `SuccessFactorsWebhookEvent` gained an optional `email_name` field (JSON key `emailName`/`email_name` via `AliasChoices`) threaded through `_dispatch_lifecycle_coordinators` → `reserve_and_writeback_email` → `reserve_work_email_for` as the **TOP-priority** local-part source (above SF `User.email` and pinyin) — HRIS can dictate the mailbox name in the event. Note: SF's `User` entity has NO `emailName` OData property (verified against prod), so this can only come from the pushed payload, not an OData pull.

> **Reconcile-first account provisioning + google login-link reconcile** (2026-06-26, 0 migration / 0 new cron). The company's PRIMARY Google account creation happens OUTSIDE Helix (HRIS-driven at hire), so `create_account_for` almost always fires for a person whose mailbox already exists. It used to go straight to `create_user` and, on the 409, append `01` — minting a **duplicate** `name01@`. Two latent failures compounded it: the configured OUs (`/Employees`, `/Contractors`) didn't exist in the tenant (real staff sit under `/PingCAP/*`), so `auto_create_on_hire` had **never once succeeded** since 2026-05-02; and 181 active staff + 4 confirmed leavers had Google accounts Helix had never linked (so offboarding's SSO-suspend was a silent no-op — 4 ex-employees stayed live 2–4 months). **Fix ①**: `create_account_for` now does a **reconcile-first** lookup of the person's authoritative `work_email` in Google (never a name-derived guess); a hit → `_link_google_identity` (create/re-activate the `google` IdentityExternalIdentity + seat, unsuspend a returning rehire's mailbox, return `linked`); a lookup error → **fail loud, never fall through to create** (would re-mint the duplicate); genuinely absent → the original create loop. **Fix ②** (folded into the existing daily `google.seat_sync` sweep — NOT a separate cron, since seat_sync already lists the whole tenant + matches by email): its Phase A now also creates the `google` login link for a matched ACTIVE person who has a Google mailbox but no `IdentityExternalIdentity` (the seat stays seat_sync's job; this only adds the identity row — reconcile-only, never creates accounts; errors ride seat_sync's existing `raise_if_total_failure` → SLO / `failure_notifier`). **It deliberately does NOT auto-suspend a `left` person's still-live Google account**: such an account legitimately stays open when intentionally retained — the orphan inventory surfaces it as a `former_employee` row and the admin marks it kept (e.g. `purpose='CBG保留 …使用'`). Auto-suspending would override those retain decisions, so leaver-account disposition stays a human call via the orphan feature (which only tombstones a remnant AFTER the admin deletes it in Google); the offboarding flow owns real-time suspend-on-leave for accounts that should close. (An initial version of this PR added a blanket Phase-A2 leaver-suspend; it was reverted same-day when 3 of the 4 swept accounts turned out to be intentionally-retained `保留` accounts.) Data-plane remediation run in prod (auto_create disabled pending this rollout then re-enabled, OUs corrected to `/PingCAP` + `/`, 181 linked). Tests: [`test_reconcile_first_2026_06_26.py`](../../server/tests/test_google_workspace/test_reconcile_first_2026_06_26.py) + [`test_google_seat_sync_identity_leaver_2026_06_26.py`](../../server/tests/test_scheduled_jobs/test_google_seat_sync_identity_leaver_2026_06_26.py).

> **`provisioning_tasks.not_before` defer-execution gate + offboarding/onboarding date-handling fix** (2026-05-29, [migration 221](../../server/alembic/versions/221_provisioning_task_not_before.py)). Production incident: HR set 庄清苓 Thalie Zhuang's termination_date 30 days ahead; HRIS sync stamped `leaver_date=2026-06-29` correctly but `_apply_software_policy` / `_apply_account_policy` ([`offboarding/service.py`](../../server/app/domains/offboarding/service.py)) created provisioning_tasks with no time gate, and the 30-second `provisioning.dispatch` cron ran them immediately — Gmail + Atlassian groups revoked 30 days early. `dispatch_post_commit_actions._push_force_logout` also fired regardless of leaver_date. **New `provisioning_tasks.not_before DATETIME(6) NULL`** + index `ix_provisioning_tasks_status_not_before(status, not_before)`. NULL = run ASAP (grants, approval completions, manual ops, audit-driven remediations — all preserved). Non-NULL = "do not dispatch before this UTC moment". (1) **Dispatcher SELECT** ([`execute_pending_batch`](../../server/app/domains/provisioning/service.py)) filters `not_before IS NULL OR not_before <= NOW()`. (2) **`execute_task` defensive re-check**: catches manually-pulled tasks (admin retry / MCP call) — naive timestamps from MySQL round-trip are coerced to UTC so the comparison can't TypeError-crash the dispatcher tick (memory: `mysql_datetime_naive_on_fresh_load`); deferred tasks write `provisioning.deferred_not_before` audit and stay PENDING. (3) **Offboarding writes `not_before = (leaver_date + 1 day) at 00:00 in person.local_tz → UTC`** via new helpers `_leaver_revoke_not_before(person, leaver_date)` and `_local_today_for_person(person)` ([`offboarding/service.py`](../../server/app/domains/offboarding/service.py)) — mirrors the local-TZ refinement in [`advance_service.run_tick`](../../server/app/hr_lifecycle/advance_service.py) for force-transfer. (4) **`dispatch_post_commit_actions` defers force-logout + start-notifications** when `local_today <= leaver_date`; the process row + blockers are durable (admin still sees the leaver in the offboarding panel) but vendor-side session kills wait. New `offboarding.post_commit_deferred_future_leaver` audit. (5) **`suspend_sso` explicit override semantics**: admin (or `advance_service.run_tick` T+0 cron) clicking suspend = "execute now"; the loop clears every pending task's `not_before` before calling `execute_task`. T+0 cron path is a no-op clear (leaver_date already past). Audit row carries `not_before_overridden_task_ids`. (6) **`cancel_process` software_account restoration surface**: `execute_task` post-success now stashes `result.raw_response` into `task.context_data['vendor_response']` (the `revoke_all_access` driver returns `removed: [...]` — the Atlassian group list). `cancel_process` reads the list, emits `software_account.restore_manual_required` audit per blocker + a `skipped_restore_reasons` entry naming the removed groups, so an admin reverting a false-leaver knows exactly which Atlassian groups to re-add (Helix has no `grant_to_groups` driver method yet; future enhancement). **Onboarding mirror**: HR enters new hires into SuccessFactors weeks before start; the HRIS sync hooks `provision_for_new_hire` / `provision_google_account` / `send_welcome_notification` all now gate on a new `_is_future_hire(person)` helper ([`hr_sync_coordinator.py`](../../server/app/core/coordinators/hr_sync_coordinator.py)) and short-circuit when `local_today < hire_date`. `account_provisioner.create_account_for` ([`google_workspace/account_provisioner.py`](../../server/app/domains/google_workspace/account_provisioner.py)) gains the same gate. The daily `hr_lifecycle.hire_day` cron ([`hire_day_service.py`](../../server/app/hr_lifecycle/hire_day_service.py)) was extended to call the three deferred hooks at the top of `run_for_today` so a future-dated hire gets their Google account + welcome email + auto-provisioning on hire_date itself, BEFORE the existing IT-info push runs (so the IT info reflects freshly-allocated seats). All three hooks are idempotent — calling them on hire_day for a legacy past-dated hire is a no-op via their own existence checks. **Tests**: [`test_future_leaver_not_before_2026_05_29.py`](../../server/tests/test_offboarding/test_future_leaver_not_before_2026_05_29.py) (10 cases: helper math, future-leaver task carries not_before, past-leaver runs immediately, dispatcher filter, execute_task defensive skip, dispatch_post_commit defer, suspend_sso clear-then-dispatch, cancel_process account-restore audit) + [`test_future_hire_gate_2026_05_29.py`](../../server/tests/test_google_workspace/test_future_hire_gate_2026_05_29.py) (5 cases on `_is_future_hire` + `create_account_for` short-circuit). 0 RBAC role change, 0 MCP tool change (`_REGISTERED_TOOLS_COUNT_HINT` unchanged), 0 cron added (`hr_lifecycle.hire_day` behavior extended), 1 migration (221). 3 docs updated same PR. **Historical state**: Thalie's Google seat already cancel→restored at 2026-05-29 10:53 prod; her Atlassian groups still require manual re-grant via Atlassian admin (Helix did not capture the removed-groups list at her revoke moment — new code does going forward).
>
> **CMDB domain public-visibility + `data_source` provenance + sweep exclusion** (2026-05-29, migration 218). Two compounding bugs in 域名管理 surfaced when non-admin `孟凡生 Jason Meng` saw only 2 of his 11 owned rows, all painted 已过期 / 自动续费=否 — and pingkai.cn / tidb-community.com were in reality auto-renewed at the registrar. **Bug 1: RBAC over-scope** ([`router.py::list_domains`](../../server/app/domains/cmdb/router.py)) auto-demoted non-admin to `owner_person_id=self`, but domains are company-public identifiers (`pingcap.com` is on the website) and the demote was wrong for this resource. Sibling endpoints (servers / DNS / CDN / LB / IAM / object storage) KEEP the owner demote because they leak internal hostnames / IPs / DKIM TXT / origins / ACLs — reconnaissance bait. The split is deliberate (see [DECISIONS.md](DECISIONS.md) `2026-05-29 — domain public visibility`); `list_domains` / `get_domain` / `export_domains` + MCP `list_cloud_domains` / `get_cloud_domain` ALL drop owner gating; `can_manage_cloud` PATCH gate preserved (read-only relaxation). **Bug 2: 62 prod rows are legacy CMDB imports, not API-synced** — [`scripts/migrate_cmdb.py`](../../server/scripts/migrate_cmdb.py) writes `registrar` from WHOIS `possessor` (the company name "PingCAP Ltd." / "平凯星辰…") instead of the cloud slug ("aliyun" / "tencent") the live drivers emit (see [`aliyun.py:681`](../../server/app/domains/cmdb/providers/aliyun.py), [`tencent.py:338`](../../server/app/domains/cmdb/providers/tencent.py)); `expiry_date` frozen at import time; `auto_renew` defaulted False because the migration INSERT omitted the column; 50+ rows tagged under UCloud have no driver (`list_domains` not implemented on ucloud.py) so they will never refresh. **New `cmdb_domains.data_source VARCHAR(16) NOT NULL DEFAULT 'synced'`** ([migration 218](../../server/alembic/versions/218_cmdb_domain_data_source.py)) — three values, application-layer validated: `synced` (last write from a cloud driver upsert), `migrated` (legacy import — expiry / auto_renew / registrar are stale), `manual` (admin override via PATCH). Conservative backfill: `registrar` NOT IN the driver-slug whitelist → `migrated`. The sync upsert path ([`sync.py::_sync_domains`](../../server/app/domains/cmdb/sync.py)) ALWAYS re-stamps `data_source='synced'` on both INSERT and ON DUPLICATE KEY UPDATE — cloud is authoritative when it speaks. The `_safe_sweep` for domains now passes `extra_active_filter=[CmdbDomain.data_source == 'synced']` so `manual` (off-platform registrars like GoDaddy / Markmonitor) and `migrated` (UCloud-tagged rows, disabled-Aliyun rows after we re-enable) are never soft-deleted when the cloud driver returns a non-overlapping list — same exclusion pattern as `CmdbIamUser.revoked_at IS NOT NULL`. **`DomainAnnotation` schema** ([`schemas.py`](../../server/app/domains/cmdb/schemas.py)) extended with `registrar / expiry_date / auto_renew` — touching any of them flips `data_source` to `manual` in the service layer; `auto_renew=null` explicitly rejected (NOT NULL column, prevents a buggy client from coercing it to False by sending null thinking "leave unchanged"). **Frontend** ([`CloudDomainsPage`](../../web/src/pages/cloud/CloudDomainsPage.tsx) admin + [`MyCloudDomainsPage`](../../web/src/pages/cloud/MyCloudDomainsPage.tsx) employee): new `数据来源` Tag column with three-state Tooltip; expiry tag prefixes `数据可能过期 ·` on migrated rows so the red `已过期` doesn't mislead; My-domain 30-day banner excludes migrated rows (separate `info` banner counts them, drives admin action); admin edit drawer makes registrar / expiry_date / auto_renew editable with sync-vs-manual hints. **Same-PR ORM-vs-prod constraint drift fix**: `cmdb_domains` / `cmdb_servers` / `cmdb_dns_records` / `cmdb_load_balancers` / `cmdb_cdn_domains` all 5 received their `UniqueConstraint(integration_id, domain_name/instance_id/…)` declarations on the SQLAlchemy model — production has had them since migration 006, but the ORM never declared them, so `Base.metadata.create_all` (the test-schema bootstrap path) created the test DB WITHOUT these unique keys, and sync-upsert tests silently inserted duplicates instead of UPDATE-ing. **New MCP `annotate_domain` tool** ([`tools.py`](../../server/app/mcp/tools.py)) mirrors HTTP PATCH 1:1 per the MCP-sync rule; `_REGISTERED_TOOLS_COUNT_HINT` bumped 194 → 195. 13 new tests in [`test_domain_rbac_and_data_source.py`](../../server/tests/test_cmdb/test_domain_rbac_and_data_source.py) pin: employee sees all active-tenant domains; `data_source` round-trips through list / get / annotate / sync; PATCH expiry/auto_renew/registrar flips manual; non-sync PATCH (ICP / environment / remark) does NOT flip; null auto_renew 4xx; employee 403 on PATCH; sync upsert re-stamps synced; sweep excludes manual + migrated; sibling endpoints (`/cmdb/servers` + `/cmdb/dns-records`) STILL apply owner-demote (seeded 2 servers + 2 DNS rows owned by another person, employee sees 0); audit log captures sync-owned before/after including data_source. zh-CN / en-US / ja-JP i18n complete. 1 migration, 0 cron, 0 RBAC role change, +1 MCP tool. 3 docs updated same PR. **2 rounds of multi-agent review + 1 security/logic scan** found and fixed 4×P1 + 1×P0 (audit log gap; missing MCP twin; Table scroll on employee page; sibling RBAC test too weak; CmdbDomain.owner_raw crash since migration 042 removed that column).
>
> **Nag cadence — a shared taper for every "keep asking until you press the button" push** (2026-08-30, feat(notifications), migration **576**, 1 new endpoint, 0 cron, 0 MCP, 0 RBAC). Two templates dominated what this platform pushed at employees — `asset_inventory_reminder` at **16.7 per person / 30 days** and `cloud.owner_semiannual_confirm` at **7.6** — and neither was a fan-out bug: both send one card per person per day, indefinitely, by deliberate design. [`app/notifications/cadence.py`](../../server/app/notifications/cadence.py) + `notification_nag_state` add the option those designs never weighed: **keep asking, more slowly.** Ladder `1/1/1/2/3/5` days then weekly, **never stopping** — 30 sends in the first month become 8, and ~4/month after. The front stays daily on purpose (nearly everyone who answers answers then; slowing it trades real response time for noise nobody felt yet), so all of the saving comes from the tail. Three invariants, each with its own test: a **first** notification is never delayed; only recurring nags enter this at all (one-shot event notifications must never be deferred); and `clear()` on the terminal transition is **mandatory** — stale counters make the next unrelated ask start on the weekly rung, a noise fix turning a brand-new problem into near-silence. **Platform rule discovered by an existing test going red**: *the ladder may only ever LENGTHEN an interval, never tighten one that already exists.* The cloud sweep has its own 20h floor (deliberately under 24 so a fixed-time cron never skips a day); layering a "24h minus slack" test on top of it dropped a card at the 21h mark — a change made to cut noise silently becoming a change to delivery. Callers that already own a daily floor therefore use `taper_holds` (which stands aside entirely while the ladder is on daily rungs), not `should_send`. Observability ships with it: `GET /notifications/outbox/employee-reach` + the 员工触达 tab report **per-recipient** rather than totals and sort by it — 500 cards to 500 people is ordinary, 500 to 30 is somebody being nagged, a total cannot tell them apart, and that is exactly how a 16.7-per-person reminder hid under an ordinary-looking headline. Active employees only, so admin paging cannot dilute the employee number. See DECISIONS 2026-08-30.
>
> **账号终态保障 (account end-state assurance) — a second, independent question about every leaver** (2026-08-30, feat(offboarding/identity), migration **575**, 2 new crons, 3 new endpoints, 2 MCP tools, 0 RBAC). Everything else in the offboarding domain answers *"did Helix revoke what it knew about"*. This answers *"can this person still sign in"*, and the two are not the same question — for 16 days in August 2026 the first was clean and the second was "yes, everywhere". New tables [`offboarding_account_assurance`](../../server/app/domains/offboarding/models.py) (one row per person × identity system: `vendor_state` absent/disabled/active/unknown, `helix_action` off the offboarding ledger, `verdict` ok/at_risk/exempt/unknown, `exempt_reason`, `evidence_at`, `at_risk_since`) and `identity_chain_heartbeats` (a positive beat + expected period for each dependency Helix relies on but does not own). Refreshed wholesale by the per-system leaver sweeps — [`jira.leaver_account_audit`](../../server/app/scheduled_jobs/jira_handlers.py) for Atlassian and the new **`google.leaver_account_audit`** for Google — so the read surface is one DB query, never a live vendor fan-out. **`upsert_rows` is a sweep REPLACEMENT**: rows absent from a batch are deleted, which is what stops the page accumulating fossils, and which means any caller must pass the whole system's rows in one call. New cron **`jira.idp_sync_heartbeat`** (hourly) reads `scim_auto_sync_complete` from the Atlassian org events API and flips the chain `stale` past 2× the vendor's 4h cadence, alerting **daily** (not weekly — a 16-day outage would ring twice on weekly) and naming who fired `scim_sync_disabled`; an unreadable vendor records `unknown`, never `stale`, because "your key is dead" and "Atlassian stopped" send IT to different consoles. New driver methods `set_org_user_access_suspended` / `read_org_user_state` / `list_org_events`; `revoke_all_access` now suspends sign-in at the end under integration config `suspend_account_on_offboard` (default on) — **suspension is not deletion**, so the never-delete-the-account policy that protects issue attribution is untouched and `/restore-access` reverses it. Endpoints `GET /offboarding/account-assurance{,/summary}` (`require_lifecycle_read`) and `POST /offboarding/account-assurance/{person_id}/enforce` (`require_admin`, audited `offboarding.account_assurance_enforced`). **Two refusals are contract, not implementation**: enforcement rejects anyone whose LIVE employment status is not `left` (a stale assurance row must never be able to lock out a rehire), and rejects an account carrying a retention decision unless `override_exemption` is passed explicitly — the auto-suspend sweep applies the same split and never touches an exempt account. **`exempt` reports a decision; it does not grade one** (corrected 2026-08-30). `external_guest` is defined in the model as 「CBG保留」 — a first-class classification alongside `shared_service` and `contractor` — and offboarding's live Feishu CBG-department check writes that row automatically via `ensure_retained_classification`, *specifically so the leaver reconcile stops paging IT daily about a documented keep*. An earlier version scored these rows by whether they carried a note / owner / author and flagged the rest red as "unowned exemptions"; all three inputs were wrong (`owner_person_id` is NULL on all 91 prod rows — nothing writes it; a NULL `created_by` means system-written, which for a CBG retention is the normal path), and the red KPI recreated the exact noise that registry row exists to remove. The page now names the retention kind and its provenance and judges nothing; the fourth KPI slot went to `unknown`, a real bucket that must never be read as closed. **Enforcement cost + honesty (review round, 2026-08-30)**: `read_org_user_state` reads like a point lookup but is a FULL org roster pull underneath (~1900 accounts / ~39 requests — the Admin API has no by-email lookup), and the enforce path was issuing it twice. It now resolves the account id cheapest-source-first — the assurance row's `vendor_account_id` (0 calls, and the id is stable so staleness cannot mis-target), then the site user search (1 call), then the roster (still needed: Atlassian's GDPR behaviour omits `emailAddress` for managed and deactivated accounts, which is exactly this population) — and it **does not read back at all**: the only org-level read available lags ≤24h, so a read-back right after a successful suspend cannot separate "did not work" from "not visible yet", and `verified: false` off that would send an operator to re-close an account already closed. It returns `verified: null` and lets the daily sweep settle it. A 404 in the suspend direction is reported as `already_absent`, not `suspended` — idempotent success at the driver, but "nothing was touched" to an operator. Both halves of the sweep now share ONE grace window: the Atlassian half originally had none, so every fresh leaver went red for a day and green again by itself, and a governance number that churns on its own is one people learn to wait out. Frontend: 「账号终态」tab in [`OffboardingManagementPage`](../../web/src/pages/offboarding/OffboardingManagementPage.tsx) → [`AccountAssurancePanel`](../../web/src/pages/offboarding/AccountAssurancePanel.tsx). Memory: [reference_atlassian_account_deactivation_is_idp_sync].
>
> **A leaver's Atlassian ACCOUNT is deactivated by the org's IdP directory sync — not by Helix — and nothing was watching it** (2026-08-30, fix(jira), 0 migration / 0 cron / 0 RBAC / 0 MCP). Helix's Jira offboarding strips every group + org product access and **deliberately keeps the account alive** so issue history stays attributed (`revoke_all_access`, `remove_org_user_access`). What flips a leaver to `account_status='inactive'` is Atlassian Guard's **Google Workspace / SCIM user provisioning**, an org-side setting Helix neither owns nor observed. On 2026-08-13 one save of the Google-sync settings form (a single form + single Save covering the sync toggle, the new-user invite message and the login policy) emitted `gsync_user_invitation_message_updated` and `scim_sync_disabled` **74 ms apart**, and user provisioning stayed off for 16 days: every leaver kept a live Atlassian account and every hire after 08-16 got none — while `jira.leaver_account_audit` reported clean runs the whole time, because it only ever read `product_access` and a correctly-revoked leaver looks identical to a never-deactivated one. [`jira_handlers.py`](../../server/app/scheduled_jobs/jira_handlers.py) now carries an **account-lifecycle watchdog**: a leaver still `active` ≥ `_STALE_ACTIVE_ACCOUNT_GRACE_DAYS` (7, `config.stale_active_grace_days`) days past termination lands in the `stale_active_accounts` summary bucket and fires a weekly-deduped admin alert on its own `template_key` (`jira.leaver_account_audit.stale_active_account`) naming the Identity-providers page to check. The check sits **before** the `product_access` early-return on purpose — that early-return is what made the outage invisible. Two reporting defects fixed alongside: `still_have_access` counted only `offenders` and so printed `0` while six leavers sat in `non_group_residual` (now counts both; `still_have_access_group_revocable` keeps the split), and a non-group residual whose remove-access 2xx never actually clears (Trello = *workspace* membership, which `DELETE directory/users` cannot touch — two leavers looped silently March→August) now escalates to the manual/alert bucket past `_NON_GROUP_RESIDUAL_STALE_DAYS` (30, configurable) instead of being filed as self-healing. The connector binding's `action_on_offboard=delete` label ("停用 Atlassian 账号") was **false** — that path only revokes org-level product access — and now says so. Diagnostic recipe for any future "did the IdP sync stop?": the Atlassian org events API (`GET /admin/v1/orgs/{org}/events?action=<name>`, `event-actions` lists all 1786 names) — `scim_auto_sync_complete` gives the real 4-hourly cadence, `scim_sync_disabled` / `scim_sync_enabled` name the actor, `scim_user_delete` / `scim_user_create` show what each sync did; `GET /gateway/api/google-sync/orgs/{org}/state` (admin-console session) returns `lastSync` + `selectedGroups`. Memory: [reference_atlassian_account_deactivation_is_idp_sync].
>
> **Atlassian non-group residual access now auto-revoked via the Org remove-access API** (2026-06-08, fix(offboarding/jira)): the `non_group_residual` bucket (Trello workspace membership / org default access / direct product grants — access group-removal can't touch) is no longer just *reported*; Helix now strips it. New driver method [`JiraSoftwareDriver.remove_org_user_access(account_id)`](../../server/app/integrations/drivers/jira_software.py) → `DELETE /admin/v1/orgs/{orgId}/directory/users/{accountId}` (keeps the Atlassian account, only revokes product access — issue history stays attributed). **Credential**: these user-access write endpoints are NOT covered by Atlassian's granular scope system, so a scoped org key 401s `INSUFFICIENT_SCOPE`; a new **separate** integration config field `org_unscoped_api_key` (an UNSCOPED org API key — "no scopes = all Admin APIs", `is_secret=True`, encrypted like the others) is the sole write credential, with **no fallback** to the scoped `org_admin_api_key` (which stays the read-only key for the daily roster sync — least-privilege read/write split). `jira.leaver_account_audit` now calls remove-access per non-group-residual leaver ([`_auto_remove_non_group_residual`](../../server/app/scheduled_jobs/jira_handlers.py)): successes → `non_group_residual_auto_removed` (silent, idempotent — Atlassian's listing lags ≤24h so a removed leaver may reappear once and get removed again); only "no unscoped key configured / API refused (401/403)" → `non_group_residual_manual` reaches the admin bell. Also fixed `revoke_seat` action=delete, which previously called a **non-existent** `…/manage/lifecycle/disable` route (404) and mis-handled the 404 as "already disabled = success" (silent false-positive — account was never disabled); it now uses `remove_org_user_access` with route-404 (`"match any route"`) treated as a HARD error, resource-404 as idempotent success, 401/403 as partial-success deferred, 5xx as bounded-retry. `provisioning.service` `JIRA_ORG_ADMIN_*` remediation hints updated to point at `org_unscoped_api_key`. Verified against prod Atlassian (org `pingcap-ticket`, Guard Standard). 0 migration / 0 cron / 0 RBAC / 0 MCP. Memory: [reference_atlassian_unscoped_key_user_revoke], [reference_atlassian_product_access_not_group_truth].
>
> **Org-wide-entitlement guard: a product conferred via a protected identity group is NOT individually revocable** (2026-06-15, fix(atlassian)): the recurring "13 active employees Confluence revoke keeps bouncing" weekly page (fixed ≥3× before, kept returning) had a structural root cause: Confluence at PingCAP is **conferred org-wide** — its admin-declared conferring groups (`software_seat.product_access_revoke.admin_declared_conferring_groups`) include `employee_in_china` + `All users from G Suite`, two **company-wide identity groups** mirrored from the IdP via SCIM. `software_seat.idle_reclaim` → `revoke_product_access` removed active employees from those identity groups to reclaim an idle seat; SCIM re-added them on the next sync; `jira.recently_revoked_audit` re-revoked 3× then paged. Both **harmful** (strips a core directory group) and **futile** (IdP wins). **Three-layer fix**: (1) **driver guard** ([`jira_software.py`](../../server/app/integrations/drivers/jira_software.py) `revoke_product_access`): never deletes a protected identity group; if the user holds the product via ANY protected group, removes nothing and returns `not_individually_revocable`/`org_wide_entitlement` (purely additive — no protected match → unchanged). Protected set = built-in `DEFAULT_PROTECTED_IDENTITY_GROUPS` (`employee_in_china`, `All users from G Suite`) ∪ new platform_setting `software_seat.product_access_revoke.protected_identity_groups` (flat list, injected by the dispatcher in [`provisioning/service.py`](../../server/app/domains/provisioning/service.py), DB-blip-tolerant). (2) **audit gate** ([`jira_handlers.py`](../../server/app/scheduled_jobs/jira_handlers.py)): `recently_revoked_audit` drops families whose binding is `seat_revocable=false` (resolved plan→binding→family, same source `idle_reclaim` uses) — org-wide entitlements are never a "bounce". (3) **prod data**: Confluence (tidb) binding set `seat_revocable=false` (idle_reclaim already honors it → stops reclaiming Confluence + disables the self-cancel button), `protected_identity_groups` setting seeded. Rule for future products: "revoke didn't stick" on an org-wide-licensed product is an entitlement, not a seat — mark the plan `seat_revocable=false`, don't fight the IdP. 0 migration / 0 cron / 0 RBAC / 0 MCP. Memory: [reference_atlassian_product_access_not_group_truth], [reference_atlassian_leaver_offboarding].
>
> **Atlassian drift audits gate on conferring-group membership, not `product_access`** (2026-06-07, fix(offboarding/jira)): the two `jira.*_audit` crons (`leaver_account_audit`, `recently_revoked_audit`) judged "still has access" purely by the Org Admin API's `product_access` being non-empty — but `product_access` does NOT track group membership in real time, so the crons false-reported a perpetual "bounce" and burned the 3-strike backoff into a misleading "SCIM keeps re-adding them" alert. Diagnosed from prod `provisioning_tasks.context_data.vendor_response`: of 6 flagged leavers, **5 were false positives** — 3 Trello-only (board membership, not group-conferred; `groups_total:0`/`removed:[]`), 1 product_access lag after all 11 conferring groups were already removed (liangzhiyuan), 2 `no_account`; only zhonghui.du was a genuine daily re-add of one conferring group. **Fix**: new `_account_site_groups(driver, account_id)` ([`jira_handlers.py`](../../server/app/scheduled_jobs/jira_handlers.py)) returns the account's current production-site group list (None on missing-id / API error → caller stays conservative). The two audits use it differently, matching what their revoke actually removes: **leaver audit** — `revoke_all_access` strips EVERY group, so the gate is "account is in ANY group at all" (NOT narrowed by product family — narrowing would wrongly exempt leavers whose access comes via `bitbucket-users` / `jira-administrators` / `jira-product-discovery` groups that `product_family_for_group` can't classify, a real offboarding leak); ≥1 group → `revoke_all_access` + genuine-bounce escalation after ≥3 strikes; **zero groups** (Trello / default-access / lagging product_access) → fire-once `non_group_residual` admin alert (accurate framing: "not group-conferred — handle Trello/default-access manually or deactivate account"), never the auto-revoke loop. **`recently_revoked_audit`** (active employees) — `revoke_product_access` removes only the family's conferring groups, so it classifies the user's current groups via `product_family_for_group` and keeps only families with a matching conferring group as real bounces (admin/default groups it can't classify are also ones revoke can't remove, so dropping them is correct). Lookup failure stays conservative. No Google/SCIM action (the early "suspend Google" hypothesis was refuted by the data), no change to active-employee single-product cancel, Atlassian account itself untouched. Tests: leaver (Trello no-loop, lag no-revoke, **unclassified-conferring-group still-revoked** regression guard) + recently-revoked (lag-not-bounce); jira scheduled-job suites green. 0 migration / 0 cron / 0 RBAC / 0 MCP. **Self-review caught + fixed** an initial version that gated the leaver path on classified product families (would have stopped auto-revoking bitbucket/admin-group leavers). Memory: [reference_atlassian_product_access_not_group_truth], [reference_atlassian_leaver_offboarding], [feedback_verify_before_answering].
>
> **Atlassian drift audits go auto-remediating** (2026-05-29, fix(asset+jira): the two `jira.*_audit` crons stop pinging admins with manual to-do lists and instead **enqueue a real revoke task** the same way the offboarding / self-cancel flows do. The user reported the daily "1 名离职员工仍有访问 / 1 名员工撤销后仍占坐席" notifications and asked why a cron that already knows the user is left (HRIS) or admin-revoked (Helix-issued task) is asking for confirmation instead of just re-running revoke. (1) **`jira.leaver_account_audit`** detected drift now enqueues a `target_type='account'` provisioning task → dispatcher → `JiraSoftwareDriver.revoke_all_access` (idempotent — same path offboarding takes). (2) **`jira.recently_revoked_audit`** detected bounce now enqueues a `target_type='product_access'` task per bouncing family → `revoke_product_access` (idempotent — same path self-cancel takes); the rare whole-provider sentinel `"*"` (rehired-then-re-cancelled leaver) routes to `target_type='account'`. (3) **Persistent-bounce escalation**: same (user, target, family) bounces ≥3 times in 7 days → handler stops auto-revoking that pair and surfaces a notification — SCIM directory / default-group config / a person manually re-adding is winning and Helix can't fix it; root cause needs a human. The threshold is encoded as `_AUDIT_REMEDIATION_BACKOFF_THRESHOLD` / `_AUDIT_REMEDIATION_BACKOFF_WINDOW_DAYS` in [`jira_handlers.py`](../../server/app/scheduled_jobs/jira_handlers.py). (4) **In-flight dedup**: if an earlier audit task is still pending / in_progress / notified, no new task is enqueued — let the dispatcher finish the existing one; prevents stacking duplicates and double-hitting the Atlassian API. (5) **New `SOURCE_AUDIT = "audit"`** provisioning source ([`provisioning/models.py`](../../server/app/domains/provisioning/models.py)) added to `VALID_SOURCES` so audit-driven remediations show up distinctly in the task history (and so dedup counters can scope themselves to `source='audit'` without false-counting genuine offboarding revokes). The dispatcher's `_resolve_execution_mode` already routed `target_type='account'`+integration_id → `EXEC_AUTO_API` when `driver.supports_full_access_revoke`, and the same for `product_access` + `supports_product_access_revoke` — so audit-source tasks land on `auto_api` automatically; no execution-mode change needed. **Behavior summary**: the regular case (drift detected → auto-remediated quietly) is silent; admins ONLY see notifications on persistent bouncers (the rare case Helix can't self-heal) — same dedup-aware weekly push the previous notification used (`NotificationOutbox.template_key`). Tests +5 net (auto-queue, in-flight dedup, threshold escalation, dedup-within-week, no-integration skip) across the two handlers; existing 11 pass cases re-asserted under the new behavior. 0 migration / 0 cron / 0 RBAC / 0 MCP tool count change. Memory: [feedback_check_recent_prs_before_rework], [reference_failure_notifier], [feedback_handler_status_must_raise].)
>
> **`X-View-Role` request-scoped self-downgrade** (2026-05-29, fix(rbac): the header role-switcher chip is now a real authority narrowing, not a cosmetic UI swap. Two seams compose. **Backend** ([`apply_view_role_narrow`](../../server/app/core/rbac.py) + [`_apply_view_role_header`](../../server/app/core/dependencies.py)): every request can carry `X-View-Role: <role>`; `get_current_user` validates the value canonical-set membership AND the user actually holds the role (walks `user.user_roles` directly to avoid the property's own override loop) AND short-circuits PATs entirely (no UI switcher; header is suspicious on automation). On success it sets a private `_role_names_override` on the request-scoped `IdentityPerson` instance; the `role_names` property reads the override first so all ~325 downstream RBAC call sites transparently see the narrowed list — zero router changes required. Narrowing can only NARROW (never widen): the `employee` universal carve-out still verifies `EMPLOYEE_ROLE` is in the user's real grants so a zero-grant contractor account can't elevate via header. `leader` is a UI synonym that collapses to `[employee]` (the manager-scope subordinate expansion in routers already kicks in for any non-admin caller — no distinct backend role needed). New `actual_role_names` property bypasses the override; `/auth/me` + `/auth/me/profile` + `_user_to_response` (all login response shapes) use it so the role-switcher menu stays populated under narrowed sessions. Header length capped at 64 chars to defuse CRLF/log-poisoning. **Frontend** ([`web/src/stores/auth.ts`](../../web/src/stores/auth.ts) + [`web/src/api/client.ts`](../../web/src/api/client.ts)): Zustand store derives `effectiveUser` (= user with `roles` narrowed by `viewRole`) on every setAuth / setUser / setViewRole / initialize / logout. Axios interceptor stamps `X-View-Role` on every authenticated request. 27 page/component permission gates migrated from `s.user` → `s.effectiveUser` (CloudServerDetailPage / CloudServersPage / CloudDnsPage / CloudCdnPage / CloudDomainsPage / CloudObjectStoragePage / CloudLoadBalancersPage / CloudCostAnalysisPage / MyCloudGrantsPage / MyCloudScopeDetailPage / MyDnsRequestsHistory / AiSpendingPage / HrisSyncPage / EmailGroupDirectoryPage / EmployeeRequestPage / UserItUniverseTab / SecurityTipsPage / RelationshipGraphPage / ProfileBasicTab / ApiKeyEditModal / ResourceCreatorSection / ScopeBillingPanel / GlobalSearch / PermissionGuard / RoleRoute / MainLayout(canAccessAudit) / App.tsx(CloudScopeRedirect) / MProvisioningTaskDetailPage); display surfaces (avatar, name, role-switcher menu itself) keep `s.user` so an admin-in-employee-view can still navigate back to admin. **Tests** ([`test_view_role_header.py`](../../server/tests/test_auth/test_view_role_header.py)): 10 backend cases — narrow happy path, escalation rejection, unknown / oversize / whitespace / case ignored, leader synonym, /auth/me real-roles preserved, zero-grant cannot self-elevate, PAT ignore, same-role no-op. 0 migration / 0 cron / 0 MCP tool. **Backward compatible**: omitting the header keeps the prior full-authority behaviour, so any non-browser caller (PAT, MCP, cron, internal service) is unaffected.)
>
> **定时任务 / 审计日志 / 审批中心 三屏新增「分析」Tab** (2026-05-29, feat(observability): each of the three platform-ops admin pages (`/admin/scheduled-jobs`, `/admin/audit`, `/admin/approvals`) now wraps its existing list view in an Antd `Tabs` (列表 / 分析) with a rich analytics tab on the second tab. Three new admin-read backend endpoints — `GET /scheduled-jobs/analytics` ([`service.analytics_summary`](../../server/app/scheduled_jobs/service.py), window 1..90d clamp), `GET /audit/analytics` ([`AuditService.get_analytics`](../../server/app/audit/service.py), 1..180d, audit-access gate), `GET /approval/admin/analytics` ([`ApprovalService.admin_analytics_summary`](../../server/app/approval/service.py), 1..180d, admin-read) — each returns a single-trip aggregate (KPI totals + daily timeseries + status pie + leaderboards) so the frontend never fans out per-chart calls. **Scheduled-jobs slice**: total / enabled / runs in window / success rate / unacked failures + daily run-status trend + status donut + by-category (jobs / lifetime runs / lifetime failures) + trigger-type mix + 24h hourly histogram + `triggered_by` (scheduler / manual / startup) + by-last_status fleet snapshot + Top 10 slowest jobs (avg duration) + Top 10 failing jobs table (with failure-rate tag). **Audit slice**: total / today / window / unique actors + window success rate + daily success-vs-failure trend + status donut + actor-type donut + by-action-prefix (`identity` / `asset` / `cmdb` / …) HBar via `substring_index(action, '.', 1)` + 24h distribution + Top 15 actions + Top 15 resource types + Top 10 actor leaderboard table (joins `identity_persons` so names render not UUIDs). **Approval slice**: total / window / pending-open / approval-rate / avg turnaround + daily created-vs-processed trend + status donut + per-request_type stacked bar + per-type avg turnaround HBar + Top 10 requesters HBar + per-type detail table (pending/approved/rejected/approval-rate) + top-requesters detail table. Turnaround derives from `TIMESTAMPDIFF(MINUTE, created_at, processed_at)` over terminal-state rows only (approved/completed/rejected — pending/cancelled excluded so the average isn't distorted). New components: [`ScheduledJobsAnalyticsTab.tsx`](../../web/src/pages/admin/ScheduledJobsAnalyticsTab.tsx), [`AuditAnalyticsTab.tsx`](../../web/src/pages/admin/AuditAnalyticsTab.tsx), [`ApprovalAnalyticsTab.tsx`](../../web/src/pages/admin/ApprovalAnalyticsTab.tsx) — all reuse the existing shared echarts primitives in [`reportsShared.tsx`](../../web/src/pages/dashboard/reportsShared.tsx) (`GradientKpi` / `LineTrend` / `BarTrend` / `Donut` / `HBar` / `ChartCard`) so the look matches the report pages. API types extended in [`api/scheduledJobs.ts`](../../web/src/api/scheduledJobs.ts) (`ScheduledJobAnalytics` + `analytics()`), [`types/api.ts`](../../web/src/types/api.ts) (`AuditAnalytics`), [`api/approval.ts`](../../web/src/api/approval.ts) (`AdminApprovalAnalytics` + `getAdminAnalytics()`). i18n: 99 new keys × 3 locales (zh-CN/en-US/ja-JP, parity verified). 0 migration / 0 cron / 0 RBAC change (each endpoint reuses the existing gate of the page that hosts it: `require_admin_read` / `require_audit_access` / `require_admin_read`) / 0 MCP-tool count change. tsc -b + eslint full sweep green; ruff clean on every touched backend file.)

> **定时任务新增「排程时间轴」Tab — 泳道甘特 + 并发负载预测** (2026-06-28, feat(observability): `/admin/scheduled-jobs` gains a third tab 排程时间轴 (`?tab=timeline`) beside 任务列表 / 运行分析, answering "which job runs at what time" on a forward-looking swimlane. New read-only admin-read endpoint `GET /scheduled-jobs/schedule-forecast` ([`service.schedule_forecast`](../../server/app/scheduled_jobs/service.py)) returns, per **enabled** job: `upcoming` future fire instants (cron via `CronTrigger.from_crontab(expr, tz).get_next_fire_time` iteration; interval via `last_run + N×interval` — **same maths as [`worker._register_scheduled_jobs`](../../server/app/worker.py)** so plotted times equal what actually fires), window-bounded `past_runs` (discrete dots for sparse jobs; counts for `dense` jobs), and a `dense` flag (sub-30-min interval or >48 fires/window → continuous band, not stacked dots). Plus top-level `load_buckets` (hourly upcoming-fire concurrency across the future window — the "two heavy syncs about to collide" signal) and `dense_baseline`. Window `[now-past_hours, now+future_hours]` with `window_start` floored to the hour for clean axis ticks; `past_hours` clamps 0..72, `future_hours` 1..168 (7d) so a client can't ask the worker to enumerate an unbounded cron horizon. Frontend [`ScheduledJobsTimelineTab.tsx`](../../web/src/pages/admin/ScheduledJobsTimelineTab.tsx): 24h / 7d toggle, category-grouped collapsible swimlanes (same hr/cmdb/provisioning/notification/other buckets as the list + analytics views), NOW dashed line splitting past (status-coloured filled dots) from future (hollow indigo rings), dense bands, hover for exact time/duration, top concurrency strip (collision hours highlighted), "active-in-window only" toggle (default on). Out-of-window rows with a known `next_run_at` show "下次 …" rather than blank. API types `ScheduleForecast` / `ScheduleForecastJob` + `scheduleForecast()` in [`api/scheduledJobs.ts`](../../web/src/api/scheduledJobs.ts); `formatClock` / `formatMonthDay` axis-label helpers added to [`utils/format.ts`](../../web/src/utils/format.ts) (reuse the shared Asia/Shanghai display TZ). i18n `scheduledJobs.timeline.*` + `tab.timeline` × 3 locales. Tests: [`test_schedule_forecast_2026_06_28.py`](../../server/tests/test_scheduled_jobs/test_schedule_forecast_2026_06_28.py) (forecast shapes + window clamp + load-bucket continuity + HTTP envelope + route-not-shadowed-by-`/{job_id}` + employee 403). 0 migration / 0 cron / 0 RBAC change (reuses host page's `require_admin_read`) / 0 MCP-tool count change. tsc -b + eslint + vite build green; ruff clean.)

> **AliCloud 海外 CloudSSO end-to-end grant pipeline — `ListAccessConfigurations` + `CreateAccessAssignment` overrides** (2026-05-29, feat(cmdb): closes a prod incident where `mohangjie@pingcap.com` filed a cloud access request for `poweruser` on the AliCloud 海外 integration and `dengshuan@pingcap.com`'s approval kept failing with `Aliyun AttachPolicyToUser 400 {"Code":"NotSupport.AliyunReservedPolicy","Message":"The policy is reserved for Alibaba Cloud services."}`. **Production-grade closure — no human-in-the-Aliyun-console step.** Root cause: [`AliyunCloudDriver.list_scope_roles`](../../server/app/integrations/drivers/aliyun_cloud.py) called `ListPolicies(PolicyType=Custom)` and let every result land in `cmdb_cloud_roles`. Aliyun CloudSSO (mode `account_mode=sso`, which the `alicloud_intl` integration uses) auto-materializes each `AccessConfiguration` permission set as a same-named **Custom** RAM policy with the literal name `AliyunReservedSSO-<config>-<config>` — Aliyun marks the whole `AliyunReserved*` namespace as reserved for service-internal use and `AttachPolicyToUser` hard-refuses to attach them (correct binding goes through CloudSSO `CreateAccessAssignment`, NOT RAM). Helix surfaced them as `Custom:AliyunReservedSSO-poweruser-poweruser [自定义]`, employees picked them, approvers approved, and provisioning dead-ended with the grant frozen in `status='pending'` + `detail.last_error`. Prod scan: 18 polluted role rows across 18 scopes of integration `62d312e2-... (AliCloud 海外)`; one pending grant (`9187b87d-...`) wedged. **Two-part fix**: (Part 1 — stop the bleeding) `list_scope_roles` skips `policy_name.startswith("AliyunReserved")` before emitting `CloudRoleInfo`; `grant_access` short-circuits with a clear error if a legacy `cmdb_cloud_roles` row still carries an `AliyunReserved*` role_key — defense in depth for direct API callers / MCP. (Part 2 — end-to-end CloudSSO grant pipeline) [`AlicloudIntlCloudDriver`](../../server/app/integrations/drivers/alicloud_intl_cloud.py) gets three new overrides so the same flow that currently surfaces `source=cloudsso` discovered grants can now also CREATE them: (a) `list_scope_roles` calls `ListAccessConfigurations` against the CloudSSO directory and returns admin-defined permission sets (`admin` / `poweruser` / `DBaaS-Prod-Admin-Role` / …) as the role catalog — `role_key` follows the same `AccessConfigurationName` convention `list_scope_grants` emits, so catalog ↔ discovery ↔ revoke round-trips identifiers cleanly; (b) `grant_access` chains `principal_email → ListUsers(Filter="UserName eq <email>") → user_id` × `role_key → cached ListAccessConfigurations → access_config_id`, then issues `CreateAccessAssignment(DirectoryId, AccessConfigurationId, TargetType=RD-Account, TargetId=scope.external_id, PrincipalType=User, PrincipalId=user_id)`, returning `external_ref="sso::<access_config_id>::<user_id>::<target_id>"` matched to the existing revoke override; (c) helpers `_sso_resolve_user_id` / `_sso_resolve_access_config_id` / `_sso_list_access_configurations` (per-driver-instance `_cached_access_configs`). Idempotency: `EntityAlreadyExists.AccessAssignment` → `ok=True already_exists=True`. Clean errors: unknown role / unprovisioned user surface human-readable messages instead of opaque Aliyun 400s. Fallback: scopes that aren't `aliyun_account` OR tenants without a CloudSSO directory drop through to the parent RAM path (which still benefits from the Part-1 reserved-namespace guard). **Prod data cleanup** (same conversation): 18 `cmdb_cloud_roles` rows soft-deleted + mohangjie's wedged grant `9187b87d-...` flipped to `revoked` with `revoke_reason='aliyun reserved policy — re-apply via CloudSSO'` — after deploy, mohangjie re-applies, dengshuan approves, Helix calls `CreateAccessAssignment`, the CloudSSO permission set lands on his user. No more "go set this up in the Aliyun console". **Tests**: +2 in [`test_aliyun_cloud_driver.py`](../../server/tests/test_integrations/test_aliyun_cloud_driver.py) (filter + short-circuit), +8 in [`test_alicloud_intl_sso_grants.py`](../../server/tests/test_cmdb/test_alicloud_intl_sso_grants.py) covering CloudSSO `list_scope_roles` happy path + no-directory fallback + `grant_access` happy path with `CreateAccessAssignment` param verification + `EntityAlreadyExists` idempotency + unknown role / unknown user clean errors + no-directory RAM fallback + Part-1 guard inherited via subclass. 87/87 aliyun-related tests green, no regressions. 0 migration, 0 cron, 0 RBAC, 0 MCP-tool change. Memory: [feedback_pick_most_mature](../../../.claude/projects/-Users-ucjmh-Desktop-Helix/memory/feedback_pick_most_mature.md), [feedback_verify_before_answering](../../../.claude/projects/-Users-ucjmh-Desktop-Helix/memory/feedback_verify_before_answering.md).)
>
> **Jira revoke follow-up: Confluence (admin-declared groups) + cancel preview + add-on disable + bounce audit** (2026-05-29, feat(asset+jira): closes the gaps the 2026-05-28 product-access revoke PR left open + lands the audit/Round-2 review findings. (1) **Confluence per-product revoke** via admin-declared list: `software_seat.product_access_revoke.admin_declared_conferring_groups` platform_setting (`{product_key: [group_name, …]}`), populated by global_admin via the existing platform-settings PUT endpoint. Dispatcher loads, driver merges with `/rest/api/3/applicationrole` (union semantic — admin extras cover SCIM-pushed custom-grant groups applicationrole misses). Refusal-on-empty preserved. Closes sunxiaoguang's Confluence task that failed in the 2026-05-28 PR. (2) **`GET /seat-allocations/{id}/cancel-preview`** read-only endpoint surfaces cascade scope BEFORE confirm; frontend [`UserItUniverseTab`](../../web/src/pages/admin/UserItUniverseTab.tsx) replaces Popconfirm with a Modal that fetches the preview on open + renders an Alert listing every plan in the cascade. Same authz as cancel-next-month (self OR admin). (3) **Add-on plan cancel disable**: [`_enrich_seat_allocations`](../../server/app/domains/asset/router.py) batch-loads connectors, computes `seat_revocable: bool` from `binding.seat_revocable` + the base-plan's `software_name` for non-revocable add-ons via a per-software lookup; frontend renders the cancel button DISABLED + tooltip pointing at the base plan. Backend cascade enforces correctness if bypassed. (4) **`jira.recently_revoked_audit`** new cron (migration 213, enabled 03:35 UTC daily) — companion of `jira.leaver_account_audit` for the active-employee side: scans completed `target_type=product_access|account` revoke tasks in last 30 days, cross-checks against Atlassian Org Admin product_access roster, reports any active employee whose `product_access` still holds a product their recent task targeted (SCIM / default-group bounce, manual admin re-add). Dedup-aware push (weekly per admin via `NotificationOutbox.template_key`), detection-only. Audit family-key normalisation: both sides (task `context_data.product_key` and Atlassian `product_access[].key`) pass through `product_family_for_access_key` so a non-canonical binding key still matches. (5) **`auto_revoke_pending_cancels` cron ORDER BY** (`allocated_at ASC, id ASC`) for deterministic cascade-source selection — Round 2 P2 from the prior PR. (6) Removed one-off `cleanup_jira_revoke_loop_2026_05_28.py` (sheng + sun's Jira side cleaned via prod admin-revoke endpoint right after the prior PR deployed; this PR clears sun's Confluence via the same path once the admin populates the setting). 1 new migration (213, scheduled_jobs seed), 1 new cron, 1 new platform_setting key (admin-declared list). 0 RBAC change, 0 MCP-tool count change. Tests: +5 driver fallback paths, +7 audit-cron paths (flag/exclude leavers/lookback/account sentinel/no-jira-skip/no-tasks-skip/weekly-dedup), +3 cancel-preview integration. Two review rounds clean except 1 P1 (notification flood — fixed with dedup) + 1 P2 (family-key normalisation — fixed in same PR). Memory: [reference_failure_notifier], [feedback_modal_confirm_loading].)
>
> **DNS host immutability + sibling form-state desync sweep** (2026-05-28, fix(cmdb+ui): closes a P0 prod incident where an admin tried to update a `_dnsauth` TXT record's value and Helix silently rewrote the host name to `feilian`, destroying the original. Confirmed via prod audit log (`cmdb.dns.update` 2026-05-28 08:49:22 — `patch.name=feilian` despite the user's screenshot clearly showing `_dnsauth`). Root cause: rc-field-form's `<Form form={shared} initialValues={dyn} key={X}>` pattern in [`CloudDnsPage.tsx`](../../web/src/pages/cloud/CloudDnsPage.tsx) — `setInitialValues` does `merge(initialValues, store)` with **store winning**, so leftover values from a previous edit-modal open silently overrode the new record's data. Fix is layered for defense-in-depth: **(1)** Frontend switches to imperative `setFieldsValue` on open (mirrors `CloudDomainsPage` / `CloudCdnPage` / `CloudServersPage`). **(2)** Host record field is now rendered as a disabled value-driven Input (not a `Form.Item name=`) and `name` is dropped from the PATCH payload — DNSPod/Aliyun/Route 53 all require delete-and-recreate to change SubDomain, so allowing edit was always a footgun. **(3)** Backend `DnsRecordUpdate` schema ([`schemas.py`](../../server/app/domains/cmdb/schemas.py)) drops the `name` field; `extra='forbid'` 422s any future MCP/mobile/curl caller that tries to send one. **(4)** [`cloud.ts`](../../web/src/api/cloud.ts) `updateDnsRecord` request type drops `name` to match. **Sibling sweep** caught two related bugs and fixed them same-PR: (a) `<UserSelect>` on the asset Return modal ([`AssetDetailPage`](../../web/src/pages/asset/AssetDetailPage.tsx) / [`AssetListPage`](../../web/src/pages/asset/AssetListPage.tsx)) lacked `pinnedUsers` and rendered the admin's id as a bare UUID when off page 1 of the `global_admin` roster — same class as the DNS owner-display bug, now wired with a `currentUserPinned` stub; (b) [`OffboardingProcessDetailPage`](../../web/src/pages/offboarding/OffboardingProcessDetailPage.tsx) override-form had the same `initialValues={dynamic}` trap on `blocker_ids` — refactored to `openOverrideForm` / `closeOverrideForm` `useCallback`s that seed once on the false→true edge, so a background `loadAll` while the panel is open cannot clobber the admin's in-flight ticking. **Sync hardening**: [`_sync_dns_records`](../../server/app/domains/cmdb/sync.py) no longer sets `deleted_at=None` in `on_duplicate_key_update` — was half-reviving tombstoned rows during the cloud eventual-consistency window (admin soft-deletes at T=0, cloud delete propagates at T=1, sync at T=0.5 still saw the record alive and the upsert cleared the freshly-set `deleted_at`). Mirrors the `lifecycle_service.py:5934` scope-sync fix (2026-05-21 incident) and the `on_duplicate_key_preserve_consistency` rule. Regression test [`test_admin_patch_dns_with_name_rejected`](../../server/tests/test_cmdb/test_modifier_and_batch_api.py) locks in the schema's 422. 0 migration / 0 cron / 0 RBAC / 0 MCP-tool change. Memory: [feedback_antd_form_initialvalues_key_trap](../../../.claude/projects/-Users-ucjmh-Desktop-Helix/memory/feedback_antd_form_initialvalues_key_trap.md).)
>
> **Jira active-employee revoke loop fix** (2026-05-28, fix(asset+jira): self-cancel and admin-revoke for active employees with Jira seats now actually free the Atlassian seat — they used to silently no-op because `revoke_seat` only removed users from the connector's bound group (`jira-software-users`), but PingCAP users hold Jira via team/role groups (`jira-dev-engineers`, `RD Leader`) that the bound group never touched. After the 400 "not a member" was treated as idempotent success, the next 03:00 `jira.seat_sync` saw the user still in `product_access` and re-imported the allocation as active — the loop that stuck `shengbo.ma` and `sunxiaoguang` for days. **Two real new abstractions**: (1) **`SoftwareProvisioningDriver.supports_product_access_revoke` + `revoke_product_access(ctx)`** on the driver base ([driver_base.py](../../server/app/integrations/driver_base.py)); for Atlassian the implementation hits `/rest/api/3/applicationrole` to fetch the conferring-group set per product_key, intersects with `get_user_groups`, DELETEs the intersection. This is the same set Atlassian's own Admin UI clicks when an operator picks "Remove access (per-product)". The new path leaves identity/org groups (`employee_in_china`, `All users from G Suite`) and unrelated products (Confluence when only cancelling Jira) intact, unlike the leaver `revoke_all_access` which strips everything. (2) **New provisioning task `target_type='product_access'`** ([service.py](../../server/app/domains/provisioning/service.py)): same shape as `target_type='account'` (software_id=NULL, target_id=integration_id), routed to `driver.revoke_product_access`. The coordinator's `enqueue_revoke_for_seat` ([provisioning_coordinator.py](../../server/app/core/coordinators/provisioning_coordinator.py)) detects `supports_product_access_revoke=True` and routes to the new path AND cascade-flips all sibling allocations sharing the same `(user, integration, product_key)` to `revoked` in the SAME tx (Jira Software + Tempo + eazyBI + ScriptRunner are one vendor-side license; the rows must move together). Dedup at `(user, integration, product_key, status_in_flight)` with `STATUS_COMPLETED` deliberately NOT in the in-flight set (the integration_id+product_key never rotates so a completed task would forever block future cycles — the original-style bug for any rehire). **Same-PR knock-on fixes** (audit catch list): (a) `SEAT_SYNC_REVOKE_GRACE_HOURS` 2h → 25h ([_seat_sync_helpers.py](../../server/app/scheduled_jobs/_seat_sync_helpers.py)) so a revoke at any clock time is protected across the next daily sync cycle (the 2h window expired between sheng's 16:25 revoke and the 03:00 sync); (b) `cancel-next-month` cascade-stamps `cancel_at_period_end` on every sibling allocation at T+0 so the UI immediately shows 4 「待取消」 tags instead of 1+3 active drift; cancel-undo symmetrically clears; (c) `_post_failure_side_effects` ([service.py:3268](../../server/app/domains/provisioning/service.py)) reverts source + all `sibling_allocation_ids` together when the task fails (caught by the new test for "split-state where source goes back to active but 3 add-on siblings stuck in revoked"); (d) `auto_revoke_pending_cancels` cron skips cascade-flipped siblings via `if alloc.status != 'active': continue` so a user with 4 cascaded plans gets ONE notification, not 4; (e) `change_plan` ([router.py:5628](../../server/app/domains/asset/router.py)) refuses for product_access vendors (revoke step removes ALL conferring groups; grant step only re-binds one — would silently shrink user access; Atlassian has no per-user tiers anyway); (f) `idle_reclaim._multi_group_decision` ([idle_reclaim_handlers.py:712](../../server/app/scheduled_jobs/idle_reclaim_handlers.py)) now uses `applicationrole` to detect multi-conferring-group holders (the old token-classifier missed `jira-dev-engineers` / `RD Leader` because they don't contain "software" / "servicedesk" / "confluence" substrings) with graceful fallback to the token classifier on Atlassian API failure. **No new migration** (no DB schema change — new task type lives in the existing `provisioning_tasks` row shape); **no new MCP tool** (`cancel-next-month` / `cancel-undo` / `revoke` all already have MCP twins; the new `cascade_sibling_allocation_ids` response field is additive). **Tests**: 14 new in `test_jira_driver.py` (full coverage of `list_conferring_groups` + `revoke_product_access` happy + edge paths) + 9 new integration tests in [`test_seat_revoke_product_access_2026_05_28.py`](../../server/tests/test_asset/test_seat_revoke_product_access_2026_05_28.py) (coordinator routing / cascade flip / dedup / cancel-next-month cascade-stamp / undo clearing / change_plan refusal / failure revert cascade / COMPLETED-not-blocking regression). **Historical cleanup** for the two affected users via one-off script [`scripts/cleanup_jira_revoke_loop_2026_05_28.py`](../../server/scripts/cleanup_jira_revoke_loop_2026_05_28.py), runs in prod worker post-deploy. 3 review rounds (Round 1 / Round 2 regression / Phase 7 security+logic scan) caught 1 P1 in Phase 7 (the COMPLETED-permanently-blocking issue) — fixed + tested. `ruff check` all clean. 0 RBAC change, 0 migration, 0 cron, 0 MCP-tool change.)
>
> **Round-54 fix sweep** (2026-05-28, fix(security+correctness): three-round re-review of the full 2026-05-27 PR batch (#524–#548, already deployed — prod alembic head verified = 208). Real fixes: (1) **HIGH — MCP fixed-window rate limiter was sliding indefinitely**. [`mcp/middleware.py`](../../server/app/mcp/middleware.py) `_check_pat_rate_limit` / `_check_auth_failure_rate_limit` had moved to an atomic `INCR`+`EXPIRE` pipeline (to kill the crash-between-INCR-and-EXPIRE → no-TTL → permanent-block race) but called `EXPIRE` *unconditionally on every request*, so under steady traffic the TTL was reset to the full window every call and the counter never decayed — a PAT/IP that crossed the cap stayed blocked far past the window (self-inflicted availability bug). Fixed with `SET key 0 EX window NX` + `INCR` in one MULTI/EXEC: the TTL is anchored once at window creation and never extended, keeping atomicity (no crash gap) AND true fixed-window decay. Deterministic regression test [`test_mcp_rate_limit_window.py`](../../server/tests/test_mcp_rate_limit_window.py) (verified red on the old behavior, green on the fix). (2) **MEDIUM — `errors` vs `skipped` contract violation in 3 scheduled-job handlers**. `idle_reclaim_handlers.run_software_seat_idle_reclaim`, `jira_handlers.run_jira_seat_sync` and `run_jira_leaver_account_audit` filed their benign "Jira not configured" / "no plans" early-returns under `summary["errors"]` (the list `raise_if_total_failure` escalates) instead of `summary["skipped"]`, contradicting idle_reclaim's own documented schema contract. Moved to `skipped` so a deliberately-unconfigured integration stays green instead of being escalated as a failed run; the genuine break signals (auth/5xx/missing org_admin_api_key) already raise on the up-front reads. Tests added/updated. (3) **migration 209** — corrects the admin-facing `jira.leaver_account_audit` `scheduled_jobs.description`/`display_name` seeded by 207, which described WRONG semantics ("checks if the account is still active … backstops auto-deactivating the account on offboarding") — the job actually detects leavers still holding product_access (still in a group) and offboarding removes from ALL groups, never deactivates the account (per the Atlassian leaver rule). 209 rewrites the row to the handler-registry text, WHERE-guarded to the 207 seed values (won't clobber an admin edit), reversible; validated 206→207→208→209 + round-trip on dev. (4) **frontend** [`CloudObjectStoragePage`](../../web/src/pages/cloud/CloudObjectStoragePage.tsx) edit icon gains the Tooltip + aria-label its 4 sibling cloud pages already had. Also corrected the ROADMAP Round-52 digest item (6) (it claimed the leaver-block listener scans `session.new`; that was reverted to dirty-only the same day). Confirmed NON-issues (left as-is): ja-JP's missing same-day keys fall back gracefully to en-US (`fallbackLng` in [`i18n/index.ts`](../../web/src/i18n/index.ts); the hard rule only requires zh-CN+en-US, both present); the asset cost-stats `start_date/end_date` params are vestigial-but-harmless on the live path (narrow within the current period); `ucloud.py`'s `_signed_call` comment is accurate — the function DOES exist in [`drivers/ucloud_cloud.py`](../../server/app/integrations/drivers/ucloud_cloud.py) (a per-slice reviewer false-positive). Migration chain head now **209**. 0 RBAC change, 1 migration (data-only), 0 cron, 0 MCP-tool-count change.)
>
> **Nav restructure PR5 (follow-up)** (2026-05-28, refactor(nav): (1) the **公司办公室及主体信息维护** page (`/admin/assets/office-addresses` — offices + legal entities) moved into **HR 工具** — out of `backoffice-mgmt` in the admin view and out of the standalone `hr-foundation` group in the HR view (that group is now removed). `getOpenKeys` moves office-addresses from `backoffice-mgmt` → `admin-hr-tools` (matched before the `/admin/assets`→`asset-mgmt` fallback); `normalizeMenuPath` keeps it in the `assetPaths` set so its own leaf highlights. (2) **`/help` refresh** that the four nav PRs (#540/#548/#546/#547) had missed: the permission matrix ([`HelpCenterPage.tsx`](../../web/src/pages/help/HelpCenterPage.tsx) `matrixRows`) gains a **`procurement`** row + an **`insights`** row (运管驾驶舱 = Insights + AI Spending; admin/management R, finance+auditor R for AI Spending), and a footnote explaining the per-domain Insights pages opened to domain roles (people/lifecycle→hr, assets→asset_admin, software→asset_admin+finance, cloud→cloud_admin+finance, security→auditor); the `software` / `identity` / `hrTools` row labels are updated; HR & finance role-scope text and the Insights help section now reflect the cockpit location + domain split + 软件总览. zh/en/ja (`matrix.row.*` ja falls back to en, per the file's existing pattern). DECISIONS.md logs the whole nav restructure + the "don't `--delete-branch` a stacked PR's base" process lesson. 0 backend / 0 migration / 0 cron / 0 RBAC / 0 MCP change.)
>
> **Nav restructure PR4 (final)** (2026-05-27, refactor(nav): the 触发源(HRIS) block moved off the 入离职管理 overview into the 身份源数据与认证 menu group. HRIS (SuccessFactors) is an identity DATA SOURCE (inbound), so it now LEADS that hub: HRIS 同步 (`/admin/hr/sync`) → 数据同步 (outbound `/admin/identity-sync`) → SSO. Pure frontend: [`LifecycleOverviewPage`](../../web/src/pages/provisioning/LifecycleOverviewPage.tsx) drops the 触发源 JmlSection (was 3 nav buttons) and widens the remaining config card to full width; [`MainLayout`](../../web/src/layouts/MainLayout.tsx) adds `/admin/hr/sync` (the rich `HrisSyncPage` — full manual-sync / diff / hire-leaver-confirm UI, which the admin menu previously had NO entry for) as the first child of `identity-sso-group`, and `getOpenKeys` moves `/admin/hr/sync` from the `lifecycle-mgmt` branch to `identity-sso-group` (`normalizeMenuPath` already self-highlights it). `nav.hrisSync` i18n key already existed (no new key). 0 backend / 0 migration / 0 cron / 0 RBAC / 0 MCP change. Completes the 4-PR nav series (#540 procurement+cockpit+apikey / #543 seat-by-department / #546 insights split + software overview / this = HRIS relocation).)
>
> **Nav restructure PR3** (2026-05-27, feat(insights): the 运营洞察 modules are now ALSO standalone per-domain pages, opened to the role that owns each domain — the cockpit (运管驾驶舱) still carries all 9 as tabs for admin/management. Mapping: people + lifecycle → HR Tools (hr); assets → 资产管理 (asset_admin); **软件总览** (merged software + software-catalog modules, the management-facing overview) → 软件管理 (asset_admin + finance); cloud → 云资产 (cloud_admin + finance); security → 安全合规 (auditor). Backend [`insights_router.py`](../../server/app/dashboard/insights_router.py) replaces the blanket `_require_admin` on each section endpoint with `_require_insights_section(user, section)` — admin/it_admin/management always pass; otherwise the section's owning role(s) from `_INSIGHTS_SECTION_EXTRA_ROLES` are admitted. overview + operations stay admin/management-only; `/export/{section}` uses the same per-section gate. Each module already self-fetches (takes only a `range` prop), so the standalone pages are thin wrappers: a generic [`InsightsSectionPage`](../../web/src/pages/admin/insights/InsightsSectionPage.tsx) (people/lifecycle/assets/cloud/security) + [`SoftwareOverviewPage`](../../web/src/pages/admin/insights/SoftwareOverviewPage.tsx) (stacks both software modules). App.tsx adds 6 RoleRoute-gated routes; MainLayout adds the entries across the admin / hr / finance / auditor / cloud_admin views + extends `getOpenKeys`/`normalizeMenuPath`. i18n: 6 nav keys + `insights.softwareOverview.*` (zh/en/ja). Tests: [`test_insights_section_rbac_2026_05_27.py`](../../server/tests/test_dashboard/test_insights_section_rbac_2026_05_27.py) (20 role×section cases + anon 401); conftest seeds `hr`/`cloud_admin` roles + `hr_user`/`hr_headers`/`cloud_admin_user`/`cloud_admin_headers` fixtures. Verified in browser (global_admin): 软件总览 renders 5 charts, 人员洞察 7 charts, menus + highlight correct. **RBAC: insights read gates widened per section** (no insights write endpoints exist; write surface unchanged) / 0 migration / 0 cron / 0 MCP tool count change.)
>
> **Nav restructure PR2** (2026-05-27, feat(asset): software seat/cost is now filterable by ANY department, not just a top-level BU. The `/cost-stats/department?department=` + `/software-billing/{period}/department?department=` endpoints already did subtree rollup for admins (asking for "Business Operations" picks up every descendant); the cost page just never exposed an arbitrary-department picker — it only had a top-level-BU `Select`. New `GET /assets/cost-stats/departments` (gated `require_software_read` = global_admin/asset_admin/finance/auditor/management) returns every `department` `IdentityOrgNode` (source_id/name/parent_source_id/org_path/level) — intentionally one notch wider than the inventory department lookup's `require_asset_read`, because finance reads software cost but has no hardware-inventory access. Frontend [`SoftwareCostPage`](../../web/src/pages/asset/SoftwareCostPage.tsx) replaces the BU `Select` with a hierarchical, searchable department `TreeSelect` (value = org-node source_id, mapped to the node name the backend matches on; tree rebuilt client-side from parent_source_id); picking any node routes the admin company-wide view to the `/department` endpoint instead of `/all`. i18n `softwareCost.filter.departmentPlaceholder` (zh/en/ja). Tests [`test_cost_dept_lookup_2026_05_27.py`](../../server/tests/test_asset/test_cost_dept_lookup_2026_05_27.py) (asset_admin/finance 200, employee 403, anon 401, parent-child shape); conftest gains the `finance` system role seed + `finance_user`/`finance_headers` fixtures. Verified in browser: selecting "APAC Marketing" fired `cost-stats/department?department=APAC+Marketing` and narrowed the period total from the company-wide $54,359.51 to $920.00. 0 migration / 0 cron / 0 RBAC change (reuses `require_software_read`) / 0 MCP tool count change.)
>
> **Nav restructure PR1** (2026-05-27, refactor(nav): top-level menu restructure, PR1 of 4 — pure frontend, routes + RBAC unchanged. New top-level **采购管理 (`procurement-mgmt`)** menu holds 采购管理 / 业务付款 / 供应商 (split out of 软件管理; mirrored in the finance view as `finance-procurement`). **API Key 管理** (`/admin/assets/api-keys`) moved from 软件管理 into **云资产 (`cloud-mgmt`)** — these keys are cloud-IAM-bound — and is stripped from the `cloud_admin` menu copy in `cloudAdminItems` since that route is gated to global_admin + asset_admin (would be a dead 403 link). Employee **我的 API Key** (`/me/api-keys`) likewise moved from the 软件目录 submenu into **我的云资产 (`my-cloud`)**. **运营洞察 + AI 支出** (the two former loose top-level links) grouped under a new **运管驾驶舱 (`ops-cockpit`)** submenu. All changes live in [`MainLayout.tsx`](../../web/src/layouts/MainLayout.tsx): menu arrays + `getOpenKeys` open-key branches (new `procurement-mgmt`; `/admin/assets/api-keys`→`cloud-mgmt` matched *before* the `/admin/assets`→`asset-mgmt` fallback; `ops-cockpit`; `/me/api-keys`→`my-cloud`). `normalizeMenuPath` is unchanged — leaf route paths are stable so `selectedKeys` highlight still resolves. 3-locale i18n keys `nav.procurementMgmt` / `nav.opsCockpit`. 0 backend / 0 migration / 0 cron / 0 RBAC / 0 MCP. Part of a 4-PR series: PR2 = seat/cost by-department picker, PR3 = insights modules split into per-domain pages + merged 软件总览 + HR access to 人员/入离职, PR4 = HRIS trigger block relocated + enriched under 身份源数据与认证.)
>
> **Today, newest** (2026-05-27, fix(offboarding): Jira leaver access revoke is now **remove-from-ALL-groups**, not one-bound-group and not account-suspend. Root cause: a leaver's Jira/Confluence access comes from arbitrarily many groups across sites (zhonghui.du was in **12** incl. `jira-administrators` / `SOC2` / per-site sandbox groups), so the old per-seat `revoke_seat` (one bound group) left most access live after offboarding "completed"; an attempted org-wide account *suspend* via the v2 directory endpoint (`POST /admin/v2/orgs/{org}/directories/{dir}/users/{id}/suspend`) returns `403 ADMIN-UAM-403-1` with the configured org admin key (write-authority gap) and is the wrong tool anyway — Helix policy keeps the managed account so issue history stays attributed (same line #532's idle-reclaim draws: "绝不停账号"). New `JiraSoftwareDriver.revoke_all_access(ctx)` (class flag `supports_full_access_revoke=True`): `find_user_by_email` → `get_user_groups` (ALL groups on the site, Basic auth — the read #532 added, no 403) → `DELETE /rest/api/3/group/user` per group; idempotent (404 / "not a member" = success), `ok=False` on any real per-group failure so the offboarding blocker stays open and the leaver stays visible. Offboarding scan now emits ONE `software_account` blocker per leaver when a Jira integration exists (created off the integration's existence, **NOT** off tracked seats — so access living only in untracked groups is still caught) and SKIPS per-seat Jira blockers; `_apply_account_policy` schedules a `target_type='account'` provisioning task whose `_dispatch_auto_api` branch builds the driver by `target_id`=integration_id and calls `revoke_all_access`. New driver-base hook `revoke_all_access` + `supports_full_access_revoke`. New **read-only** reconcile cron `jira.leaver_account_audit` (migration **207**, daily 03:30 UTC, enabled): one paginated pull of the Org Admin `GET /admin/v1/orgs/{org}/users` roster cross-checked against Helix `employment_status='left'` people → reports anyone still holding any `product_access` (job summary + audit + dedup'd in-app push to IT admins); detection only, never modifies access. Verified in prod 2026-05-27: zhonghui.du removed from all 12 groups, re-read `groups_after=[]`. Tests: `revoke_all_access` (5) + offboarding account-blocker scan (3) + reconcile (3); #532's 28 unaffected. 0 RBAC change, 1 migration, 1 cron, 0 MCP-tool change.)
>
> **Today, latest** (2026-05-27, feat(asset): asset recycle bin + restore. Soft-deleting an asset stamps `deleted_at`, but there was NO way to view or restore deleted assets — and the intake-collision 409 told admins to "restore from the admin recycling bin", a feature that didn't exist. Now built: [`AssetService.restore_asset`](../../server/app/domains/asset/service.py) (clears `deleted_at` + writes a `restored` history row; 409 `ASSET_NOT_DELETED` if the asset isn't deleted; NO unique-collision handling — the single-column `asset_tag`/`serial_number` UNIQUE indexes cover ALL rows, so while a soft-deleted ghost holds the tag/serial no live row can take it and restoring it back can't collide) + `list_deleted_assets` (deleted_at IS NOT NULL, newest-first, searchable). Two admin-only routes `GET /assets/recycle-bin` + `POST /assets/{id}/restore` (`require_asset_admin`, audited `asset.restore`); the recycle-bin route is declared BEFORE `GET /{asset_id}` so the literal path isn't parsed as an asset UUID. `AssetListResponse` + `enrich_with_users` gained a `deleted_at` field. 1:1 MCP twins `list_recycle_bin_assets` + `restore_asset` (mutates=True), `_REGISTERED_TOOLS_COUNT_HINT` 181→183. Frontend: admin 资产管理 → 回收站 page ([AssetRecycleBinPage.tsx](../../web/src/pages/asset/AssetRecycleBinPage.tsx), RoleRoute global_admin/asset_admin), table + search + per-row restore. The 5 soft-delete-collision 409 messages now consistently say "restore it from the asset recycle bin". Regression: TestAssetRecycleBin (4). 0 migration / 0 cron / 0 new RBAC gate.)
>
> **Today, latest** (2026-05-27, fix(asset): asset-intake 500 on a soft-deleted serial/tag collision. User hit "资产入库失败" registering a Mac Mini (serial `K27CY61C37`); confirmed against prod `compose-backend-1` logs + TiDB (NOT the #534 tag-code change as suspected). A same-serial asset created 04:29 / soft-deleted 09:55 still occupied the UNIQUE index `uk_asset_items_serial_number`; `intake_asset`'s dup pre-check filters `deleted_at IS NULL` (misses the ghost) → INSERT hits the DB constraint → `IntegrityError (1062)`. The soft-delete-collision handler was meant to reshape that into a clean 409 but matched the **column** name (`asset_items.serial_number`) while MySQL/TiDB's 1062 message names the **index** (`asset_items.uk_asset_items_serial_number`) → no match → fell through to an unhandled 500. **Reusable gotcha — test/prod index-name divergence:** the test DB's `create_all` names a single-column `unique=True` index after the **column** (`serial_number`), while alembic migrations name it `uk_asset_items_serial_number`; so any code that string-matches a 1062 message passes in tests and fails in prod. Fix: new module helper [`_asset_duplicate_unique_column(exc)`](../../server/app/domains/asset/service.py) gates on errno 1062, extracts the key name from `for key '...'`, and matches the **column token inside the index name** — robust to both naming schemes. Applied at all three asset IntegrityError handlers (intake / create / update; update previously also missed `asset_tag` entirely and had the same broken match). Regression: `TestAssetDuplicateUniqueColumn` (pins BOTH the prod `uk_` index name and the test column name) + `TestSoftDeletedCollisionIntake` (end-to-end 409). 0 migration / 0 cron / 0 RBAC / 0 MCP tool count change. NOTE: the code fix turns the 500 into a 409 but does NOT let the blocked Mac Mini intake succeed — the soft-deleted ghost still owns the serial; resolve by restoring it from the recycling bin or hard-deleting the ghost.)
>
> **Today, latest** (2026-05-27, fix(cmdb): `cmdb.cost.nightly_recompute_allocation` 1800s timeout — `recompute_allocation` now batches its writes. Prod failed at the 1800s hard timeout (run `7e754732`, 04:49 UTC). Root cause (confirmed against prod TiDB, not code-reading): the re-stamp loop in [`cost_service.py::recompute_allocation`](../../server/app/domains/cmdb/cost_service.py) issued **one `UPDATE … WHERE id = :id` per changed row** (~10 ms/round-trip to TiDB). Steady-state runs (nothing changed) are ~270s — pure scan of the 2.7M-row / 90-day window. But on 2026-05-27 an HRIS department/cost-center reorg (+196 changed scopes) dirtied **~513k facts** (verified via `updated_at` histogram); at ~10ms/row that needs **>5000s** — impossible inside one 1800s tick, so the cron ran partial, the worker died mid-run leaving an orphan `running` row (swept at +6h), a 04:49 retry re-stamped ~180k more and hit the wall. **Fix:** group changed rows by the target tuple `(person, dept, cost_center, rule, source)` and emit **one `UPDATE … WHERE id IN (…)` per distinct target per 10k-row scan batch** (the 90-day window holds only ~150 distinct targets), committing per batch — so a full ~500k re-stamp drops from ~500k statements to a few hundred and finishes in seconds. **Compounding concurrency bug also fixed:** the `distributed_lock("cost_recompute_allocation")` TTL was **600s** at all three callsites (cron handler / `cost_router.py` HTTP / `mcp/tools.py`) — shorter than real runtimes (270s–1408s observed), so the lock silently expired mid-run and let the 03:30 + 04:49 ticks run concurrently and contend on the same rows. All three raised to **1800s** (= the executor hard-timeout ceiling); asyncio-cancellation on timeout still unwinds the `async with` and releases the lock, so the longer TTL only bounds hard-death recovery. Regression test `test_recompute_batches_writes_without_cross_contaminating_owners` drives 3 distinct targets × multiple facts and asserts each fact lands on its OWN owner + idempotent re-run (`updated == 0`). 0 migration / 0 cron schedule / 0 RBAC / 0 MCP tool count change. **NOTE — separate latent bug spotted, NOT fixed here:** the executor's outer single-flight lock `sched_job:{job_id}` is `ttl=900` ([`service.py::execute_job`](../../server/app/scheduled_jobs/service.py)) while the handler hard-timeout is 1800s — ANY job running 900–1800s loses single-flight and can be double-dispatched. Cross-cutting; left for its own PR.)
>
> **Today, newest** (2026-06-02, fix(tencent-cam): 离职清云权限改 **group-aware revoke** — 修正 PR #663 走错方向的 "DetachUin" 假修复。PR #663 上线人工验证才发现修在错的方向上:真根因是 lifu.wu@/chujie@ 13 条 grant **全部是 group-inherited**(管理员 / 账单计费 / EE 三个 CAM group)而不是 direct attach,`DetachUserPolicy` 对 group-inherited policy 返回 RequestId 但 silent no-op — 加 `DetachUin` 让 API 接受请求但策略仍在云上。三层套娃 root cause:(1) **SDK 3.1.79 字段 bug** — `DetachUserPolicyRequest`/`AttachUserPolicyRequest` 只有 `PolicyId`+`DetachUin`/`AttachUin`,没有 `DetachUserName`/`AttachUserName`;旧 driver 写到 ad-hoc 属性,SDK 序列化 drop 首字母 D 变 `EtachUserName`(同 `_Page`/`_Month` 已知 bug 模式)→ API 拒绝 `UnknownParameter`。(2) **DetachUserPolicy 仅对 direct attach 有效** — group-inherited 必须 `RemoveUserFromGroup`。(3) **Uid≠Uin** — `ListGroupsForUser.Uid` / `RemoveUserFromGroup.Info[].Uid` 是 CAM 内部 id,跟 sub-account UIN 不同号;`GetUser` 同时返两个,旧代码把 Uin 当 Uid 传 → `ResourceNotFound.UserNotExist`(说谎,user 实际存在)。修复 [`tencent_cloud.py`](../../server/app/integrations/drivers/tencent_cloud.py):新 `_resolve_user_ids(client, user_name)` helper 同时返 `(Uid, Uin)`;`_revoke_sync` 重写为 group-aware — 先 `ListAttachedUserAllPolicies(TargetUin, AttachType=2)` 找 source group → 对每个 group `RemoveUserFromGroup(Uid, GroupId)`,找不到再 fallback AttachType=1 → `DetachUserPolicy(DetachUin, PolicyId)`,都没有 → `already_absent`;`_revoke_account_sync` 同步用 Uid 调 group APIs(原本错传 Uin);`_grant_sync` AttachUserPolicy 去掉 `AttachUserName`,只传 `AttachUin`+`PolicyId`。**副作用**:撤一条 group-inherited grant 会移除 user 整个 group membership,同 group 其他 policy 一起被撤(Tencent CAM 固有语义,group 是 atomic 撤权单位)。离职场景天然 correct;admin 单撤场景 UI 应在 confirm modal 显示 `detail.groups_removed`/`side_effect_warning`。回归测试 [`tests/test_integrations/test_tencent_driver_cam.py`](../../server/tests/test_integrations/test_tencent_driver_cam.py) 重写,7 用例锁住:group-inherited path / multi-source-group / direct-attach / not-attached(idempotent)/ user-missing / Uid-vs-Uin / AttachUin。Prod 残留 lifu.wu@+chujie@ 13 条 Tencent grant 已在 03:20 手动 SSM `RemoveUserFromGroup` 清干净(ListAttachedUserAllPolicies 验证返 0),无需新 backfill — `sweep_pending_manual_cloud_revokes` 会走新 group-aware path 自然清未来同类残留。0 migration / 0 cron / 0 schema / 0 RBAC / 0 MCP tool count change.)
>
> **Today, earlier** (2026-05-27, fix(asset): Atlassian seat-count basis → `product_access`. `jira.seat_sync` now counts a seat as **an active Atlassian account that holds the product on the plan's paid-production site(s)**, read from the Org Admin API `product_access` — NOT the membership of a default group. Group membership ≠ license: cross-verified on prod against `/rest/api/3/applicationrole` userCount (= billing truth), the old group-count was wrong three different ways — Jira Software undercounted by ~81 (holders in other conferring groups), JSM counted the WRONG group entirely (`jira-servicedesk-users` confers no JSM license; ~108 of its members had no JSM, ~40 real agents missing), Confluence never synced (0). Correct numbers: **Jira Software 366, JSM 72, Confluence tidb 107 + pingcap-us 30**. The shared truth source is [`app/integrations/atlassian_activity.py`](../../server/app/integrations/atlassian_activity.py): `AtlassianAccountActivity.access_sites_by_product` (per-product presence, **counts holders even when `last_active` is blank** — a license is a license) + `AtlassianActivityDirectory.license_holders(family, sites)`. Per-plan scope comes from `binding_config.product_key` + `site_urls` (added to the driver `binding_schema`); **`product_key` is the count opt-in** — a plan without it is skipped, which is how Marketplace add-on catalogs (Tempo/ScriptRunner/eazyBI) that reuse `group_name='jira-software-users'` are excluded. The two Confluence subscriptions (tidb / pingcap-us) are separate catalogs/plans/connectors pinned to one site each. **JSM grant/revoke group fixed** to `jira-servicemanagement-users-pingcap-ticket` (the applicationrole `defaultGroups`) — counting ignores the group but grant/revoke (offboarding / idle-reclaim) depends on it, so the wrong group silently broke provisioning. Migration **206** (JSM group fix + `product_key`/`site_urls` on the two real bindings, by catalog name; seed 2 Confluence subs with `offboarding_policy='sso_cascade'`). No new cron (reuses `jira.seat_sync`), no new MCP tool. Convergence to the numbers above is verified by running `jira.seat_sync` in prod post-deploy. Regression: [`test_jira_seat_sync.py`](../../server/tests/test_scheduled_jobs/test_jira_seat_sync.py) (9 cases incl. sandbox-not-counted / blank-last_active-still-counts / Confluence-two-site-separate / in-group-no-license / license-not-in-default-group / idempotent / drift / empty-directory-refusal). 0 RBAC change.)
>
> **Today, earlier** (2026-05-27, feat(asset): idle software-seat reclaim — `software_seat.idle_reclaim`. New daily cron that recovers paid Atlassian (Jira Software / JSM / Confluence) seats nobody uses, with a warn → grace-buffer → self-service-keep → reclaim lifecycle. Scope: only Helix-tracked **active** `software_seat_allocations` for the Jira three-piece set (service/bot/external accounts never get a Helix seat → "not in HR → never reclaim" holds by construction). **The "90 days unused" computation lives in a reusable helper [`app/integrations/atlassian_activity.py`](../../server/app/integrations/atlassian_activity.py)** so the previous mistake can't recur: for each (person, product) it takes the MAX `last_active` across the **paid production sites only** (Jira/JSM → `tidb.atlassian.net`; Confluence → `tidb.atlassian.net` + `pingcap-us.atlassian.net`), ignoring `tidb-sandbox` + external personal sites (`yanyanqing`/`ecosystem`/`fluxai`); parses the nanosecond UTC `last_active` by date-prefix only; a blank `last_active` is "undetermined → never reclaim". Two new `JiraSoftwareDriver` methods feed it: `list_org_users_with_activity()` (`GET /admin/v1/orgs/{org}/users`, cursor-paginated, returns `last_active` + `product_access[]`) and `get_user_groups(account_id)`. Hard guardrails (never auto-reclaim): Atlassian admins (`jira-administrators`/org/site admin), leavers/non-active employees, blank `last_active`, **multi-group holders** (if removing the bound product group leaves the holder with the product via another group → punt to admin manual-review, never a half-reclaim). Reclaim = direct `driver.revoke_seat(action_on_offboard=deactivate)` — removes the **product group only**, never disables the account (that's the offboarding line). All day-math in the holder's `resolve_timezone` local date. Self-service "keep my seat" = `POST /assets/seat-allocations/{id}/keep` (sets `idle_kept_until`, clears `idle_warned_at`) + 1:1 MCP twin `keep_my_idle_seat` (MCP hint 180→181). Schema: `idle_warned_at` / `idle_kept_until` / `idle_manual_review_at` on `software_seat_allocations` + threshold(90)/buffer(14)/keep(180)/admin-groups/prod-sites platform_settings — all migration **204**. Seeded **enabled=0**: an operator flips it on after a one-time prod-worker probe confirms the Org Admin API `product_access` JSON shape (CREDENTIAL_ENCRYPTION_KEY is prod-only; warnings are user-facing and must not fire against an unverified shape). Frontend: idle-warning tag + 保留坐席 button on the IT 宇宙 seats tab (`UserItUniverseTab`, serves `/me`). 0 RBAC gate change.)
>
> **Today, later** (2026-05-27, fix(cmdb) — UCloud body-level transient `RetCode` retry. UCloud returns HTTP 200 + a non-zero `RetCode` for **every** error including transient server faults, so `RetCode=8498` "Call third api service timeout, please retry" bypassed the `_call` retry budget (which only covered transport + HTTP 429/5xx), was raised immediately, failed the whole multi-region `servers` sync and paged (prod, `jpn-tky` UHost). Fix: new `_is_retryable_retcode(ret, msg)` in [`providers/ucloud.py`](../../server/app/domains/cmdb/providers/ucloud.py) (RetCode 8498 + message hints `timeout`/`please retry`/`try again`; EXCLUDES region-skip 150/230 and the auth bucket) + the JSON-parse/RetCode-classification moved INSIDE the retry loop so a transient RetCode retries in-place within the same 3-attempt budget (parity with the existing HTTP-5xx path and Tencent's `_tencent_throttle` `InternalError`/`ServiceUnavailable` pool). The per-region `list_servers`/`list_load_balancers` re-raise is **intentionally unchanged** — `_safe_sweep` does not consult `partial_failures`, so continuing a partial sweep could soft-delete a transiently-failed small region within drop-tolerance. The lifecycle/billing driver [`drivers/ucloud_cloud.py`](../../server/app/integrations/drivers/ucloud_cloud.py) `_signed_call` (which had NO retry budget) gained a `retryable: bool = False` flag: True on idempotent reads only (GetRegion/List*/ListUBillDetail), default-False preserves single-shot behavior for non-idempotent IAM mutations so a timed-out write never silently re-fires. Cross-checked aws/azure/gcp/aliyun/alicloud_intl/tencent — no same-class gap. 0 migration / 0 cron / 0 RBAC / 0 MCP tool count change.)
>
> **Today (latest)** (2026-05-27, fix(cloud): `cmdb.billing_daily_sync` `PendingRollbackError` — two structural invariants codified. **(A) `NotificationService.dispatch` never poisons its caller's session.** The initial `notification_outbox` INSERT (`db.add + flush`) is now wrapped in a `begin_nested()` SAVEPOINT, so a transient DB error on that flush — prod was a `(1205) Lock wait timeout exceeded` — rolls back only the INSERT and re-raises cleanly instead of leaving the session rolled-back-pending. Before this, every best-effort dispatch caller (`_dispatch_scope_threshold_alert`, `_alert_spike`, offboarding/provisioning notifiers) that did `try: dispatch() except: log` silently poisoned the session; the next statement raised an opaque `PendingRollbackError` that masked the real error. Savepoint releases *before* the external Feishu/email send, so it adds no lock-hold across I/O. **(B) The multi-scope cloud-sync crons commit PER SCOPE, not once at the end.** `sync_billing_daily_for_integration`, `sync_roles_for_integration`, and `sync_grants_for_integration` each previously ran their entire scope loop in ONE transaction (single commit after the last scope), held open across minutes of external cloud-API calls (prod billing runs 122s–894s) — long enough to hold target-table row locks (`notification_outbox` / `cmdb_cloud_roles` / `cmdb_cloud_access_grants`) and trigger the 1205. Prod `scheduled_job_runs` confirmed the SAME 1205 class on billing_daily AND roles_sync (2×) AND cost_facts_sync. Now each commits `await session.commit()` at the end of its per-scope iteration, matching the proven `cost_service.ingest_cost_facts` per-batch-commit pattern; locks release promptly, partial progress survives a later scope's failure. Safe mid-loop because `async_session_factory` sets `expire_on_commit=False`. **`grants_sync` invariant preserved:** only the per-scope discovered-grant UPSERTS commit mid-loop — the stale-grant drift sweep + 50% safety floor + auto-owner adoption stay a SINGLE atomic block at the end, still gated on `synced_scope_ids`, so the mass-revoke blast radius is unchanged. **Already-safe (NOT changed):** `sync_scopes_for_integration` (single upfront `list_scopes()` + BATCH_SIZE=50 commit), `cost_facts_sync` (per-batch commit inside `ingest_cost_facts`). Also: the two alert-path defensive reads (`_period_already_alerted`, the pre-existing-alerts read in `evaluate_scope_alert_rules`) were `try/except-pass` around a `session.execute` with no rollback — same poison class — now each read is `begin_nested()`-wrapped.
>
> **(C) The billing reconciliation pass preserves the driver's currency label.** The unregistered-account (scope_id=NULL) rows written by `sync_billing_daily_for_integration`'s reconciliation pass previously hard-coded `currency="USD"`. CN clouds (Aliyun/Tencent/UCloud) write NATIVE CNY into the legacy `total_cost_usd` column with `currency='CNY'` and rely on downstream `_to_usd` to convert; the hard-coded USD mislabeled CNY-as-USD and would inflate the admin billing-overview total ~7× the moment a CN cloud has an unregistered sub-account. Now the reconciliation row carries `row.currency`, mirroring the per-scope path. Regressions: [`test_dispatch_session_safety.py`](../../server/tests/test_notifications/test_dispatch_session_safety.py) + [`test_billing_daily_reconciliation_currency.py`](../../server/tests/test_cmdb/test_billing_daily_reconciliation_currency.py) (both fail when their fix is reverted). 0 migration / 0 cron schedule / 0 RBAC / 0 MCP tool count change.)
>
> **Today** (2026-05-27, Notifications outbox — admin drawer now shows the real body + server-side `unhealthy` filter. The admin outbox detail drawer ([`NotificationsOutboxPage.tsx`](../../web/src/pages/admin/NotificationsOutboxPage.tsx)) rendered only `title` + a `template_key` Tag and never `body_text`, so an operator debugging "what did we actually send this person?" had no way to read the dispatched content. Backend [`notifications/router.py`](../../server/app/notifications/router.py) `OutboxRow` schema + `_serialize` now expose `body_text` + `url`; FE `OutboxRow` type + drawer render the full pushed text (pre-wrap, copyable) + deep-link. Separately, the **default `unhealthy` view** filtered the page client-side while `meta.total` still counted delivered rows → pagination claimed pages that rendered near-empty; `list_outbox` now accepts the synthetic `status='unhealthy'` → `status IN (pending,partial,failed,dead)` resolved server-side so the row set AND total agree (free-text `q` was already server-side). First admin-outbox API regression tests added ([test_outbox_admin_api.py](../../server/tests/test_notifications/test_outbox_admin_api.py): body/url serialization, unhealthy filter+total, exact-status match, admin-only 403). 0 migration / 0 cron / 0 RBAC / 0 driver / 0 MCP tool count change. Found via a prod incident — 10 `identity_sync.auto_included_manager` Feishu pushes sat `pending` because the `notification.outbox_retry` cron had been manually disabled 2026-05-16 (audit: `scheduled_job.update {enabled:false}`); the 10 were delivered out-of-band via the prod worker, and the cron remains disabled pending an owner decision on whether the 2026-05-16 mass-disable of CMDB jobs was intentional.)
>
> **Today** (2026-05-27, Round-53 security sweep — 16 bugs fixed across connectors / auth / asset / identity-sync / CLI in one PR (#527). No new features. Bug categories: (1) **UCloud driver** `_user_name_for` fallback was literal `"helix-user"` — collides across all zero-alphanumeric email prefixes; changed to `f"helix-{sha1[:12]}"` matching AWS/Aliyun/Tencent pattern. (2) **UCloud billing** `list_billing_daily` silently capped at 10k rows with `logger.warning`; changed to `raise RuntimeError` to surface partial billing data. (3) **Azure billing** error-body sanitization now chains `redact_provider_error` (credential scrubbing) THEN control-char strip (`\n/\r/\t → space`) — prior fix applied only redaction, leaving control chars in persisted error messages. (4) **`integrations/service.py`** Bailian history-table names moved from inline local variable to module-level `_BAILIAN_HISTORY_TABLES: frozenset[str]` (SQL-injection defence-in-depth on dynamic table-name selection). (5) **`identity_sync/service.py`** dry-run `out_of_scope_states` list comprehension now mirrors `_sweep()` logic (adds `auto_included_reason` check), eliminating dry-run/live divergence. (6) **`auth/service.py` `_store_refresh_jti`** changed fail-open (try/except swallowing Redis errors) to fail-closed — Redis is a hard auth dependency; swallowed errors allowed refresh tokens without JTI tracking, enabling replay. (7) **`auth/service.py` `update_user`** gained module-level `_UPDATE_USER_WRITABLE_COLUMNS` allowlist gating the `hasattr` mass-assignment path — any JSON key matching a Python attr on `AuthUser` could previously be mass-assigned. (8) **`identity/successfactors_sync.py`** logs WARNING when `work_email` changes during HRIS sync, enabling forensic trail. (9) **`asset/service.py` `get_subordinate_assets`** BFS query gained `leaver_pending` + `deleted_at IS NULL` filters; `get_stats()` queries gained `deleted_at IS NULL`. (10) **`asset/router.py`** software-catalog PUT locked `owner_person_id` + `it_contact_person_id` for non-admin callers; `GET /{asset_id}` manager scope replaced single-level direct-reports with full BFS depth-5; `subordinates_cost_stats` + `subordinates_billing` added is_manager-OR-admin guard. (11) **`me_universe/service.py` `_assets_for`** gained `deleted_at IS NULL` (soft-deleted assets leaked into employee self-view). (12) **`cli/output.py`** added CR (0x0D) to `_TERMINAL_STRIP_TABLE` to prevent terminal line-overwrite attacks. Fix-16 (`require_browser_session` Depends refactor) reverted after CI: FastAPI `dependencies=[]` runs concurrently with handler `Depends()` params — `request.state.auth_via` not yet set, always raising 403. SHA `7103d44e` deployed to production 2026-05-27. 0 migration / 0 cron / 0 RBAC / 0 MCP tool count change.)
>
> **Earlier today** (2026-05-27, Round-52 security + correctness sweep — 15 bugs fixed across connectors / leaver-blocked / MCP / CLI / offboarding / asset in one PR. No new features. Bug categories: (1) **Jira driver** `find_user_by_email` was a single-page 100-result lookup — added cursor pagination (`startAt` + 50-row pages, 20-page cap) so orgs with >100 Jira accounts no longer silently miss seats. (2) **Azure ARM driver** `_find_existing_assignment` had an unbounded `while next_url` nextLink loop — added `_MAX_PAGES=50` cap; `_list_principal_assignments` gained a `_MAX_PAGES=500` cap + warning log when cap hit. (3) **Feishu software driver** `_feishu_error_code` used `re.match` (anchored) on exception messages that are not always the first chars — changed to `re.search`. (4) **LeaverBlockedGuard** `_validate_override` never checked `expires_at` — a permanently valid or expired override was accepted; now enforces UTC-aware expiry with explicit `None` rejection. (5) **LeaverBlockedListener** `_before_flush_check` only scanned `session.dirty`, missing brand-new `session.new` leaver rows. (6) **Feishu seat handler** `safe_to_drift_revoke` had `matched_count` and `want_count` transposed — revoke safety gate was computing wrong ratio, risking mass-revoke. (7) **CLI config** `load_config()` never validated `base_url` from env/config-file sources — only the default was validated; any SSRF-shaped URL in the config file was accepted silently. (8) **MCP rate-limit middleware** Redis INCR + EXPIRE for PAT and auth-failure limits were non-atomic — two concurrent requests could both see `count=1` and neither set the TTL; fixed with `pipeline(transaction=True)`. (9) **Slack SCIM driver** `revoke_seat` PUT hardcoded `userName = ctx.user_email` — Slack requires the original SCIM `userName` value (not always the email) for PATCH/PUT; added `find` lookup and `user.get("userName")` fallback; added post-PUT active=True validation. (10) **MCP tools** `recycle_asset_to_office` and `release_asset_from_office` were not bounding free-text fields via `_bound_free_text`, allowing >2000-char reason/notes to reach the DB. (11) **Zoom S2S token cache** was an instance variable — a new `ZoomSoftwareDriver` per request created a new cache, forcing a fresh token fetch on every API call; moved to process-global `_TOKEN_CACHE` dict keyed by `integration_id`. (12) **Zoom API** had no 429 rate-limit retry — added `_zoom_request` async helper with 3-attempt exponential backoff (2+4+8s), respects `Retry-After` header, passes through concurrent-disassociate idempotency 429 so `revoke_seat` can detect it. (13) **Asset bulk idempotency** `_check_bulk_idempotency` / `_acquire_bulk_idempotency_lock` had no operator scoping — two different admins using the same client-generated key would falsely deduplicate each other; now scoped by `operator_id`. (14) **Offboarding `suspend_sso`** pending-task query was scoped only by `user_id`, not `process_id` — tasks from a prior cancelled cycle for the same person could leak into the current suspend run; added JSON-path filter on `context_data["offboarding_process_id"]`. 0 migration / 0 cron / 0 RBAC / 0 MCP tool count change.)
>
> **Tonight** (2026-05-26, Creator column on shared scope-billing panel — surfaces `cmdb_resource_creators` attribution to **both admin AND employee** in one place. Before: creator data populated by the cron sweep + on-demand resolver + one-shot backfill script (PR #466/#500/#506/#508 plus tonight's ad-hoc sidecars) was visible only via `CloudCostAnalysisPage`'s resource-drawer `ResourceCreatorSection` (admin only). After: a new 「创建者」 column lands on [`ScopeBillingPanel`](../../web/src/components/cloud/ScopeBillingPanel.tsx)'s Top-20 resources table — rendered identically at `/admin/cloud/scopes/<id>` and `/my/cloud/scopes/<id>` since the panel is shared. Backend: [`_top_resources`](../../server/app/domains/cmdb/scope_billing_detail.py) gains a 4th SQL pass — single `tuple_(integration_id, resource_id).in_(...)` against `cmdb_resource_creators` joined to `identity_persons` for canonical display_name, bounded by `_RESOURCES_TOPN=20` so the round-trip stays O(1) in fleet size. Frontend: `ScopeBillingResourceRow.creator: ScopeBillingCreator | null` (null = cron sweep hasn't processed yet, distinct from `status='not_found'`); column renders avatar+name for `resolved`, status tags with explanatory tooltips for `not_found`/`window_exceeded`/`unsupported`/`error`/`pending`, dash for null. Person-name links to `/admin/users/<id>` only when viewer has `global_admin`/`hr` role (mirrors `App.tsx` RoleRoute allowlist) — employees see the same name as plain text so they get attribution visibility without a 404 button. `scroll.x` 920 → 1090 to keep the 170px column from squeezing the sparkline. zh/en/ja i18n keys: `cloudBilling.drawer.creator.*` (column header + 5 status labels + 5 tooltip bodies, full bilingual+JP coverage). 0 RBAC change (data already gated by `can_read_cloud OR finance` via the parent `/billing-detail` endpoint), 0 migration, 0 driver change, 0 cron change.)
>
> **Earlier today** (2026-05-26, Resource-creator attribution third deep fix — Alicloud event-field case asymmetry + Azure SP→owner Graph fallback. Prod state pre-PR: alicloud_intl `cmdb_resource_creators` 3743/3743 stuck at `not_found` (0% resolved); Azure 5193 resolved but only 24 rows (0.5%) had `creator_email` — the other 99.5% were Service Principal callers whose `caller` field is a bare GUID. **Root cause (Aliyun)**: ActionTrail OpenAPI v3 (2020-07-06) returns its top-level wrapper in PascalCase (`Events` / `NextToken`) but each event object's fields are camelCase (`eventName`, `eventTime`, `userIdentity.{userName,principalId}`). The driver read PascalCase throughout, so `Events` came through as a list but `ev.get("EventName")` was always None → every event filtered out → empty earliest tracker → false `not_found`. Confirmed via prod `_acs3_call` raw-body probe. Fix: new `_ev_get(obj, *keys)` static method on `AliyunCloudDriver` (case-tolerant: tries each key as-given AND with first-letter case-flipped) used everywhere event fields are read. Paging loop also accepts both `Events`/`events` and `NextToken`/`nextToken` defensively. **Root cause (Azure)**: 99.5% of Azure write events in prod were issued by Service Principals (Terraform / AKS managed identities / GitHub Actions), whose `caller` is the SP's objectId GUID and `claims.upn`/`claims.name` are absent. The static parse had no SP→human path. Fix: new instance method `_enrich_sp_creator(info)` triggered when static parse returns `info.email=None` AND `info.principal_id` looks GUID-shaped. Calls Microsoft Graph in two steps: (1) `GET /v1.0/directoryObjects/{oid}` — disambiguates `@odata.type=#microsoft.graph.user` vs `…servicePrincipal`. (2) If SP → `GET /servicePrincipals/{oid}/owners?$select=id,mail,userPrincipalName,displayName&$top=20` → pick first user-typed owner as `creator_email`; render `creator_display_name` as `"<owner-name> (via <sp-name>)"`. On any Graph 403 / 404 / transport-error → silently degrades: `creator_email` stays None but `creator_display_name` upgrades to `"<sp-name> (SP, owner unknown)"`, never crashes the cron. Process-level `_GRAPH_OID_CACHE` (6h TTL, 1024-entry LRU, keyed `(integration_id, oid)`) amortises repeated SP lookups — Top SP `721a00b8-…` owns 627 prod resources, so one Graph call serves the whole batch instead of 627. URL-path interpolation safety: `_OID_GUID_RE` (`^[0-9a-fA-F]{8}-…{12}$`) gates `oid` before it touches the Graph URL — same defence-in-depth shape as the existing subscription-id guard. Data-fix path (NOT a migration — pure data reset run after deploy via TiDB direct SQL): `UPDATE cmdb_resource_creators SET status='error', attempt_count=0 WHERE …status='not_found'` (alicloud-intl) and `…WHERE status='resolved' AND creator_email IS NULL` (azure) — flips both classes from terminal back to non-terminal so the cron picks them up next tick. 13289 stale rows / 400 per day default cadence ≈ 33d drain, or one-shot `run-now {"limit":1000}` for catch-up. Five new regression tests cover both new code paths AND the backwards-compat case (existing PascalCase test still green). 0 driver-base / 0 RBAC / 0 MCP / 0 migration / 0 cron schedule change — pure driver-layer parse + Graph fallback.)
>
> **2026-05-26 follow-up** (Resource-creator multi-round P0 sweep — audit-write bug + force-retry escape hatch + frontend link 404 + last_error sanitization + sensitive rate-limit + first HTTP integration tests. Three independent agent passes (code-review / security / logic) caught **a latent P0 that has shipped broken since PR #466**: [`cost_router.py:329`](../../server/app/domains/cmdb/cost_router.py) GET `/creator` (and PR #508 first draft's new POST `/creator/retry`) called `AuditService.write_audit(...)` — a static method that does not exist. Every successful invocation fell through to `AttributeError` AFTER the cloud audit-API (CloudTrail / Cloud Audit Logs / ActionTrail / Activity Log) had already been queried + quota burnt. Client got 500, `audit_logs` got nothing — opaque rate-budget drain with no observability. The PR #466 service-layer tests never caught it because zero HTTP-route integration test existed; the project's other ~19 audit call-sites all use `AuditService(db).log(...)` instance pattern. Fix: rewrite both call-sites to the instance pattern (`actor_id` → `actor_user_id`, `target_*` → `resource_*`, `details` → `detail`, add `actor_type="user"` + `status="success"`); add 7 HTTP integration tests in [test_resource_creator_router.py](../../server/tests/test_cmdb/test_resource_creator_router.py) that hit GET + POST endpoints with real httpx AsyncClient + assert audit rows land + RBAC 403 + 404 paths short-circuit before any driver call. Same PR also lands: (a) [`reset_and_resolve_resource_creator`](../../server/app/domains/cmdb/resource_creator_service.py) service function + `POST /creator/retry` endpoint + 1:1 MCP tool `cost_retry_resource_creator` so rows stuck at `attempt_count >= RESOURCE_CREATOR_MAX_ATTEMPTS` can be unstuck without DB intervention (`_REGISTERED_TOOLS_COUNT_HINT` 178 → 179); (b) `Depends(check_sensitive_endpoint_rate_limit)` on the retry endpoint + `apply_sensitive_rate_limit(rt.user.id)` in MCP twin (10/h/user, same limiter as PAT-reveal / asset-PII reveal — reset deliberately clears the in-row cooldown so app-level rate-limit is the only tail-protection against a compromised finance account looping the endpoint); (c) [`_sanitize_last_error`](../../server/app/domains/cmdb/resource_creator_service.py) regex-scrubbing of pre-signed URL params (`Signature=` / `X-Amz-Signature` / `X-Amz-Credential` / `AWSAccessKeyId`), OAuth bearer tokens (`Bearer eyJ…` / lone `eyJ…`), and `(token|secret|password|api_key)=<blob>` patterns — `last_error` is returned to every reader passing `can_view_finance OR can_read_cloud` so unredacted driver exception strings could leak material; original exception still goes to `logger.exception` (admin-only log access); (d) frontend `<Link to={'/admin/people/${id}'}>` → `/admin/users/${id}` (correct route) AND gated on `user.roles ⊇ ['global_admin'|'hr']` mirroring `App.tsx:438` `RoleRoute` allowlist so finance/cloud-admin viewers don't see a button that bounces them home; new error-state "Force retry" button uses the new POST endpoint (the existing "Re-resolve" via `refresh=true` doesn't restore cron eligibility once over the cap); (e) `serialize_resource_creator_row` lifted to service layer as single source of truth across 4 surfaces (HTTP GET, HTTP POST, MCP cost_resolve_resource_creator, MCP cost_retry_resource_creator) — both MCP tools previously omitted `last_attempt_at` while HTTP included it; now mechanically identical; (f) UUID + length defence-in-depth on HTTP path (was MCP-only). 53/53 tests pass (33 service-layer + 7 new HTTP integration + 5 MCP surface + 8 worker import). 0 migration / 0 cron schedule / 0 driver change.)
>
> **Earlier today** (2026-05-26, Round-51 上线前账单/告警/异常综合扫,16 个真 bug 一 PR 修完. 用户原话："整个帐单获取,告警、增长异常触发之类的再做 4 次 code review 和 3 次逻辑扫描 3 次安全扫描"。10 轮独立 agent 扫描汇总,本 PR 落实了下列结构性 invariants(详见 ROADMAP):
>
> - **Spike detection: 最小 7 天 baseline + multiplier 范围校验 + USD-normalized 渲染 + audit log**. `lifecycle_service.py::sync_billing_daily_for_integration` 的 inline spike 检测之前 `past` 仅 1 天也会触发 → 老 cron 上线第一周必然爆假阳性。`_audit_cloud_scope_alert_fired` helper 给每次 dispatch 写一行 `cloud.scope_alert.fired` audit(scope/baseline/threshold/multiplier/FX rate/recipients/dispatched 快照),解决了"为什么 3am 收到这张卡"的 forensic 缺失。`detect_anomalies` 早就有 `len(points) < 7` gate,新的 cron 路径与它一致。
> - **FX rate 14d staleness gate**. `app.core.fx_rate.get_usd_per_cny` 现在读 `updated_at` 并拒绝 >14 天的陈旧值(与 `ai_spending.cny_rate_refresh` cron 的 hard-fail 阈值对齐)。`lifecycle_service._to_usd` 在 `cny_per_usd <= 0` 时改为 RAISE 而非 fall through —— 之前的 silent fall-through 会把 CNY 当 USD,delta-threshold 比较多 7× 触发假阳性。
> - **Owner-confirmation first-observation cooldown**. 新增 `platform_settings.cmdb.owner_confirmation.first_observed_at` 时间戳,第一次 cron tick 写入 `now`。`list_scopes_due_semiannual` 在 cooldown 期(默认 30 天)内只发 ever-confirmed 但 180d 到期的卡,silently suppress 老 NULL-confirmed scope。`list_scopes_with_zero_spend_last_month` 在 cooldown 期内直接返回 `[]`。这是 launch-day 不爆 200 张卡的核心保护。
> - **Zero-spend ingest-presence guard**. `list_scopes_with_zero_spend_last_month` 现在要求 scope 在 [month-60d, month) 内至少有 1 条 cost_fact 行 —— "没接 driver" 和 "真零花费" 在 SQL 上区分开。
> - **Notification outbox / admin retry: leaver gate**. `retry_pending_outbox` 和 `/notifications/outbox/{id}/retry` 都新加 `is_effectively_departed` + `deleted_at` 检查,违反时 mark `dead` 而非重发到离职员工 Feishu / 邮箱。`FEISHU_USER_NOT_FOUND` 加入 `_PERMANENT_ERROR_FRAGMENTS`,不再 5 次 backoff 重试。`/api/v1/notifications` 路由整体加 `_leaver_runtime_gate` dep。
> - **Budget systemic-failure gate 公式**. `run_apikey_budget_check` 的 release-rate 触发条件从 `all_release_count >= total_evaluated` 改为 `total_fired == 0 AND all_release_count >= 1`。之前的公式只在 100% saturation 才 trip,5/100 over-budget 全 fail dispatch 就静默吃了。
> - **`run_billing_sync` 不再静默吞 budget alert error**. 修复后 budget-alert dispatch 失败会写到 summary 并 RAISE,触发 `failure_notifier` page IT admin。
> - **Subordinates / cost: leaver_pending + deleted_at 过滤**. `_resolve_subordinate_ids` 之前只过滤 `employment_status != 'left'`,允许 `leaver_pending` 留在 manager 视图。
> - **Feishu / ECharts XSS + markup injection 一揽子修复**. `owner_confirmation_cards` 的 4 个 card builder 全部把 `scope.name`/`scope_environment`/`display_name`/`top_active_members` 走 `safe_envelope_text`。`billing_alerts` 的 Feishu group-bot 文本同样消毒。前端 `HostsTab.tsx` + `CloudAnalyticsDashboard.tsx` 的 ECharts donut tooltip 从字符串 formatter 切到 function formatter + `htmlEscape`。`_url_safety.UnsafeURLError` 不再 embed full URL(Feishu bot webhook URL 含 bearer token,之前会进 stdout JSON 日志)。
> - **越权 / RBAC 收紧**. `AllocationTab` (chargeback CRUD + 重算分摊) 现在只对 `canManageCloud(user)` 显示,`finance` (read-only) 不再看到大红按钮。`/my/cloud/department-cost` 路由 + menu entry 加 `requireManager` 包裹,IC 不再被诱导到一个永远只显示自己的"部门花费"页。
> - **owner-confirmation `escalate_overdue` 加 `with_for_update(skip_locked=True)`** 防止 sweep 与 owner respond 路径 race。
>
> **Yesterday — late** (2026-05-25, **CMDB cloud-resource list endpoints now uniformly scope to enabled cloud integrations** when no specific `integration_id` is pinned. New module-level helper `_active_cloud_integration_ids(db) -> set[str]` in [`server/app/domains/cmdb/service.py`](../../server/app/domains/cmdb/service.py) is the single source of truth — returns the string-form (per memory `reference_asyncmy_uuid_bind`) ids of `integrations WHERE kind='cloud' AND enabled=true`, used by `list_servers / list_domains / list_dns_records / list_dns_zones / list_load_balancers / list_cdn_domains / list_object_storage` in the same file and by the IAM-users list path (only `revoked_mode='active' AND NOT mine`) in `router.py`. Behind it sits the prod incident this PR closes: when an admin disables a cloud integration, `cloud_sync` cron skips it, which skips mark-and-sweep, which means the integration's `cmdb_*` rows live forever with `deleted_at IS NULL` and surface as duplicate / ghost rows in every "全部" list (prod showed 4 dupes — `pimgcap.com / chaosmatrix.cn / tidbcloud.cn / tidbcloud.com.cn` — all from the disabled `阿里云` tenant + the enabled `UCloud` tenant). Caller-pinned `integration_id` is honored as-is — admin audit of a disabled tenant via the `<CloudTenantFilter>` "disabled" tag path keeps working. **Carve-outs**: billing endpoints (`/billing/monthly`, `/billing/summary`, `/billing/overview`, `/billing/export.csv`) and `/sync-jobs` deliberately stay unfiltered — they're historical / audit data and disabling a tenant doesn't undo last quarter's spend or last week's sync history. The `cmdb.domain_expiry_alert` cron is now gated by the same helper to prevent double-paging an owner whose same `domain_name` lives in both a disabled and an enabled tenant (state table is keyed by `domain.id` not `domain_name`, so two rows → two DMs per bucket transition). IAM-users `mine=true` self-service path carves OUT of the gate so employees still see their own credentials when the tenant is disabled (the active-tab revoke buttons are admin-only — the carve-out cost is zero). Frontend `<CloudTenantFilter>` "全部 (N)" label switched from `tenants.length` to `tenants.filter(t => t.enabled).length` so the count matches what the now-filtered backend will return; `<CloudDnsPage>` zone dropdown re-queries with `integration_id` on tenant switch and clears any stale `zoneFilter` chip (non-embedded path via onChange; embedded path via `useRef` + setState-during-render to avoid the stale-fetch race between the tenantId useEffect and the zoneFilter clear). Pre-existing sibling bug also fixed in same PR: `me_universe.service._cloud_domains_for` was missing the `deleted_at IS NULL` filter that the sibling `_cloud_servers_for` already had — same copy-paste-gap-on-one-branch family as the disabled-tenant fix. +10 regression tests in `test_dns_lb_cdn_api.py` covering 7 list endpoints + admin-audit carve-out + IAM mine bypass + expiry-alert skip + zero-enabled short-circuit. 2 independent code-review passes (1 caught 2 MED; 1 caught 1 MED). 0 migration / 0 cron seed / 0 driver / 0 RBAC / 0 MCP. Same-pattern audit confirms `selectable-for-dns` already had this filter since the self-service DNS picker shipped — this PR aligns the rest of the surface.)
>
> **Yesterday** (2026-05-25, Resource-creator sweep first-run shook out two real PR #466 bugs. After PR #500 seeded the cron + added the 90d filter, the user manually triggered a sweep and got `RuntimeError: 116/200 resolves failed (≥50% threshold)` + zero rows in `cmdb_resource_creators`. Two root causes: **(1)** the cron handler shares ONE session with `_execute_job_locked` and `_upsert_creator_row` only `flush()`s, never `commit()`s — when the systemic-failure raise fires at the end, the `async with session` exit rolls back every row already written (the 84 successful resolves AND the 116 error rows). Fix: `await db.commit()` after each `resolve_resource_creator` call inside the handler loop, so every row lands the instant it's written and the systemic raise no longer destroys the batch's diagnostic evidence. **(2)** PR #466's Aliyun driver hardcoded `host="actiontrail.aliyuncs.com"` but that bare global host doesn't resolve in DNS — `actiontrail` is a region-pinned service on Aliyun (verified 2026-05-25 against prod EC2: `actiontrail.aliyuncs.com` → NXDOMAIN, `actiontrail.cn-shanghai.aliyuncs.com` and `actiontrail.ap-southeast-1.aliyuncs.com` → IPs). Fix: new `_ACTIONTRAIL_REGIONS` class attribute on `AliyunCloudDriver` (default `("cn-shanghai",)`) + override on `AlicloudIntlCloudDriver` (`("ap-southeast-1", "us-east-1", "eu-central-1")` matching its `config_public.regions`); `get_resource_creator` now walks the regions, transport-fail on one falls through to the next, every-region transport-fail re-raises so the service layer records `status='error'` (avoids the regression class of misclassifying a transport outage as terminal `not_found`). Three new regression tests: `test_sweep_handler_commits_each_resolve_before_systemic_raise` (ingest 6 cost_facts, 1 succeeds + 5 fail → RuntimeError raised, all 6 rows still present in DB — drops to 0 rows if commit boundary regresses), `test_aliyun_get_resource_creator_walks_regions_until_hit` (ConnectError on ap-southeast-1, empty on us-east-1, event on eu-central-1 → result is the eu-central-1 event AND all three hosts called), `test_aliyun_get_resource_creator_raises_when_all_regions_dns_fail` (every region ConnectError → driver re-raises httpx.ConnectError, locks the "transport-broken ≠ not_found" invariant). 25/25 service-layer tests pass. PR #466 test coverage gap that lit up these bugs: the service tests used `_FakeCloudDriver` so the handler commit boundary was never stress-tested; the driver tests only covered the post-fetch event parser `_aliyun_event_to_creator_info`, never exercised `get_resource_creator`'s endpoint construction. Prod observable signal that GCP + Azure perms are fine: first sweep's backend log shows `logging.entries.list` 200 OK against GCP + `microsoft.insights/eventtypes/management/values` 200 OK against Azure — only Aliyun was broken end-to-end. 0 driver-base / 0 RBAC / 0 MCP / 0 migration / 0 cron schedule change.)
>
> **Earlier same-day** (2026-05-25, Resource-creator attribution cron is now actually seeded + bounded. PR #466 (2026-05-22) shipped the 4-cloud `get_resource_creator` driver methods, the `cmdb_resource_creators` cache (migration 198), the HTTP + MCP twins, and the frontend `ResourceCreatorSection` — but the cron handler decorator `@register_scheduled_job(key="cmdb.resource_creator_sweep")` only adds the handler to the in-process registry, it does NOT write the matching row into `scheduled_jobs`, so APScheduler had no row to attach to and the sweep never fired. Caught 2026-05-25 when a prod `SELECT * FROM scheduled_jobs WHERE name='cmdb.resource_creator_sweep'` returned zero rows. Migration **201** explicit INSERTs the row (`trigger_type='cron'`, `cron_expr='0 2,14 * * *'` UTC → 10:00 + 22:00 Asia/Shanghai, `enabled=1`, `config={"limit":200}`) with `WHERE NOT EXISTS` idempotency. Pairs with a 90-day candidate-freshness filter in `list_unresolved_resources` (new module constant `RESOURCE_CREATOR_CANDIDATE_WINDOW_DAYS = 90`, exposed as optional `candidate_window_days` kwarg for one-shot wider sweeps) — without it the prod `cmdb_cost_facts` pool of 276,709 distinct rids would feed the sweep ~277k candidates and 2/day × 200 = 690 days to drain. With the filter the candidate pool caps at ~38k initial + ~150/day steady-state. The 90d cutoff matches the shortest provider audit retention (AWS CloudTrail / Aliyun ActionTrail / Azure Activity Log default 90d); GCP's 400d capacity is sacrificed for predictable cross-cloud sizing — a one-shot widening via `run-now {"candidate_window_days": 365}` is supported when an operator wants the GCP back-history. Out-of-band: `roles/logging.privateLogViewer` granted to `pch-resource-manager@gcp-digital-infra` at org level (478164292948) so the SA can call `entries.list` on `cloudaudit.googleapis.com/activity`. AWS / Aliyun / Azure scopes verify lazily — first sweep tick's `cmdb_resource_creators.status='error' + last_error` rows surface any per-cloud gap and we patch the cloud-side IAM. Handler docstring + decorator description updated: the original "Disabled by default — operator enables once IAM confirmed" + "1k backlog drains in 5 days" guidance was authored when no IAM was granted anywhere and assumed a 1k-resource scale; both estimates are corrected to "Seeded enabled by migration 201" + "twice-daily 400/day, 90d window caps initial sweep at ~38k → ~95 days drain". One new regression test `test_list_unresolved_skips_resources_outside_90d_window` (fresh vs stale cost_fact ingest, asserts default 90d excludes stale + explicit `candidate_window_days=365` re-admits it). 22/22 service-layer tests pass. 0 driver / 0 RBAC / 0 MCP / 1 migration / 1 cron seeded.)
>
> **Earlier same-day** (2026-05-25, Mail-group suffix whitelist gets a purpose-built chip editor inside the generic Platform Settings page. Previously `google.mail_group.suffix_whitelist` could only be hand-edited as raw JSON in `PlatformSettingsPage.tsx`'s textarea — a 6-domain list with no domain-shape validation and no visible "this entry is the dropdown default" affordance. Now the editor renders one closable Tag per domain (first chip gets a blue color + "默认" badge to surface the convention that the user-facing mail-group `Select` shows the first item; non-first chips expose a `↑` action that promotes them to position 0 in one click); add-input validates against `^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$` + dedup, and the modal refuses to save an empty list because `email_group/service.py::get_suffix_whitelist` treats `[]` literally (no fallback to `_DEFAULT_SUFFIX_WHITELIST`), which would silently disable every mail-group + alias submission AND the `account_provisioner.py::_validate_cc_emails` allowlist. **Semantics**: "delete a domain" is the only visibility primitive — removing an entry hides it from the dropdown AND rejects new submissions referencing that domain (including `cc_emails`). No DB schema change (storage stays plain `string[]`), no migration, no MCP / RBAC / cron change. Editor activates only for that one key — every other JSON-array setting still routes through the generic TextArea. Files: [web/src/pages/admin/PlatformSettingsPage.tsx](../../web/src/pages/admin/PlatformSettingsPage.tsx) (`SuffixWhitelistInput` chip component + `isSuffixWhitelist` branch in `SettingEditModal` save/render); i18n keys `platformSettings.mailGroupSuffix.*` added to all three locales.)
>
> **Earlier same-day** (2026-05-24, Round 30 — **status-code 5xx retry** family alignment, prod-incident triggered). PR #477 added **transport-layer** retry to the CMDB resource-sync drivers (ReadTimeout/ConnectError etc.) but didn't cover the case where a successful TCP roundtrip returns a 5xx response. Prod hit it: `cmdb.resource_sync .../servers: partial_failure · nic_fetch_failed:ProviderTransientError × 1 [nic:...pm-validation-priv-vm101_z1]` — single ARM 5xx on one NIC GET flipped the resource_sync cron red, next tick self-healed but the failure-notifier paged. This round consolidates 5xx + 429 + transient-server-fault retry across the four drivers that lacked it: (i) **Azure** `providers/azure.py` — new `_arm_request` helper consolidates transport + 429 + 5xx retry; `_arm_get` and `_arm_list_all` nextLink hop both go through it. `_DEFAULT_ARM_5XX_RETRIES=2` (3 total attempts, 1s/2s exp backoff, 30s cap). AAD `_token` endpoint same 5xx retry inline (single AAD hiccup pre-fix nuked the entire sync). Code-review-driven hardening: 401 mid-retry now invalidates `_token_cache[_ARM_BASE]` so the next ARM call re-fetches a fresh token (previously a stale token would have N concurrent NIC GETs all 401 in lockstep); intermediate 5xx/429 responses `await resp.aclose()` before backoff sleep (httpx pool hygiene under regional 5xx storms). `_fan_out_per_subscription` gains a `capability` parameter passed by all 4 callers (`list_servers/dns_records/object_storage/load_balancers`) so subscription-level `partial_failures` / `skipped_legs` entries carry the same `capability` label as the NIC-level NIC fetch leg already did — failure-notifier dedup signatures no longer collapse different capabilities' sub-403s onto one alert. (ii) **UCloud** `providers/ucloud.py::_call` — same 3-attempt loop now also retries 429 + 5xx within the budget (pre-fix only retried `httpx.HTTPError`). `await resp.aclose()` between retries to release the pool slot. UCloud signature is pure sort+concat+SHA1 (no Timestamp / SignatureNonce), same payload retry is idempotent. (iii) **GCP** `providers/gcp.py` — every `.execute()` call site (6 total: `crm.projects.search` for billing-list pagination, `compute.instances.aggregatedList`, `dns.managedZones.list`, `dns.resourceRecordSets.list`, `storage.buckets.list`, `cloudbilling.billingAccounts.projects.list`) now passes `num_retries=_GCP_EXECUTE_NUM_RETRIES` (=3). googleapiclient's built-in retry covers 5xx + 429 + transport (SSLError, socket.timeout, HttpLib2Error, ServerNotFoundError) with built-in exponential backoff + jitter; does NOT retry 4xx (deterministic client errors stay deterministic). (iv) **Tencent** `integrations/_tencent_throttle.py` — `_is_request_limit_error()` widened from "`RequestLimitExceeded.*` only" to "`RequestLimitExceeded.*` ∪ `InternalError.*` ∪ `ServiceUnavailable.*`" via module-level `_TENCENT_TRANSIENT_CODE_PREFIXES` tuple. Tencent error-code taxonomy is well-documented: these three prefixes are server-side / transient; client-side errors get distinct prefixes (`InvalidParameter.*`, `MissingParameter.*`, `OperationDenied.*`, `AuthFailure.*`, `ResourceNotFound.*`) which still propagate immediately to surface real config / auth bugs. Both async (`tencent_call`) + sync (`tencent_call_sync`) paths aligned. **Audit completeness**: 5 of 9 CMDB cloud providers either gained 5xx retry this round (Azure / UCloud / GCP / Tencent) or already had it from PR #477's parent (Aliyun + Alicloud-Intl); AWS uses boto3 standard mode (built-in 5xx + 3 attempts); Ksyun is a stub. Three independent review rounds: Round 1 scan listed 4 high-severity gaps across 4 driver files → Round 2 code review caught 2 real bugs (token 401 cache invalidation + connection close between retries) → Round 3 security/logic scan caught 1 more (UCloud connection leak in retry path). +18 new unit tests pin the retry contract: 5xx retry then recover, retry budget exhausted with attempt count in error message, nextLink hop path, 401 invalidates token cache, all 4 drivers' classifier boundaries (e.g. `AuthFailure.SignatureFailure` MUST NOT retry even after widening). 0 migration / 0 cron / 0 driver / 0 RBAC / 0 MCP tool — pure transient-resilience hardening across the resource-sync hot path.)

> ---

> **2026-05-25 — 上线前 Round 50 综合扫(9-pass)— 2 处真 bug 修复**: 9 个独立 agent (3×CR + 3×逻辑扫 + 3×安全扫) 全面审计云连接器面 + IAM/账号 + 订阅/权限 + 帐单 + 通知/回收。Net 安全发现 0 HIGH / 0 MED / 0 LOW (凭证 / SSRF / 注入 / RBAC IDOR / 高危幂等 / 审计完整性全部通过)。代码层 2 处真 bug + 修复:(i) [`scope_request_service.approve`](../../server/app/domains/cmdb/scope_request_service.py) 成功路径写完 PROVISIONED + audit 后未 commit，依赖 router 尾部 commit;若 `_notify_requester_provisioned` 抛错则 row 卡 PROVISIONING(云端 scope 已创建)。补对称 commit + try/except 与失败路径一致。(ii) [`AlicloudIntlProvider._list_ram_users_current_credentials`](../../server/app/domains/cmdb/providers/alicloud_intl.py) 分页内层 try/except 静默吞异常，page 2 失败仅返回 page 1 partial 数据，cron `runs.status='ok'`;让异常上升到外层 `list_iam_users` 的 per-sub-account 隔离层(已有 try/except + continue)，违反 `feedback_handler_status_must_raise` 修正。Round-1 → Round-49 的 ~200 处历史 review 注释解释了为什么 agent 报的另外 11 处「HIGH」其实是 false-positive(已修过 / 设计意图 / 规则不覆盖)。805/805 cmdb pytest pass。0 migration / 0 cron / 0 driver / 0 RBAC / 0 MCP tool / 0 frontend。
>
>
> **Later same-day** (2026-05-24, Round 30 — **status-code 5xx retry** family alignment, prod-incident triggered). PR #477 added **transport-layer** retry to the CMDB resource-sync drivers (ReadTimeout/ConnectError etc.) but didn't cover the case where a successful TCP roundtrip returns a 5xx response. Prod hit it: `cmdb.resource_sync .../servers: partial_failure · nic_fetch_failed:ProviderTransientError × 1 [nic:...pm-validation-priv-vm101_z1]` — single ARM 5xx on one NIC GET flipped the resource_sync cron red, next tick self-healed but the failure-notifier paged. This round consolidates 5xx + 429 + transient-server-fault retry across the four drivers that lacked it: (i) **Azure** `providers/azure.py` — new `_arm_request` helper consolidates transport + 429 + 5xx retry; `_arm_get` and `_arm_list_all` nextLink hop both go through it. `_DEFAULT_ARM_5XX_RETRIES=2` (3 total attempts, 1s/2s exp backoff, 30s cap). AAD `_token` endpoint same 5xx retry inline (single AAD hiccup pre-fix nuked the entire sync). Code-review-driven hardening: 401 mid-retry now invalidates `_token_cache[_ARM_BASE]` so the next ARM call re-fetches a fresh token (previously a stale token would have N concurrent NIC GETs all 401 in lockstep); intermediate 5xx/429 responses `await resp.aclose()` before backoff sleep (httpx pool hygiene under regional 5xx storms). `_fan_out_per_subscription` gains a `capability` parameter passed by all 4 callers (`list_servers/dns_records/object_storage/load_balancers`) so subscription-level `partial_failures` / `skipped_legs` entries carry the same `capability` label as the NIC-level NIC fetch leg already did — failure-notifier dedup signatures no longer collapse different capabilities' sub-403s onto one alert. (ii) **UCloud** `providers/ucloud.py::_call` — same 3-attempt loop now also retries 429 + 5xx within the budget (pre-fix only retried `httpx.HTTPError`). `await resp.aclose()` between retries to release the pool slot. UCloud signature is pure sort+concat+SHA1 (no Timestamp / SignatureNonce), same payload retry is idempotent. (iii) **GCP** `providers/gcp.py` — every `.execute()` call site (6 total: `crm.projects.search` for billing-list pagination, `compute.instances.aggregatedList`, `dns.managedZones.list`, `dns.resourceRecordSets.list`, `storage.buckets.list`, `cloudbilling.billingAccounts.projects.list`) now passes `num_retries=_GCP_EXECUTE_NUM_RETRIES` (=3). googleapiclient's built-in retry covers 5xx + 429 + transport (SSLError, socket.timeout, HttpLib2Error, ServerNotFoundError) with built-in exponential backoff + jitter; does NOT retry 4xx (deterministic client errors stay deterministic). (iv) **Tencent** `integrations/_tencent_throttle.py` — `_is_request_limit_error()` widened from "`RequestLimitExceeded.*` only" to "`RequestLimitExceeded.*` ∪ `InternalError.*` ∪ `ServiceUnavailable.*`" via module-level `_TENCENT_TRANSIENT_CODE_PREFIXES` tuple. Tencent error-code taxonomy is well-documented: these three prefixes are server-side / transient; client-side errors get distinct prefixes (`InvalidParameter.*`, `MissingParameter.*`, `OperationDenied.*`, `AuthFailure.*`, `ResourceNotFound.*`) which still propagate immediately to surface real config / auth bugs. Both async (`tencent_call`) + sync (`tencent_call_sync`) paths aligned. **Audit completeness**: 5 of 9 CMDB cloud providers either gained 5xx retry this round (Azure / UCloud / GCP / Tencent) or already had it from PR #477's parent (Aliyun + Alicloud-Intl); AWS uses boto3 standard mode (built-in 5xx + 3 attempts); Ksyun is a stub. Three independent review rounds: Round 1 scan listed 4 high-severity gaps across 4 driver files → Round 2 code review caught 2 real bugs (token 401 cache invalidation + connection close between retries) → Round 3 security/logic scan caught 1 more (UCloud connection leak in retry path). +18 new unit tests pin the retry contract: 5xx retry then recover, retry budget exhausted with attempt count in error message, nextLink hop path, 401 invalidates token cache, all 4 drivers' classifier boundaries (e.g. `AuthFailure.SignatureFailure` MUST NOT retry even after widening). 0 migration / 0 cron / 0 driver / 0 RBAC / 0 MCP tool — pure transient-resilience hardening across the resource-sync hot path.)
>
> **Same-day earlier** (2026-05-24: CMDB **resource-sync** drivers (`domains/cmdb/providers/`) get the same transport-layer retry hardening that fc08c02e + 73daf2b6 shipped for the **cost** driver (`integrations/drivers/azure_cloud.py`) — 3 driver files all aligned to one bar: "single transport flake does NOT kill an entire capability for a cron tick". (i) `providers/azure.py` adds module-level `_request_with_transport_retry()` (5 attempts, exponential backoff `min(2 ** attempt, 60)`s, catches `httpx.ReadTimeout` / `ConnectError` / `PoolTimeout` / `RemoteProtocolError` / `WriteError` / `ReadError` / `ConnectTimeout` / `WriteTimeout`); applied to `_token` POST + `_arm_get` GET (both the initial + the post-429 retry hop) + `_arm_list_all` nextLink GET (the nextLink hop pre-fix had NO retry at all, not even 429) + `get_monthly_billing` POST (both first call + post-429). Helper deliberately does NOT centralise status-code (429/5xx) retry — each call site keeps its own (ARM honours Retry-After up to 60s for 429; cost-mgmt does a single 429-retry) because ARM and Cost Management have materially different throttle envelopes. Re-raises the ORIGINAL httpx exception on budget exhaustion so PR #465's observability guarantee (failure-notifier shows real exception class) is preserved. (ii) `providers/aliyun.py::list_object_storage` (OSS GetService) had a sibling bug: the OSS REST path lived in a separate `httpx.AsyncClient` block (line ~699) with `client.get(...)` un-wrapped, while the RPC `_call` path (ECS / RDS / CDN / domains, line ~286) already had a transport-retry budget. Symmetric `_oss_get_with_transport_retry()` (same 8 exception classes + 5 attempts + same backoff). OSS Auth v1 signature is Date-header-bound to a 15-minute window so 5-attempt retry with the same signature is provably safe — no per-attempt re-sign. (iii) `providers/ucloud.py::_call` was a one-shot `except httpx.HTTPError as exc: raise ProviderTransientError(...) from exc` with no retry; replaced with a 3-attempt loop matching Aliyun's `_call` pattern. UCloud signature `_sign_params` is pure sort+concat+SHA1 (no Timestamp / SignatureNonce field), so re-POSTing the same payload is provably idempotent. (iv) Coverage audit confirmed the rest of the 9 CMDB providers don't need touching: AWS uses boto3 (SDK retry built in) / GCP uses google-cloud-* (SDK retry built in) / Tencent uses tencentcloud-sdk-python (SDK retry built in) / Alicloud-Intl wraps `aliyun._call` and inherits the OSS fix transitively / Ksyun is a stub. (v) The Azure ARM driver's hardcoded 30-second per-request `httpx.AsyncClient(timeout=30.0)` is left as-is — increasing the timeout doesn't solve the root cause (slow + flapping endpoint), the retry budget does. The 2026-05-24 prod incident that triggered this was `cmdb.resource_sync 6310dc15-... /object_storage: partial_failure · 1 leg(s): ReadTimeout × 1 [subscription:d3a296ee-...]` — Azure Storage account list returning ReadTimeout under no specific load pattern, with the prior tick's `cost_facts_sync` from fc08c02e succeeding fine (different driver file). 13 new tests pin the retry contract (each transport exception class catches, budget exhaustion re-raises, kwargs forward, exponential sequence, status codes do NOT trigger retry, integration test on object_storage). 0 migration / 0 cron / 0 RBAC / 0 MCP tool / 0 new driver — pure driver-internal hardening.)
>
> **Last refreshed**: 2026-05-25 (Round 49 — 5-agent parallel review on top of Rounds 1–48: ~20 HIGH + ~10 MED across 31 files in one PR. No new abstractions / drivers / cron / RBAC roles / MCP tools (`_REGISTERED_TOOLS_COUNT_HINT` unchanged at 172). Round-49 platform-shape highlights: (A) `auth/service.py::refresh_tokens` — on detected refresh-token reuse, flip `force_logout_at = now()` in an independent session + write `auth.refresh_token_reuse_detected` audit row via `log_denied_action`. RFC 6819 / OAuth 2.1 BCP — token-family invalidation, not just refusing the replayed token. (B) `auth/router.py::refresh_token` writes `auth.refresh_failure` audit row (independent session via `log_denied_action`) on every `ValueError` from refresh — leaver/laptop-still-polling forensic trail. (C) New audit-row coverage on SSO Apps catalog HTTP CRUD (`sso/app_router.py::create_sso_app/update_sso_app/delete_sso_app`) — MCP twins audited via tool_runtime, web UI did not; now both. (D) `require_browser_session` gate added to: `PATCH /bailian/workspaces/{id}/owner`, `PATCH /bailian/api-keys/{id}/owner` (Round-46 missed Bailian twins), `POST /apikeys/assignments/{id}/bailian/reissue` (mints fresh DashScope sk-), `POST /cmdb/scope-requests/{id}/approve`, `POST /cmdb/scope-requests/{id}/manual-provision` (last cloud_admin-write PAT lanes). (E) Driver-side error-body redaction sweeps: 5 Azure `*.text[:N]` sites Round-46 missed migrated to `_safe_body(resp, max_len=...)`; UCloud non-JSON fallback now uses `redact_provider_error`; new `_redact_jira_body` helper in `jira_software.py` wraps every Atlassian `resp.text[:N]` echo (Authorization: Basic + ATATT/ATCTT token shapes); `compute_failure_signature` JWT regex gains `re.IGNORECASE`. (F) `smtp_email.py` CRLF guard extends from subject/from/reply-to to `msg.to/cc/bcc` recipient lists (defence-in-depth; upstream allowlists cover most callers). (G) `feishu_chat_approval.py::verify_webhook` v1 body-token path gains SETNX `feishu:webhook_replay_v1:{sha256(body)}:{ts}` (TTL 600s) to match the v2 path's replay protection. (H) `feishu_hrbp.py::resolve_hrbp_for_person` now catches network-layer exceptions on all three Feishu calls (was only `AppException`) — fulfils the "never raises" contract so a Feishu DNS hiccup doesn't break the SLA escalation cron for every subsequent leaver. (I) `permission/service.py::admin_grant` inserts at `PERM_PENDING` (was `PERM_ACTIVE`); the cloud-side grant fires from the provisioning task and `_post_success_side_effects` flips PENDING → ACTIVE — closes the phantom-active gap when the driver call fails. (J) `apikey/service.py::apply_transfer` accepts `transfer_request_id: UUID | None` so the offboarding force-transfer path can delegate (was: bypassed `apply_transfer` entirely → silent ownership flip, no audit, no cost-allocation rule swap, no cloud tag rewrite). Audit action `apikey.transfer.force_complete` distinguishes voluntary accept from force-transfer. `hr_lifecycle/advance_service.py::_handle_one_blocker`'s `RES_API_KEY` branch now delegates with fallback inline `apikey.transfer.force_fallback` audit on raise. (K) `domains/asset/service.py::assign_asset` routes recipient through `_verify_user` (was: bare `select(AuthUser)` — surviving member of the Round-44/45 leaver-gap family for claim/borrow/change_assignee/transfer). (L) `leaver_blocked/listener.py::_before_flush_check` writes the trap-audit via `engine.sync_engine.connect()` independent connection + commit BEFORE the `raise LeaverBlockedError` — was: `session.add(audit)` then raise, audit-row discarded with the caller's rollback. (M) `offboarding/service.py::suspend_sso` only stamps `sso_suspended_at` when `not failed_task_ids` (was: unconditional → `complete_process`'s `sso_suspended_at is None` gate accepted partial-failure suspend; compliance report lied about vendor-session termination). `_scan_pending_blockers`'s GoogleOrphanAccount block adds `deleted_at IS NULL` filter. (N) Manager-resolution paths (`hr/service.py::_assign_manager` + `identity/successfactors_sync.py::_resolve_managers`) filter `deleted_at.is_(None)` AND `employment_status != "left"` — HRIS update-lag no longer leaves subordinates wired to a tombstoned manager. (O) `cmdb/sync.py::_sync_object_storage` drops `owner_raw` from `on_duplicate_key_update` clause (was: every hourly sync stomped admin-set bucket owner annotations); `_resolve_iam_owner_person_id` Tier-1 + Tier-2 require `employment_status == "active"` (was: a leaver could be auto-bound as owner of a freshly-discovered IAM user). (P) `auditor/services.py::decide_holder` requires `cycle.status == PRIV_CYCLE_IN_PROGRESS` (was: a closed cycle's leftover pending holder could be decided, mutating the auditor-signed snapshot). `auditor/router.py::complete_review_cycle` commits BEFORE `model_validate(cycle)` for response serialization (was: 500 on schema-validate rolled back state + audit row together). `auditor/router.py::download_evidence_pack` `os.stat()` for size>0 + commits BEFORE FileResponse streaming begins (was: race between retention sweep + stream open could land "downloaded" audit row for a stream that delivered zero bytes). `list_holders` raises `NotFoundError` when `cycle_id` unknown (was: 500 from `model_validate(None)`). (Q) `provisioning/router.py::my_billing` BFS gains `max_depth=6` cap aligning with canonical `app.core.rbac.get_subordinate_person_ids`. (R) `cost_router.py` 5 read endpoints (`/breakdown`, `/timeseries`, `/forecast`, `/anomalies`, `/resources`, `/tag-keys`) gain `_validate_optional_integration_id` helper (was: arbitrary string → log-flood + slow-query DoS, like Round 22 already fixed on `/subordinates`). (S) `cmdb/router.py` DNS request submit/bypass/retry use `get_real_ip(request)` (was: `request.client.host` = 127.0.0.1 behind nginx — Round-46 migration missed these three). (T) `_apply_addon_max_seats` in `zoom_handlers.py` now async + emits per-change `software_plan.max_seats_auto_tuned` audit rows (base-plan branch already did; add-on branch was silent). (U) Tencent `_resolve_policy_id` + Aliyun `list_iam_users` gain hard pagination caps (10 pages / 100 pages respectively) + Aliyun raises on degenerate `IsTruncated=True + unchanged Marker` (no silent truncation that would feed mass-revoke). (V) Slack SCIM `_reactivate_existing` rejects `userName: None` (was: a legacy SCIM-1.0 record without `userName` would PUT `null` and silently clear Slack's workspace identity); falls back to lowercased email or fails loud. (W) Google `_get_or_create_google_seat` dedup includes `pending` status (was: a stuck-pending row slipped past the active-only check and tripped migration 119's partial-unique-index IntegrityError, poisoning the HR-sync batch's session). (X) **Round 49 续: 帐单获取 / 告警 / 增长异常 final wave** — `cmdb/cost_service.py::ingest_cost_facts` rejects non-finite (`NaN`/`±Inf`) `amount_usd` / `amount_native` / `usage_quantity` before the Decimal conversion with full-context `RuntimeError` (was: silently dropped via the adaptive-split singleton-fallback path, dashboard underreports cost). `update_allocation_rule` + `delete_allocation_rule` audit moved INTO the service layer in the SAME transaction as the mutation — update detail now carries `old_values` / `new_values` / `changed_fields`, delete detail now carries the full pre-delete `snapshot` (was: router wrote `detail={}` AFTER the service committed; delete erased the rule + the only record of its prior state). `scheduled_jobs/handlers.py::run_apikey_budget_check` wraps the sweep in `try/finally` so the tail `session.commit()` fires even on mid-loop exception (was: ~50 iterations of `ApiKeyBudgetAlertsDedup` SAVEPOINT rows rolled back on the session-scope rollback → next day's cron re-fires the same 80%/100% alert storm). `apikey/expiry_alerts.py` adds `manager.employment_status != "left"` filter to recipient list (parity with budget_alerts). `cost_router.py::/anomalies` + `mcp::cost_anomalies` gain `limit` Query (default 200, max 500) and thread it into `detect_anomalies`, which now also reports per-candidate `eff_iqr` (was: global `iqr` — display mismatched the threshold actually used to gate each row). Round 46 wave (preserved below) — tertiary pre-launch fix wave on top of Round-45 + Round-44: 3 CRITICAL + ~14 HIGH + ~5 MEDIUM across 13 files. No new abstractions / drivers / cron / RBAC roles / MCP tools (`_REGISTERED_TOOLS_COUNT_HINT` unchanged at 172). Round-46 platform-shape highlights: (α) `cmdb/router.py` adds `Depends(require_browser_session)` to `POST /dns-record-requests/{id}/bypass-execute` and `PATCH /iam-users/{id}/owner` — a stolen cloud_admin / global_admin PAT can no longer drive the most-destructive cloud-write override (4-eyes bypass) or rebind IAM principal ownership without an interactive browser session. MCP tools call the service layer directly so they are unaffected. (β) `cleanup_iam_user`'s cascade-revoke `helix_grants` branch now also matches `GRANT_STATUS_PENDING` (Round-45 CR-206 had updated only the discovered-grants branch). Pairs hoist of the `GRANT_STATUS_PENDING` import to function scope so both branches see it. (γ) New `_safe_body(resp, max_len=...)` helper in `integrations/drivers/azure_cloud.py` wraps every `resp.text[:N]` echo into `redact_provider_error`; 21 sites migrated. Same family as the Aliyun fix Round-45 already shipped. (δ) `scheduled_jobs/service.py::_execute_job_locked` scrubs `str(exc)` via `_scrub_message` BEFORE persisting into `ScheduledJobRun.error_message` + `ScheduledJob.last_error` — Round-44 had only added scrubbing at notification dispatch time, leaving the DB row raw. (ε) `core/dependencies.py::get_real_ip` migration: 113 `ip_address=request.client.host if request.client else None` audit / mutation sites across `asset/router.py` (61), `apikey/router.py` (19), `hr/router.py` (15), `asset/inventory_router.py` (9), `approval/router.py` (4), `scheduled_jobs/router.py` (3), `cmdb/router.py` (2) all migrated to `ip_address=get_real_ip(request)` so audit rows behind nginx record the real client IP, not the loopback proxy peer. Pairs with Round-45's rightmost-XFF fix. (ζ) `provisioning/service.py::complete_manual_task` now calls `_post_success_side_effects(task)`. `fail_task` calls `_post_failure_side_effects(task)`. `cancel_task` calls `_post_failure_side_effects(task)` only when `task.action == ACTION_REVOKE` (cancel of a GRANT leaves the seat in pending, which is correct). Round-45 doc claimed this had been done but the actual file showed no such call. (η) MCP `set_sso_app_owner` refuses inactive (left / leaver_pending / deleted) successors. `DomainRegistrationService.handover` does the same at the service layer (HTTP + MCP both benefit). (θ) `hr_lifecycle/advance_service.py::_force_transfer_all` early-returns when `proc.status in (PROC_CANCELLED, PROC_COMPLETED)` — defends against future code paths that cancel a process while leaver_pending stays set. (ι) `offboarding/escalation.py::_send_reminder` only writes `OffboardingReminderLog` when at least one channel was delivered — Feishu app revoke + SMTP creds expired no longer permanently suppresses future T-1/T-3/T-7 retries. (κ) `leaver_blocked/guard.py::_cancel_pending_requests_for_leaver` extends sweep to `CmdbDnsRecordRequest` (status `pending → cancelled`) and `CmdbCloudScopeRequest` (status `pending → withdrawn`) — both tables use `requester_person_id` so they live in a separate sweep block (cannot share the `person_id` generic loop). (λ) `cmdb/service.py::annotate_server` writes a per-resource `record_owner_change(RESOURCE_TYPE_CLOUD_SERVER, ...)` history row + `SELECT FOR UPDATE` lock — Round-45 added the type but only wired it into the offboarding T+0 path. `batch_annotate_servers` does the same per-row + locks all rows. (μ) `cmdb/dns_request_service.py::cancel` admits `can_manage_cloud` callers on behalf of the requester (mirror of SSO grant flow's admin-on-behalf cancel). (ν) `auth/router.py::reveal_my_contact` (`/auth/me/reveal-contact`) gains `Depends(require_browser_session)` — admin twin already had it; PAT can no longer pull the owner's mobile_phone + personal_email. (ξ) `auth/service.py::update_user` refuses to flip `employment_status` from `left` to anything else via the generic PATCH; resurrection must go through `OffboardingService.cancel_process` so SSO / blockers / seats unwind consistently. (ο) `domains/cmdb/cost_router.py::_audit_failure` scrubs `str(exc)` via `_scrub_message` before persisting. (π) `mcp/tools.py::mark_person_leaver_manual` anchors `today` on `datetime.now(UTC).date()` to match the HTTP twin (was: `_date.today()` = worker-local TZ — multi-hour ±7d-floor gaming window across deployment regions).) Round-45 wave (preserved below) — secondary pre-launch fix wave on top of Round-44: 7 CRITICAL + ~25 HIGH + ~5 MEDIUM across 26 files. No new abstractions / drivers / cron / RBAC roles / MCP tools (`_REGISTERED_TOOLS_COUNT_HINT` unchanged). Round-45 platform-shape highlights: (a) `approval/router.py::handle_feishu_callback` applies SETNX `approval:webhook_replay:{nonce}:{ts}` (sig path) + `approval:webhook_replay_v1:{instance|uuid}:{ts}` (token path) Redis dedup that Round-44 had only put in the driver class — closing the in-±300s-window replay gap on the actual production HTTP endpoint. (b) `approval/service.py::verify_callback_token` also enforces `header.app_id == cfg.app_id`; `_serialize_feishu_form` extends the radioV2 option-set backstop to `checkboxV2` + `select` (raises `APPROVAL_FIELD_INVALID_OPTION` 400 on miss). (c) `cmdb/dns_request_service.py::bypass_4_eyes_execute` refuses `DNS_REQ_BYPASS_SELF_FORBIDDEN` 403 when `actor.id == requester_person_id` — cloud_admin cannot self-bypass. (d) `cmdb/sync.py::_sync_iam_users` writes `cmdb.iam_user.revival_detected` audit row + Feishu/email IT-admin push for every row whose `revoked_at` is being cleared by the 5-min cloud-revival path (shadow-IAM threat made loud). (e) `provisioning/service.py::complete_manual_task` now calls `_post_success_side_effects(task)` so manual-mode Slack/Zoom/Atlassian completions flip the linked SoftwareSeatAllocation / OffboardingBlocker (was: task done but seat stayed active forever). (f) `scheduled_jobs/handlers.py::cron_auto_revoke_pending_cancels` reorders to enqueue-FIRST + status-flip on success only + raises summary failure (was: flip first, swallow enqueue errors, claim ok). (g) `offboarding/service.py::_apply_transfer` writes AssetOperation alongside AssetHistory, accepts `status in ("assigned","claimed")` (was `"assigned"` only), and refuses recipient accept when asset is `disposed/disposal_pending/held_by_leaver`. (h) `cancel_process` cancels in-flight LeaverDualSignOverride rows in `pending_secondary`/`approved` state and writes `identity.force_logout_at.cleared` audit row. `start_process` reopen clears prior `hardware_disposition`. Three blocker-resolution paths (`mark_blocker_resolved_manual`, `resolve_blocker_by_revoke`, `resolve_blocker_by_handover_to_successor`) gain `_get_blocker(lock=True)`. (i) `asset/service.py::transfer_asset` routes recipient verification through `_verify_user` (rejects leaver_pending / left / deleted). `dispose_asset` snapshots `purchase_date / purchase_cost / serial_number / brand / model / asset_tag / prior_status / prior_assigned_to` into `AssetOperation.detail.asset_snapshot`. `dispose_asset`'s held_by_leaver guard now only blocks when `force=True and not allow_held_by_leaver` (default-path "Lost / write-off" is the legitimate path for held_by_leaver). (j) `sso/service.py::handover` refuses successors with `employment_status in ("left","leaver_pending")` or `deleted_at`; `revoke` dispatches Feishu+email push to system_owner + IT admins so manual SAML revocation isn't forgotten. (k) `leaver_blocked/sla_service.py` evaluates `leaver_date > today` against per-leaver TZ-local date (was: worker UTC). (l) `cmdb/owner_history.py` adds `RESOURCE_TYPE_CLOUD_SERVER`; `advance_service.py::_apply_hardware_disposition` cloud_server branch writes a per-resource history row. (m) `advance_service.py::transfer_to_person` stamps `silent_retarget=True` + originally chosen person/label into AssetOperation.detail when chosen successor is inactive. (n) `digest_service.py` skips inactive admins in daily-digest fan-out. (o) `leaver_blocked/guard.py::_cancel_pending_requests_for_leaver` extends sweep to `EmployeeRequest`. (p) `asset/router.py::request_seat` server-resolved Feishu form keys (software_name / plan_name / price / reason) are no longer overwritable via client `body.answers`. (q) `inventory_service.py::send_cycle_reminder` skips leaver_pending/left recipients; `respond_to_item` re-fetches asset with `.with_for_update()`. (r) `cmdb/router.py::update_iam_user_owner` + `cleanup_iam_user` extend inactive-target gate to `leaver_pending`; `cleanup_iam_user` cascade-revoke includes `GRANT_STATUS_PENDING`. (s) `approval/service.py::_auto_execute_approved_request` re-checks requester `employment_status` at auto-execute time for `computer_borrow`/`asset_return`/`offboarding_buyback`. (t) `scheduled_jobs/router.py::patch_job` + `acknowledge_run` + `trigger_job` add `Depends(require_browser_session)`. (u) `core/dependencies.py::get_real_ip` picks rightmost X-Forwarded-For (was leftmost = client-controlled). (v) `identity/successfactors_client.py::_get_json` refuses absolute `__next` URLs whose host doesn't match `base_url`. (w) `integrations/drivers/aliyun_cloud.py` raw-response fallback passes through `redact_provider_error`. (x) `mcp/tools.py::presign_upload_put` + `presign_upload_get` replace bare ValueError/PermissionError/RuntimeError with typed AppException. (y) `sso/app_catalog_service.py::import_apps_from_csv` adds ≤1000 rows cap. (z) `offboarding/router.py::suggest_transfer_recipient` requires caller-holds-asset OR asset_admin. (aa) `asset/service.py::process_employee_request` refuses approve when requester has become inactive between submit and decision. Round-44 wave (preserved below) — Platform-shape highlights: (1) `dispose_asset` service-method signature gained `allow_held_by_leaver: bool = False` kwarg — moved off the Pydantic schema (now `extra="forbid"`) so external API callers cannot smuggle it; the buyback auto-execute in `approval/service.py::_auto_execute_approved_request` passes it explicitly. (2) `app/main.py` now mounts a `StarletteHTTPException` handler that wraps bare `HTTPException(...)` into the standard `{success, data, meta, errors}` envelope AND filters response headers through the same `_SAFE_RESPONSE_HEADER_KEYS` allowlist used by `app_exception_handler` (only `retry-after` / `www-authenticate` / `allow` pass through). (3) `core/dependencies.py` `check_login_rate_limit` + `check_refresh_rate_limit` now raise `AppException` (was bare `HTTPException`) so the frontend can read `errors[0].message`. (4) `worker.py::_run_scheduled_job` now writes a synthetic `ScheduledJobRun(status='failed', triggered_by='scheduler')` + fires `maybe_notify_failure` when `execute_job` raises BEFORE its inner wrapper takes over — closes the wrapper-bypass silent-failure window (previously the watchdog was the only signal). (5) New private helper `_record_owner_force_transfer_audit` in `hr_lifecycle/advance_service.py` writes `audit_logs` rows for force-transfer of cloud_scope_owner / cloud_iam_user / software_owner / software_it_contact / domain_registration / google_orphan_owner — these 6 branches previously rewrote the owner FK silently. (6) Feishu v2 webhook driver `verify_webhook` now SETNX's `feishu:webhook_replay:{nonce}:{ts}` in Redis (TTL 600s) — closes the in-±300s-window replay gap. (7) `cmdb/iam_upsert.py::upsert_local_iam_user` pre-flight refuses 409 `IAM_USER_PREVIOUSLY_REVOKED` when the `(integration_id, user_name, sub_account_id)` row exists with `revoked_at IS NOT NULL` — pairs with PR #196's scope-side asymmetric-tombstone fix. (8) Manager-chain walk depth in `me_universe.assert_can_view` bumped 4 → 6 (matches the `permission/router.py::list_grants` explicit depth-6 call) so a CEO can view 5+ levels of subordinates in a 500-person org; same depth-6 widening applied to `permission/router.py` cross-user `?user_id=` grant reads. (9) Cron handlers `relationships.full_sync` / `relationships.incremental_sync` / `sso.execute_pending_google_grants` / `identity_sync.dispatch` now `raise RuntimeError` when every source / target failed — fixes the "scheduled_job_runs.status='ok' while nothing got done" class. (10) `failure_notifier` card body now scrubs credential-shaped substrings via `_scrub_message`. (11) `audit/router.py` — `/export` requires at least one scoping filter and writes a self-audit `audit.export` row; `/logs` gets `check_directory_read_rate_limit`. (12) Borrow-return path canonicalised: `asset.status` becomes `"assigned"` / `"idle"`, never the invalid `"in_use"` literal (which would have escaped every consumer of `ASSET_IN_USE_STATUSES`). DNS request 4-eyes admin recovery — explicit bypass + retry. The server-side 4-eyes guard in [`dns_request_service.py::_is_self_approval`](../../server/app/domains/cmdb/dns_request_service.py) (Round-30 hardening) was designed to fail-closed when the requester also appears as an APPROVED approver on the Feishu instance, but in a single-admin shop or after a template misconfig that path leaves the row stuck at `status='failed'` with no recovery surface short of "fix the Feishu template + ask the user to re-fill the form". Two new admin-only paths fill the gap: (a) `POST /cmdb/dns-record-requests/{id}/bypass-execute` — `can_manage_cloud` (global_admin / cloud_admin) only, requires `reason ≥ 20 chars`, gates on `status='failed'` + `error_message` containing the 4-eyes fragment, flips the row to `approved` and runs the standard cloud-write path; audits `cmdb.dns_request.bypassed_4_eyes_execute` with the verbatim reason + original error_message; fans out a Feishu + email notification to EVERY other admin (global_admin ∪ cloud_admin ∪ it_admin, excluding the bypasser) so the security exception is peer-visible. (b) `POST /cmdb/dns-record-requests/{id}/retry` — same admin gate + same precondition, but instead of bypassing it re-opens a fresh Feishu approval instance under the original requester's identity (form data taken from the durable row); the new flow goes through the (now hopefully fixed) template's approver chain as normal and the 4-eyes guard fires again on the new callback. The bypass path is intentionally limited to 4-eyes failures only — cloud-write failures cannot be bypassed because that would risk double-firing the provider call. Frontend: `MyDnsRequestsHistory` detail drawer renders a warning Alert with two CTAs when the caller `canManageCloud()` AND the row matches the 4-eyes-failure signature; bypass triggers a Modal requiring reason ≥20 chars + explicit "通知所有其他管理员" acknowledgement checkbox + `confirmLoading` re-entry guard. MCP gets two matching tools `bypass_4_eyes_dns_request(request_id, reason)` + `retry_blocked_dns_request(request_id)`; `_REGISTERED_TOOLS_COUNT_HINT` 168 → 170. 0 migration, 0 cron, 0 driver, 0 RBAC change (reuses `can_manage_cloud`). 9 pytest regression tests in [`test_dns_request_admin_recovery.py`](../../server/tests/test_cmdb/test_dns_request_admin_recovery.py) pin: non-admin 403 on both, short-reason 422 on bypass, non-4-eyes failure 409 on both, bypass happy-path (row status flip + audit + notify), notification recipient set excludes the bypasser, retry opens new Feishu instance + creates new ApprovalRequest row + preserves the old ApprovalRequest.external_id for audit, retry refused when the zone's `allow_dns_request` has been flipped off since.)
> **Earlier same-day** (2026-05-22: Resource-creator attribution via cloud audit logs. New table `cmdb_resource_creators` (migration 198) caches per-resource "who created this" attribution resolved via the four cloud audit-log APIs: AWS CloudTrail `LookupEvents` (90-day default), GCP Cloud Audit Logs `entries.list` (400-day default), Aliyun ActionTrail `LookupEvents` (90-day), Azure Activity Log `eventtypes/management/values` (90-day). `CloudDriver` base method `get_resource_creator(resource_id, *, resource_type, scope)` returns a new `ResourceCreatorInfo` dataclass; drivers without audit-log support raise `NotImplementedError` and the service layer maps that to terminal `status='unsupported'`. AWS / GCP / Aliyun / Azure drivers all ship the method in the same PR — AWS event-name allowlist covers ~40 services (EC2 / EBS / S3 / RDS / Lambda / DynamoDB / ElastiCache / ALB / CloudFront / Route53 / IAM / SNS / SQS / KMS / Secrets / EKS / ECS / Redshift / DMS / API Gateway / SageMaker / MSK / Glue / CloudFormation), resource-id → ResourceName extraction handles ARN-shape, S3 bucket ARN special case, and synthetic ids from the BQ-CUR ingest path short-circuit to None. SSO assumed-role ARNs (`arn:aws:sts::<acct>:assumed-role/AWSReservedSSO_X_Y/<email>`) extract the email after the trailing slash — feeds the `creator_email` → `identity_persons.work_email` (case-insensitive) match that stamps `creator_person_id`. Service layer `resolve_resource_creator` does ON DUPLICATE KEY UPDATE upsert keyed on `(integration_id, resource_id)` + caches terminal statuses (`resolved`/`not_found`/`window_exceeded`/`unsupported`) so the cron sweep stops retrying; `error` status retries up to attempt_count=5. New HTTP route `GET /cmdb/cost/resources/{integration_id}/{resource_id:path}/creator?refresh=bool` (RBAC `can_view_finance OR can_read_cloud`, writes a `cmdb.resource_creator.lookup` audit row each call). New cron `cmdb.resource_creator_sweep` (default disabled — operator enables per-integration once audit-log SA/IAM perms confirmed) walks newly-discovered `cmdb_cost_facts.resource_id` rows missing a cache row, throttled to 1 req/s (under CloudTrail's 2 req/s cap). New MCP tool `cost_resolve_resource_creator` (HTTP twin); `_REGISTERED_TOOLS_COUNT_HINT` 173 → 174. Frontend: new `web/src/components/cloud/ResourceCreatorSection.tsx` mounted in `CloudCostAnalysisPage` resource drawer — fires resolve on drawer open, renders one of 5 states (resolved Descriptions with email-copyable + "Open profile" link to matched Person; not_found / window_exceeded / unsupported / pending info Alerts; error Alert with last_error + "Re-resolve" button). zh/en/ja i18n complete (~20 new keys). 18 new tests: 13 service-layer (happy path, cache hit, force refresh, None→not_found, window_exceeded, unsupported, transient-error attempt counting, missing-email leaves person_id NULL, case-insensitive email match, integration-not-found LookupError, cache miss returns None, cron skips synthetic ids, cron skips terminal-status rows) + 5 driver-side event parsers (AWS SSO email extraction, GCP principalEmail, Aliyun PrincipalId+UserName, Azure caller+claims.oid, AWS resource-id extractor synthetic-id short-circuit). Migration 198 only adds a new table + 3 indexes + 2 FKs (`integrations` RESTRICT, `identity_persons` SET NULL) — zero touched columns on existing tables. Pairs with the 2026-05-22 tag-surfacing work (PR #457): tags answer "who set the Owner tag?", creators answer "who actually clicked Create?" — together they cover the full attribution surface.)
>
> **Earlier same-day** (Cloud-cost dashboard — surface resource tags + tag-filter. CUR 2.0 (`tag_schema='nested'`, used by every PingCAP-prod AWS billing pipeline) tag harvest broadened from a narrow 5-key allowlist (`Owner`/`Application`/`Environment`/`Project`/`Department`) to the full `resource_tags.key_value` ARRAY via one `ANY_VALUE` projection — every user-defined tag the customer attached now lands in `CmdbCostFact.tags` going forward. CUR 1.0 (`flat`) path unchanged (per-account schema heterogeneity makes dynamic flat-column discovery expensive and no current prod tenant is on CUR 1.0). Per-fact cap (50 keys × 256 chars / value) is defence-in-depth — AWS already caps at 50 tags/resource. **Dashboard surfacing**: `cost_service.top_resources()` + `scope_billing_detail._top_resources()` both pull the latest non-empty `tags` dict per (integration_id, resource_id) via a third pass (JSON columns can't be MAX()'d cleanly across MySQL + TiDB; bounded by topN × window-days rows). Frontend: `CloudCostAnalysisPage` ResourcesTab gains a Tags column (Popover with all tags + click-to-filter), Drawer renders the full tag dict as a Descriptions block with per-tag filter button; `ScopeBillingPanel` mirror gains the same Tags column. **Tag filter**: `GET /cmdb/cost/resources?tag_key=K&tag_value=V` (both-or-neither; backend validates `tag_key` against `^[A-Za-z0-9_\-.:/+@]{1,128}$` to prevent JSON-path injection through the interpolated `JSON_EXTRACT(tags, '$."K"')` predicate; `tag_value` capped at 256 chars). **Tag-key discovery**: new `GET /cmdb/cost/tag-keys` returns the observed tag keys in the cost window sorted by per-key fact count — powers the AutoComplete dropdown. MCP: one new tool `cost_tag_keys` + signature change on `cost_top_resources` (new `tag_key`/`tag_value` params, `tags` field on every row); `_REGISTERED_TOOLS_COUNT_HINT` 172 → 173. 0 migration (column already exists), 0 cron, 0 RBAC. Answers the "who created this resource?" attribution question from existing bill data without a CloudTrail lookup — works for the GCP / Aliyun / Azure drivers too (their cost-facts ingest already harvests the full tag/label dict).
>
> **Earlier same-day** (Round 44 — pre-launch comprehensive code-review fix wave: 5 CRITICAL + 19 HIGH + 7 MEDIUM across 31 files. No new abstractions / drivers / cron jobs / RBAC roles / MCP tools (`_REGISTERED_TOOLS_COUNT_HINT` unchanged). Platform-shape highlights: (1) `dispose_asset` service-method signature gained `allow_held_by_leaver: bool = False` kwarg — moved off the Pydantic schema (now `extra="forbid"`) so external API callers cannot smuggle it; the buyback auto-execute in `approval/service.py::_auto_execute_approved_request` passes it explicitly. (2) `app/main.py` now mounts a `StarletteHTTPException` handler that wraps bare `HTTPException(...)` into the standard `{success, data, meta, errors}` envelope AND filters response headers through the same `_SAFE_RESPONSE_HEADER_KEYS` allowlist used by `app_exception_handler` (only `retry-after` / `www-authenticate` / `allow` pass through). (3) `core/dependencies.py` `check_login_rate_limit` + `check_refresh_rate_limit` now raise `AppException` (was bare `HTTPException`) so the frontend can read `errors[0].message`. (4) `worker.py::_run_scheduled_job` now writes a synthetic `ScheduledJobRun(status='failed', triggered_by='scheduler')` + fires `maybe_notify_failure` when `execute_job` raises BEFORE its inner wrapper takes over — closes the wrapper-bypass silent-failure window (previously the watchdog was the only signal). (5) New private helper `_record_owner_force_transfer_audit` in `hr_lifecycle/advance_service.py` writes `audit_logs` rows for force-transfer of cloud_scope_owner / cloud_iam_user / software_owner / software_it_contact / domain_registration / google_orphan_owner — these 6 branches previously rewrote the owner FK silently. (6) Feishu v2 webhook driver `verify_webhook` now SETNX's `feishu:webhook_replay:{nonce}:{ts}` in Redis (TTL 600s) — closes the in-±300s-window replay gap. (7) `cmdb/iam_upsert.py::upsert_local_iam_user` pre-flight refuses 409 `IAM_USER_PREVIOUSLY_REVOKED` when the `(integration_id, user_name, sub_account_id)` row exists with `revoked_at IS NOT NULL` — pairs with PR #196's scope-side asymmetric-tombstone fix. (8) Manager-chain walk depth in `me_universe.assert_can_view` bumped 4 → 6 (matches the `permission/router.py::list_grants` explicit depth-6 call) so a CEO can view 5+ levels of subordinates in a 500-person org; same depth-6 widening applied to `permission/router.py` cross-user `?user_id=` grant reads. (9) Cron handlers `relationships.full_sync` / `relationships.incremental_sync` / `sso.execute_pending_google_grants` / `identity_sync.dispatch` now `raise RuntimeError` when every source / target failed — fixes the "scheduled_job_runs.status='ok' while nothing got done" class. (10) `failure_notifier` card body now scrubs credential-shaped substrings via `_scrub_message`. (11) `audit/router.py` — `/export` requires at least one scoping filter and writes a self-audit `audit.export` row; `/logs` gets `check_directory_read_rate_limit`. (12) Borrow-return path canonicalised: `asset.status` becomes `"assigned"` / `"idle"`, never the invalid `"in_use"` literal (which would have escaped every consumer of `ASSET_IN_USE_STATUSES`). DNS request 4-eyes admin recovery — explicit bypass + retry. The server-side 4-eyes guard in [`dns_request_service.py::_is_self_approval`](../../server/app/domains/cmdb/dns_request_service.py) (Round-30 hardening) was designed to fail-closed when the requester also appears as an APPROVED approver on the Feishu instance, but in a single-admin shop or after a template misconfig that path leaves the row stuck at `status='failed'` with no recovery surface short of "fix the Feishu template + ask the user to re-fill the form". Two new admin-only paths fill the gap: (a) `POST /cmdb/dns-record-requests/{id}/bypass-execute` — `can_manage_cloud` (global_admin / cloud_admin) only, requires `reason ≥ 20 chars`, gates on `status='failed'` + `error_message` containing the 4-eyes fragment, flips the row to `approved` and runs the standard cloud-write path; audits `cmdb.dns_request.bypassed_4_eyes_execute` with the verbatim reason + original error_message; fans out a Feishu + email notification to EVERY other admin (global_admin ∪ cloud_admin ∪ it_admin, excluding the bypasser) so the security exception is peer-visible. (b) `POST /cmdb/dns-record-requests/{id}/retry` — same admin gate + same precondition, but instead of bypassing it re-opens a fresh Feishu approval instance under the original requester's identity (form data taken from the durable row); the new flow goes through the (now hopefully fixed) template's approver chain as normal and the 4-eyes guard fires again on the new callback. The bypass path is intentionally limited to 4-eyes failures only — cloud-write failures cannot be bypassed because that would risk double-firing the provider call. Frontend: `MyDnsRequestsHistory` detail drawer renders a warning Alert with two CTAs when the caller `canManageCloud()` AND the row matches the 4-eyes-failure signature; bypass triggers a Modal requiring reason ≥20 chars + explicit "通知所有其他管理员" acknowledgement checkbox + `confirmLoading` re-entry guard. MCP gets two matching tools `bypass_4_eyes_dns_request(request_id, reason)` + `retry_blocked_dns_request(request_id)`; `_REGISTERED_TOOLS_COUNT_HINT` 168 → 170. 0 migration, 0 cron, 0 driver, 0 RBAC change (reuses `can_manage_cloud`). 9 pytest regression tests in [`test_dns_request_admin_recovery.py`](../../server/tests/test_cmdb/test_dns_request_admin_recovery.py) pin: non-admin 403 on both, short-reason 422 on bypass, non-4-eyes failure 409 on both, bypass happy-path (row status flip + audit + notify), notification recipient set excludes the bypasser, retry opens new Feishu instance + creates new ApprovalRequest row + preserves the old ApprovalRequest.external_id for audit, retry refused when the zone's `allow_dns_request` has been flipped off since.)

> **Earlier same-day** (2026-05-22: Empty-`str(exc)` observability bug-class swept across every cron / scheduled-job + cost-facts / billing / IAM / grant code path — canonicalised on `app.core.exceptions.format_exception_for_log` already in production in provisioning + offboarding + hr_lifecycle + leaver_blocked. Triggered by the Azure `cmdb.cost_facts_sync` partial_failure notifier `cmdb.cost_facts_sync <int_id> partial_failure: <scope_id>: ` (bare colon, zero exception-type info) on 2026-05-21. Root cause is broader than the original site: any `f"...: {exc}"` falls back to `str(exc)` which returns the empty string for httpx transport-level exceptions (ReadTimeout / RemoteProtocolError / ConnectError) AND for every `AppException`-derived raise (the class deliberately leaves `Exception.args` empty so `.message` / `.code` are the only carriers). Pre-fix the executor itself (`scheduled_jobs/service.py:806`) used `str(exc)[:4096]` for `scheduled_job_runs.error_message`, so ANY handler whose top-level raise was an httpx transport error or AppException landed an empty `error_message`, an empty failure-notifier card body, AND a dedup signature that collapsed every kind of empty-message failure onto one signature (so the SECOND occurrence of any empty-message kind was silenced by the dedup gate). Fix replaces `str(exc)` / `f"...: {exc}"` with `format_exception_for_log(exc)` at every error-list append, raise-payload composition, `error_message` write, audit-detail write, and result-dict `reason` in the cron / scheduled-jobs / cmdb-lifecycle / cost-facts surface — a total of ~30 sites across `scheduled_jobs/service.py`, `scheduled_jobs/handlers.py`, `worker.py`, `scheduled_jobs/{ai_spending,bailian,jira,slack,zoom}_handlers.py`, `domains/cmdb/cost_service.py` (driver-load / FX / BQ / per-scope), `domains/cmdb/lifecycle_service.py` (notify / revoke / roles_sync / grants_sync / billing_by_dimension / reconciliation_pass / reconciliation_savepoint / disable / grant / billing_daily_precheck / BQ misconfig). `bailian_handlers.py`'s pre-existing two-branch (AppException vs unexpected) pattern collapsed to one `format_exception_for_log(exc)` call which handles both shapes natively. Same PR adds per-scope OK/failed/total counts to the `cmdb.cost_facts_sync` raise message — the failure-notifier card now surfaces blast radius even when `result_summary` lands null because the handler must raise per `feedback_handler_status_must_raise`; the counts suffix is suppressed on early-return paths (`driver load failed` / `FX rate unavailable` / `BQ misconfigured`) where `per_scope` is empty by design. 3 new regression tests in `test_sync_cost_facts_renders_empty_str_exception_with_type` + `test_handler_partial_failure_message_includes_scope_counts` + `test_handler_early_failure_omits_empty_scope_counts` pin the contracts. Verified end-to-end: 2026-05-21 prod Azure incident manually re-ran on the worker container (run id `73d5a85e-be55-4bfe-bc96-eb78ebd2d56b`, 6 min 37 s, all 38 scopes ok, 119,011 facts upserted including the previously-failing 7bc8ab17 PingCAP Azure OpenAI scope at 37 facts). Failure-notifier dedup signatures will now differ per exception class (httpx.ReadTimeout vs AppException vs RuntimeError) instead of collapsing all empty-message failures onto one bucket — operator gets one card per kind of failure, not one card per integration. 0 migration, 0 cron, 0 RBAC, 0 driver, 0 MCP tool, 0 frontend — pure backend observability fix.)
>
> **Earlier** (2026-05-21: `cmdb_cloud_scopes` decommissioned-scope resurrection bug fixed end-to-end. Symptom: 10 GCP org-root projects decommissioned in the 2026-05-18 bulk takedown reappeared in the 订阅列表 tab with `status='decommissioned'` (legacy literal, NOT canonical `'closed'`). Two-layer fix: (a) [`sync_scopes_for_integration`](../../server/app/domains/cmdb/lifecycle_service.py) `on_duplicate_key_update` now gates the `deleted_at` un-soft-delete on `decommissioned_at IS NULL` via a MySQL `CASE WHEN` — admin-set decommission tombstones survive re-discovery, behaviour parity with the existing "don't overwrite admin-set `status`" rule. Pre-fix, ANY driver re-listing of a decommissioned scope cleared `deleted_at` while `status` / `decommissioned_at` stayed put → half-revived row bypassed both the default `status != 'closed'` and the secondary `deleted_at IS NULL` list filters. (b) [Migration 196](../../server/alembic/versions/196_normalize_cmdb_scope_decommissioned_literal.py) is a forward-only data normalization: every row with the legacy `status='decommissioned'` literal AND `decommissioned_at IS NOT NULL` AND `deleted_at IS NULL` gets re-soft-deleted (`deleted_at = NOW()`); every row with the legacy literal AND `decommissioned_at IS NOT NULL` gets normalized to `status='closed'` so the default list filter excludes them going forward. Migration is literal-scoped (predicate `status='decommissioned'`) — canonical `status='closed'` rows from the supported lifecycle path are untouched and stay visible in the 已下线 tab. The 2026-05-21 audit confirmed only GCP carried the legacy literal in prod (AWS / Azure / Aliyun / Tencent decommission paths have always landed canonical `'closed'`), and `grep` confirmed no app-code writes the `'decommissioned'` literal anywhere — so the literal-scoped predicate is exactly the leaker set, with no need for an integration join. Audit columns (`decommissioned_at` / `_by` / `_reason`) untouched — takedown trail preserved. 3 regression tests in [`test_scope_sync_decommissioned_resurrection.py`](../../server/tests/test_cmdb/test_scope_sync_decommissioned_resurrection.py) pin: (1) decommissioned scope re-listed by driver keeps `deleted_at`; (2) non-decommissioned soft-deleted scope re-listed STILL un-soft-deletes (legitimate re-discovery path preserved); (3) legacy `'decommissioned'` literal also protected (defence-in-depth — code is resilient even before migration runs). Migration head 195 → 196. 0 MCP, 0 RBAC, 0 driver, 0 cron, 0 frontend. Bug class is specific to `cmdb_cloud_scopes` because that's the only table with a `decommissioned_at` column — the same `deleted_at=None` pattern in `sync.py` for resources / DNS / domains / LB / CDN / IAM is unaffected.)
>
> **Earlier** (2026-05-20: Object-storage surface + asset-inventory employee experience polish. Introduces a generic S3 abstraction backed by [`server/app/core/storage.py`](../../server/app/core/storage.py) (boto3 client cache + `resolve_storage_config` reading `platform_settings.storage.s3.{bucket,region,key_prefix}`) and a thin HTTP surface [`server/app/storage_router.py`](../../server/app/storage_router.py) exposing `POST /api/v1/uploads/presign-{put,get}` — `purpose` enum + per-purpose object-key prefix + Allowed-Content-Type allowlist; per-user prefix in the object key for audit. Runtime guard: `APP_ENV != "production"` force-overrides `key_prefix` to `"dev/"` so a local dev DB sharing the prod bucket can never write into the prod top-level namespace. Bucket = `helix-prod-uploads-ap-southeast-1` (account 239023060546, ap-southeast-1, BucketOwnerEnforced + SSE-S3 + versioning + Block-Public; lifecycle: abort-multipart 7d, noncurrent→IA at 30d→expire 365d, `dev/` prefix expire 7d). Prod access via the existing `helix-prod-ec2-role` instance profile (inline policy `HelixProdUploadsAccess`) — no access key in env. Migration 194 seeds the three settings rows. Two matching MCP tools `presign_upload_put` / `presign_upload_get` (MCP-sync rule); `_REGISTERED_TOOLS_COUNT_HINT` 166 → 168. **Asset inventory uses this** for the new `response_type='not_mine'` evidence-photo requirement: migration 195 adds `asset_inventory_items.evidence_photos JSON NULL`; `ItemRespondRequest` rejects `not_mine` without ≥1 photo (≤10); service drops any caller-supplied photos on non-`not_mine` branches to avoid orphan keys. Admin's ResolveModal renders the keys as a presigned-GET Image.PreviewGroup. **Employee workbench entry** — `MyTodosPage` gains a 5th tab "资产盘点" (cycles list with per-row pending badge + 进入 button → `/me/inventory/{cycleId}`); `DashboardPage` employee alerts panel adds a warning Alert when `inventoryPending > 0` linking to `/me/inventory`. **Confirm modal SN highlight** — bold + red + light-red background on the SN value plus 「⚠️ 请仔细核对 SN 号是否与实物一致」 hint so holders can't blindly accept a wrong SN. **Draft cycle deletion** — `AssetInventoryAdminPage` gains an `actions` column with a delete button gated on `status==='draft'` (backend `DELETE /asset-inventory/cycles/{id}` already existed). **Feishu card footer dedup** — `_build_interactive_card` strips a trailing `"— Helix IT 平台"` / `"— Helix IT Platform"` from `body_text` before rendering so the card no longer shows two footers (the inline body signature plus the dedicated footer note). Email channel still keeps its inline signature — only the Feishu card path strips. Migrations 194 + 195; MCP +2 tools; 0 new cron; 0 RBAC change.)
>
> **Earlier same-day** (`management` backend read-coverage completion — closes the 403 holes the menu matrix realignment exposed. New read-only dependency family in [server/app/core/dependencies.py](../../server/app/core/dependencies.py): `require_admin_read` (global_admin + auditor + management), `require_hr_read` (+hr), `require_asset_read` (+asset_admin), `require_foundation_read` (+asset_admin + hr); `require_audit_access` extended to include management. 88 GET endpoints switched from write-side `require_*_admin` / `require_admin` to the matching `require_*_read`, plus 3 inline `has_role(..., GLOBAL_ADMIN_ROLE)` checks extended with `MANAGEMENT_ROLE` on cross-user reads (provisioning task list, permission grant list, IT-宇宙 view-mode). `/cmdb/overview/admin` inline gate switched from `can_manage_cloud OR FINANCE_ROLE` to `can_read_cloud OR can_view_finance` (both helpers already include management + auditor). `apikey/service.py` widens its 5 `is_admin = can_manage_assets(...)` read-paths to also admit MANAGEMENT / AUDITOR. MCP `list_software_seat_cancellations` + `list_provisioning_tasks` mirror their HTTP twins. Write surface 0-changed — `can_manage_*` helpers untouched, `require_admin()` / `require_*_admin()` retained on every POST/PUT/PATCH/DELETE. Compliance incidents tools stay global_admin-only (PII). The 7 admin-broadcast notification fan-outs (`("global_admin","it_admin")`) untouched — management is not paged by routine ops. 0 migration, 0 cron, 0 driver. Verified via scripted scan: 0 read-gate helpers landed on non-GET endpoints, 0 `can_manage_*` helper acquired MANAGEMENT.)
>
> **Earlier same-day** (Role ↔ menu matrix realignment — `cloud_admin` promoted from perpetual-grant marker to a real cloud + network write authority (backend `can_manage_cloud` admits it; frontend gains a `cloud_admin` ViewRole that renders only the cloud + network admin menus; every `/admin/cloud/*` + `/admin/network/*` route in `App.tsx` opt-s it in; the perpetual-grant marker semantic survives via the unchanged `is_cloud_admin` helper). `RoleRoute` auto-promotion for `management` broadened from "global_admin-only routes" to "any admin-tier route" (global_admin / asset_admin / hr / finance / cloud_admin) so the read-only leadership view actually reaches asset / HR / finance admin pages, not just the global_admin ones. Backend `can_manage_*` helpers unchanged, so management writes still 403. The dynamic AI-Spending wedge that injected `/admin/ai-spending` into the menu for finance users in non-finance views was removed — menus shouldn't mutate based on a hidden state; finance users go through the role chip. `/help` ↦ Quick Start tab gains a `#permission-matrix` section: 11-row × 7-column color-coded table (W / R / M / —) cross-referenced with the existing role cards, plus a new `management` card. zh-CN + en-US bundles synchronised; ja-JP gains the `role.cloudAdmin` chip label only (rest falls back to en-US per existing coverage). 0 migration, 0 MCP, 0 driver — RBAC-only PR.)
>
> **Earlier same-day** (2026-05-20, API Key lifecycle pre-launch hardening — 14 critical / high findings across 4 review rounds (same-bug-class sweep + business review + security scan + logic scan), fixed in one consolidation PR. Highlights: AOAI revoke was reading `aoai["account_name"]` while autograny wrote `foundry_account_name` → **every Azure OpenAI revoke silently skipped cloud teardown**, account stayed live + billing meter kept running; Bailian RAM teardown was passing `expected_provider="aliyun"` while Bailian autograny only ever uses `alicloud_intl` → **every Bailian RAM teardown failed** with `CLOUD_REVOKE_FAILED` 502, sk- key DELETE worked but RAM sub-account orphaned forever; `run_apikey_budget_check` cron `async with async_session_factory()` block was missing the trailing `await session.commit()` so every `ApiKeyBudgetAlertsDedup` SAVEPOINT row was rolled back on session close → **every 80% / 100% threshold re-fired every morning all month**; `OffboardingService._initiator_still_holds_asset` for `asset_type='api_key'` only compared `assigned_to == initiator_id` (no status check) → a pending transfer accepted AFTER source revoke would flip a `revoked` row's assigned_to to the recipient, retag a deleted IAM user, re-point a disabled cost-allocation rule; `approve_release_request` couldn't survive a concurrent transfer (between submit + approve) — `revoke_key`'s `self_with_approval` branch 403'd on stale assignee identity, rolled the whole transaction back, left the row stuck in `pending_release` forever; `bailian_autogrant` 4 error paths left `in_flight=True` permanently, wedging the row past any future retry; `fill_key` / `reissue_bailian_api_key` / `rebind_iam_user` lacked row locks (concurrent admin actions could double-mint cloud resources / silently overwrite key_value); `apply_transfer` + `extend_validity` didn't check the recipient/assignee's `employment_status` (re-arming credentials for leavers). All fixed in same PR. Migration 193 also added missing FK constraints on `api_key_assignments.revoked_by` + `.transferred_from` that PR #408's 189/190 forgot to declare (ORM said `ondelete=SET NULL`, DB had no FK — hard-deleted persons would leave dangling refs). Frontend gap closed: `MyApiKeysPage` now has Reveal + Extend buttons (the expiry-warning email's "续期" CTA finally has a landing button) + admin revoke gate widened to `active || expired`. See ROADMAP.md for full 14-item digest. The fixes also propagated same-class bugs out of apikey scope — two `[Cancel][Cancel]` Modal collisions on `OffboardingProcessDetailPage` (same bug class as the en-US `apikey.release.cancelConfirmOk = 'Cancel'` collision PR #408 shipped) plus the `release_my_api_key` MCP tool description (still saying "via Feishu approval card" after PR #417 rewrote the flow to Helix-internal).
>
> **Earlier same-day**: Asset admin gets a unified **「变更持有」** entry — one Modal routes (current_status × target_mode) onto the already-existing lifecycle endpoints (`/claim` / `/change-assignee` / `/recycle-to-office` / `/release-from-office` / `/return` / `/mark-held-by-leaver`). No new backend route, no schema change, no MCP. Built to give admins a single fix-it surface for the cross-mode corrections that motivated this discussion (e.g. "Meng Fansheng's laptop should have been office-held, not held by him") without re-opening the deliberately-closed door of `PATCH /assets/{id} body.status=…` (the audit-chain-bypass that [models.py:224-230](../../server/app/domains/asset/models.py:224) explicitly forbids). Source-side blocks render an Alert (held_by_leaver → use "找回离职资产" first; borrowed / pending workflow / maintenance / disposed → wait for the existing flow to finish). Component: [web/src/pages/asset/ChangeHolderModal.tsx](../../web/src/pages/asset/ChangeHolderModal.tsx); entry points on `AssetDetailPage.tsx` (primary action bar) + `AssetListPage.tsx` (per-row dropdown). Required `reason` field flows into the underlying endpoint's `notes`/`reason`, so the audit row carries the admin's correction intent.
>
> **Earlier same-day** (2026-05-19, `google.group_sync` total-failure gate — steady-state false-positive fixed + Round-42 mutation-counter conflation tightened. 0 MCP, 0 RBAC, 0 driver, 0 migration.):
>
> - **Why** — Round-42's `raise_if_total_failure` progress_keys discipline dropped fetch counters (`groups_listed`) from progress so a 200-OK fetch followed by every-write-failing wouldn't silently green-light. Side-effect: on a steady-state tenant (PingCAP prod, 742 groups, hourly run with `created/updated/soft_deleted = 0/0/0`) a single defensive `owners fetch indeterminate` skip (one group with a 403 on the members endpoint — `list_group_owners` returns `None` by design to avoid blanking owner links) false-fired the gate as `"all 1 unit(s) of work failed and no progress recorded"`. Real prod failure at 2026-05-19 10:17 UTC on `ibg-mktcommunity-ccm@pingcap.com`. Round-1 self-review (per "max prod-grade discipline" instruction) surfaced a second bug: Round-42 also conflated "work attempted" with "work succeeded" — `created/updated` count property mutations that happen BEFORE owner reconcile, so a partial-greenfield credentials rotation (e.g. 5 new groups created + 742 owners-fetches return `None`) would silently green-light despite a system-wide failure. A third bug: the 50% owner-removal defensive refusal appends to `errors` but falls through instead of `continue`-ing, so a 100% paginator-truncation event would log N errors AND increment a naive per-group counter N times — still bypassing the gate.
> - **Where** — [google_handlers.py::run_google_group_sync](../../server/app/scheduled_jobs/google_handlers.py): adds `groups_reconciled_ok` (per-group "made it all the way through" counter, gated on `errors_before == len(errors)` snapshot so defensive-refusal fall-through paths don't inflate it). `progress_keys` tightened from `("created", "updated", "soft_deleted")` → `("groups_reconciled_ok", "soft_deleted")` — mutation counters dropped because they fire before the owner-reconcile work that's the actual failure mode. `soft_deleted` stays (out-of-loop work; a pure soft-delete tick with no listings is legitimate progress). Summary schema gains the new key on all three return paths (driver-None / GoogleApiError / success) for shape consistency; also fixes a stale `owner_links` key in the driver-None branch (rest of handler uses `new_owner_links`). Frontend type in [emailGroup.ts](../../web/src/api/emailGroup.ts) updated to match. 9 scenario gate tests pass: today's 741/1, creds rotation, 100% paginator-truncation, mixed, clean steady state, greenfield, mid-life rotation, pure soft-delete, soft-delete+1 error.
>
> **Earlier today** (2026-05-19, CMDB sweep guard drop tolerance promoted to a platform_settings runtime knob — admin-editable, no worker restart. Migration 182 (head: 181 → 182). 0 MCP, 0 RBAC, 0 driver.):
>
> - **Why** — multi-region/sub-account clouds drop 5–15% of rows when one region/leg flaps; the hardcoded 5% floor locked `cmdb.resource_sync` / `scopes_sync` / `roles_sync` for hours per cron tick and re-paged failure-notifier on every miss. User-requested upgrade path (out of two: `.env` vs platform_settings) was platform_settings so ops can hot-tweak during an incident without a redeploy.
> - **Where** —
>   * [migration 182](../../server/alembic/versions/182_seed_cmdb_sync_sweep_drop_tolerance.py) seeds `platform_settings['cmdb_sync.sweep_drop_tolerance_percent'] = 20` (idempotent `ON DUPLICATE KEY UPDATE description` — never overwrites admin-edited value).
>   * [sweep_settings.py](../../server/app/domains/cmdb/sweep_settings.py) — `async def get_sweep_drop_tolerance_percent(db)` is the canonical resolver. DB-first, falls back to `settings.CMDB_SYNC_SWEEP_DROP_TOLERANCE_PERCENT` on missing/malformed row. Plausibility band `[0, 100]`; out-of-band values trigger fallback (never raises, never returns a value outside the band).
>   * Three call sites: [sync.py::_safe_sweep](../../server/app/domains/cmdb/sync.py) (resource sweep), [lifecycle_service.py::sync_scopes_for_integration](../../server/app/domains/cmdb/lifecycle_service.py) (scope close-sweep), [lifecycle_service.py::sync_roles_for_integration](../../server/app/domains/cmdb/lifecycle_service.py) (role soft-delete sweep). All three `await` the resolver each tick (cheap pre-check `upserted < baseline` short-circuits the healthy path so resolver only hits DB when there's an actual drop) — single source of truth, no caching.
> - **`.env` fallback still in place** — `settings.CMDB_SYNC_SWEEP_DROP_TOLERANCE_PERCENT` (pydantic-validated `[0, 100]`, default 20) is the bootstrap default before migration 182 lands AND the resilient fallback if an admin accidentally deletes the platform_settings row. Deliberate belt-and-braces: a fresh DB or a deleted row must NEVER silently revert to the legacy 5% behaviour.
> - **Operator UX** — admin edits the row via the existing Platform Settings page ([web/src/pages/admin/PlatformSettingsPage.tsx](../../web/src/pages/admin/PlatformSettingsPage.tsx)). No dedicated UI for this single knob; the generic JSON editor is enough until a product case for "slider + last-trip telemetry" arrives. Effective on the next sweep call (typically <1 min, bounded by the cron's sleep + the next integration tick).
> - **Test pinning** — [test_sweep_settings_resolver.py](../../server/tests/test_cmdb/test_sweep_settings_resolver.py): unit + integration cases covering missing row, well-formed int, numeric string / whole-float coercion, bool / non-int / out-of-band / nested-structure fallback, band-boundary (0 and 100) acceptance, AND end-to-end proof that a platform_setting edit actually moves the trip floor. Plus existing sweep-guard / cloud-sync-integration tests stay green (61 total pass).
> - **Audit surface for `force_sweep` override** — `forced_meta` now carries `forced_sweep_tolerance_percent` on the drop_ratio branch (not the zero_rows branch — tolerance is irrelevant there), so a future audit can reconstruct the active threshold at trip time even if the platform_setting changes afterward.
>
> **`SuccessFactorsClient._get_json` 瞬时传输重试** (2026-06-04) — 每页 OData GET 现做有界重试(原 +2 次,指数退避 1s/2s 封顶 30s),catch `_TRANSPORT_RETRY_EXCEPTIONS`(ConnectError/ConnectTimeout/ReadError/ReadTimeout/WriteError/WriteTimeout/PoolTimeout/RemoteProtocolError,镜像 `azure_cloud` 同款集合)。每次重建 request + 重跑 `auth.apply`,auth 在 try 内(token 拉取抖动一并重试);耗尽后原样上抛保留原始 `ReadTimeout` traceback。HTTP 4xx/5xx 仍走 `SuccessFactorsHTTPError` 不重试。**触发**:prod 2026-06-04 06:00 `hris.absent_person_check` CN 租户全量 `EmpJob` 拉取单页 `httpx.ReadTimeout`,DE 同 tick 成功 → 瞬时传输问题,1 次重试即可救。同 PR 把 `scheduled_jobs/handlers.py` 6 处失败摘要 `str(exc)[:N]`(对 `AppException` / httpx 超时都返回空串)统一换成 `format_exception_for_log(exc)`,失败通知不再出现 `'error': ''`。受益所有 SF 调用方(增量 / 全量 / 主数据)。
>
> **同一条重试路径 2026-09-02 加了唯一一个 HTTP 状态例外:SF 自己的鉴权网关 401。** `hr.pending_leaver_notice` 07:00 UTC 那一跳红在 `401 AuthenticationFailed: Authentication service failed to return token.`(`api55.sapsf.eu/odata/v2/EmpEmployment`)—— 这句话是 **SF 的网关说它没能从 IAS 拿到 token**,不是对我们发的 bearer 的判决。判据是瞬时而非凭据失效:同一跳里 CN 租户几秒前刚成功、02:00–06:00 每跳都正常读到 EU 租户的 3 条、4 分钟后在生产容器里现场探测两个租户各连读 3 次全过(token 200 / 0.98s),且这是 30 天 `scheduled_job_runs` 里唯一一次 SF 401。所以 `_get_json`/`_post_json` 共用的新 `_send_with_retries` 把它并入原有的传输重试预算(共 3 次,1s/2s 退避);**其余 4xx/5xx 一律第一次响应就抛** —— 确定性的 400 重试三遍只是慢三倍的 400。凭据真的被吊销时仍然只是多花 ~3s 后照常变红。同 PR 收紧 `get_termination_reasons_for_users` 的 `$select`/`$expand` 降级:只有 **400** 才算「租户拒绝这个形状」,401/403/5xx 直接上抛 —— 否则它会拿两个更窄的形状去撞同一个坏端点,最后报最窄那次的失败,把 run 行的锅甩给一个从来没出问题的 `$select`。回归:[`test_sf_transient_auth_retry_2026_09_02.py`](../../server/tests/test_identity/test_sf_transient_auth_retry_2026_09_02.py)(形状那条不能只数调用次数 —— 3 形状 x 1 次 == 1 形状 x 3 次,必须断言每次请求仍带最宽的 `$expand`)。

> **Earlier same-day** (2026-05-23, Round 47 — HRIS absent-person-check 修复 + 同模块全面扫):
>
> - **`SuccessFactorsClient.list_current_jobs` 修复 OData 400 全量 outage** — `jobTitle / positionNameEN / positionName` 三字段不在 PingCAP SF 租户(`api*.sapsf.cn`)的 `EmpJob` 实体上。Round-44 (PR #270) 加入后,`hris.absent_person_check` 每日 06:00 UTC 全部 instance fail 10 天(2026-05-13 → 2026-05-23)。`list_current_jobs.select_fields` 现镜像 `get_jobs_for_users` 唯一在生产经验证过的字段集。
> - **`SuccessFactorsClient._get_json` 错误体捕获** — 新 `SuccessFactorsHTTPError(RuntimeError)` + `_extract_odata_error()` helper 解析 OData v2 `error.code + error.message.value` 包装层(同时兜底 `UnicodeDecodeError` / `httpx.DecodingError` / 非 OData JSON / HTML 网关 body)。原 `response.raise_for_status()` 只回 "Client error '400' for url ..." 把根因吞掉,这次 outage 因此 10 天不可见。URL 持入 exception 前用 `copy_with(query=None)` 去掉 query string(避免把 `get_jobs_for_users` OR-batched userId 列表泄露到 admin-readable 的 `scheduled_job_runs.error`)。`hr/router.py::test_successfactors_connection` 同步迁移到新 typed exception,frontend `http_status + error` 字段不变。
> - **`run_hris_absent_person_check` partial-failure 必 raise** — 之前仅在 `instances_checked == 0` 时 raise,1/2 instance 失败时 `runs.status='ok'` 静默(违反 `feedback_handler_status_must_raise`)。现任何 `summary["errors"]` 非空都 raise,`maybe_notify_failure` 正常 fire。
> - **绝对刹车 + 百分比天花板双层刹车** — `max(25, 5%)` 绝对底之上加 "instance_active ≥ 4 且 absent > 50%" 的百分比天花板。原:10 人 instance 有 9 个 SF 消失(< 25 floor)会被全量 flag。新:9/10 = 90% > 50% → 天花板 trip,refuse-to-alert。≥4 的下限保留 2 人 instance hard-delete 1 人(50% 不 > 50%)这种合法检测路径。
> - **审计行 SAVEPOINT 隔离** — brake-trip + per-person `audit.log` 调用现各自 `async with db.begin_nested()`,某行 UNIQUE 冲突 / JSON 序列化 fail 不再 poison 整个外层 session 让后续行静默丢失(Round-44 apikey `_record_dedup_send` 同模式)。
> - **`successfactors_sync._sync_users_body` 跟齐 hot path** — 加 `client.list_active_positions()` 缓存 + `EmpJob.position` → `Position.externalName_defaultValue` 回退 fallback,与活跃路径 `hr/service.py::_apply_event` 对齐;dormant 路径再次启用时新员工不再以 `job_title=None` 落库。
> - **新 regression 测试** — [test_successfactors_client.py](../../server/tests/test_identity/test_successfactors_client.py) 7 个 case 钉:(a) `list_current_jobs` 不再 request 任何 `positionName / positionNameEN / jobTitle`;(b) `list_current_jobs` vs `get_jobs_for_users` 字段集除 `effectiveLatestChange`(filter 字段)外完全一致(防止再次 select-field drift);(c) OData v2 error envelope 解析 → `SuccessFactorsHTTPError.detail` 含 `code + message.value`;(d) 非 OData / HTML body 回退 raw 截断 500 字符;(e) URL 持出无 query string;(f) 空 body + string-shape message + undecodable body 都不抛 exception。[test_absent_person_check.py](../../server/tests/test_hr/test_absent_person_check.py) 加 2 个 case:(g) 9/10 absent → 百分比刹车 trip;(h) 1/2 instance 失败必 raise + `summary["errors"]` 内容回灌 RuntimeError message。
>
> 0 migration / 0 cron / 0 driver / 0 RBAC / 0 MCP 工具增减(`_REGISTERED_TOOLS_COUNT_HINT` 仍 172)。修复局部于 HRIS / SuccessFactors 模块,不动其他面。
>
> **Earlier** (2026-05-19, Round-43 — 全平台 3 轮 code review + 3 logic scan + 3 security scan, 20 bug 整改):
>
> - **HRIS absent-person-check total-failure now raises** — `scheduled_jobs/handlers.py`. `run_hris_absent_person_check` raises `RuntimeError` when all HRIS instances fail; previously returned `{"status":"ok"}` silently.
> - **`successfactors_sync.sync_users` distributed lock** — wraps `_sync_users_body` in `distributed_lock("hris_full_roster_sync", ttl=600)` to prevent concurrent full-roster runs from racing on the `work_email` UNIQUE constraint.
> - **`integrations/compat.py` narrow exception catch** — `except RuntimeError` → `except CredentialDecryptionError` so unrelated RuntimeErrors propagate instead of being silently mapped to `CREDENTIAL_DECRYPTION_FAILED`.
> - **connector hardening (9 drivers)** — URL encoding in SCIM/Admin API paths (`github_software`, `jira_software`, `slack_software`); Azure token cache + pagination cap; GCP BQ table-name injection guard + project-list page cap; AWS CE page cap + IAM `revoke_sync` paginator; Zoom token lock; Google Workspace non-404 error pass-through + `get_running_loop`; driver_base `ClassVar` for capabilities.
> - **JWT alg startup guard** (`main.py`); Redis rate-limiter `expire(nx=True)` → Redis 6.x compat (`mcp/middleware.py`); Redis URL log redaction (`auth/service.py`); FX rate band parity + Decimal precision (`ai_spending/service.py`, `core/fx_rate.py`); Bailian `days` cap + TZ fix; budget-enforcement bypass warning; `period` param validation; ILIKE metachar escaping; auth audit PII mask; Feishu bidi-control injection; override notification text sanitization; Bedrock IAM region-scoped policy.
>
> **2026-05-20 — API Key self-service release: Feishu approval template → Helix-internal approval + Feishu PUSH**.
>
> The release flow shipped in Round-43 (PR #408 PR1) routed through a Feishu approval template — same shape as ``api_key_request``: submit creates a Feishu approval instance, the leader clicks 同意 inside Feishu, the central callback dispatcher runs ``handle_release_feishu_callback``. The user audited this after merge and pointed out the wrong target: release should mirror PR2's transfer pattern (Helix is the decision-UI, Feishu is just the notification channel). The flow is rewritten in-place:
>
> - **New table** [`api_key_release_requests`](../../server/app/domains/apikey/models.py) — 11 columns (id, assignment_id FK CASCADE, requester_id FK **SET NULL**, approver_id FK SET NULL, reason_code, reason_note, status, decision_note, previous_status, requested_at, responded_at) + 3 composite indexes (`(requester_id, status)` / `(approver_id, status)` / `(assignment_id, status)`). [Migration 192](../../server/alembic/versions/192_apikey_release_requests.py) creates it; uses the ``_fk_source_col_type`` helper (introduced for PR #416's collation hotfix) to mirror ``api_key_assignments.id``'s DATA_TYPE + COLLATION_NAME at runtime so TiDB FK strict-mode is satisfied. ``requester_id`` is nullable in the DB (NOT NULL + SET NULL is rejected with MySQL ERROR 1830) but the application layer enforces non-null on every insert.
>
> - **Helix-internal approve / reject endpoints** ([apikey/router.py](../../server/app/domains/apikey/router.py)):
>   * ``POST /apikeys/assignments/{id}/release-request`` (submit, owner-only) — inserts the row in ``pending``, parks the assignment in ``pending_release``, calls ``_notify_release_pending`` to push a Feishu **PUSH notification (not approval card)** + email card to ``requester.manager_person_id`` linking to ``/my/todos?tab=apikey-release``. Fallback: manager link broken → notify all ``global_admin``.
>   * ``GET /apikeys/release-requests?role=approver|requester&status=...`` — list for the manager inbox or the requester's own list. Non-admin approver view scopes via subquery on ``IdentityPerson.manager_person_id == current_user.id``.
>   * ``GET /apikeys/release-requests/{id}`` — single detail; visible to requester / requester's manager / global_admin.
>   * ``POST /apikeys/release-requests/{id}/approve`` and ``/reject`` — both gated by ``check_sensitive_endpoint_rate_limit`` for defence-in-depth. Authz: requester's direct manager OR ``global_admin``. On approve, the service calls ``revoke_key(actor_role=self_with_approval, current_user=requester)`` synchronously — the cloud teardown runs through the same path as an admin revoke. ``current_user`` is the **requester** (not the manager) because the ``self_with_approval`` guard inside ``revoke_key`` requires the caller to be the assignee.
>   * Leaver-status check on approve: if requester's ``employment_status`` is ``left`` or ``leaver_pending`` (or row soft-deleted) the approve short-circuits to ``apikey.release.refused_leaver`` and the offboarding pipeline owns the cleanup. Same shape as the old callback's leaver guard, kept in place.
>
> - **Feishu callback dispatcher** ([approval/service.py](../../server/app/approval/service.py)) — the ``api_key_release`` branch is removed. The route is gone, and any stale ``approval_type='api_key_release'`` rows from before the cutover land in the generic dispatcher path as no-op.
>
> - **MCP** ([mcp/tools.py](../../server/app/mcp/tools.py)) — three new tools for AI clients to act as the approver: ``list_my_api_key_release_approvals``, ``approve_my_api_key_release(request_id, decision_note?)``, ``reject_my_api_key_release(request_id, decision_note?)``. The existing requester-side ``release_my_api_key`` / ``cancel_my_api_key_release`` keep their signatures (only the underlying state machine changed). ``_REGISTERED_TOOLS_COUNT_HINT`` bumped **163 → 166**.
>
> - **Frontend** ([MyTodosPage.tsx](../../web/src/pages/my/MyTodosPage.tsx)) — new "API Key 释放审批" tab with KeyOutlined icon, yellow Badge for pending count, mirrors the existing cloudGrants tab structure. Modal enforces ``confirmLoading`` three-stage guard and surfaces a warning Alert on approve ("Cloud credential will be deleted immediately, cannot be restored"). MyApiKeysPage (requester side) is unchanged — the user-facing submit / cancel flow keeps the same UI.
>
> - **State machine** (final):
>   * ``active`` / ``expired`` → owner submits ``/release-request`` → ``pending_release`` (new ApiKeyReleaseRequestModel row in ``pending``)
>   * Manager / admin clicks Approve in Helix Web UI / MCP → ``revoked`` (``actor_role=self_with_approval``, cloud teardown via ``revoke_key``)
>   * Manager / admin clicks Reject in Helix → ``previous_status`` (assignment reverts; request row → ``rejected``)
>   * Requester clicks Cancel → ``previous_status`` (assignment reverts; request row → ``cancelled``)
>   * Approve on a leaver assignee → auto-reject with reason ``assignee_offboarded_during_pending``, audit row ``apikey.release.refused_leaver``, offboarding pipeline takes over.
>
> Cloud teardown shape is unchanged — same three-step ``_revoke_cloud_resources`` deletes AWS IAM user / Aliyun RAM user + DashScope sk- / Azure Cognitive Services account, regardless of which actor_role triggered the revoke. The change is entirely in the *trigger* (Helix endpoint vs Feishu callback), not the teardown itself. ``cloud-lifecycle-ops.md`` does not need updating.
>
> MCP-sync rule ✓. Doc-update rule ✓.
>
> ----
>
> Earlier (Round-45, 2026-05-19 — **API-Key lifecycle closure PR3**: offboarding auto-revoke now really tears down the cloud credential + daily expiry-warning cron + IAM tag retry endpoint + cross-state guards + leaver-status guard on release callback).
>
> Five things landed:
>
> - **Pre-existing offboarding bug fixed** ([provisioning/service.py](../../server/app/domains/provisioning/service.py)). When an admin clicked "auto-revoke" on an api_key offboarding blocker, ``OffboardingService.auto_revoke_blocker`` queued a ``ProvisioningTask(target_type='api_key', software_id=None)`` and the standard ``_invoke_connector`` raised ``ValueError("auto_api task has no software_id")`` — the task landed FAILED, blocker stayed pending, **cloud IAM user / RAM user / AOAI account kept working** until someone noticed. Now ``_invoke_connector`` short-circuits api_key tasks with ``ok=True``, and ``_post_success_side_effects`` dispatches into ``ApiKeyService.revoke_key(actor_role=SYSTEM_OFFBOARDING)`` for the real teardown. ``revoke_key`` accepts ``current_user=None`` so future pure-cron callers don't need a synthetic admin, and writes ``revoked_by = task.created_by`` when an admin dispatched the task (preserving the actor for forensics).
>
> - **Daily expiry-warning cron**: new ``apikey.expiry_check`` handler ([scheduled_jobs/handlers.py](../../server/app/scheduled_jobs/handlers.py)) + per-assignment evaluator [expiry_alerts.py](../../server/app/domains/apikey/expiry_alerts.py) + dedup table ``apikey_expiry_alerts_dedup`` ([migration 186](../../server/alembic/versions/186_apikey_expiry_alerts_dedup.py)). Fires assignee + manager Feishu/email cards at the 7-day and 1-day boundaries. UNIQUE on ``(assignment_id, valid_until_iso, threshold_days)`` so re-running same day = noop, but admin extension changes ``valid_until`` → fresh dedup row → next window re-fires.
>
> - **IAM tag retry endpoint**: ``POST /apikeys/assignments/{id}/retry-iam-retag`` + MCP ``retry_api_key_iam_retag``. When PR2's best-effort tag rewrite during transfer fails, admin re-runs from the table (red chip in revoke-metadata column → button → call). ``detail.last_transfer.retag_attempts`` accumulates the per-attempt history capped at 20 rows. Refused on revoked rows (IAM user is gone) and rows with no IAM binding.
>
> - **Cross-state + leaver-status guards** in [apikey/service.py](../../server/app/domains/apikey/service.py):
>   * Release Feishu callback now re-validates ``assignee.employment_status`` on approve (mirror of the existing ``api_key_request`` callback). If the assignee departed mid-approval, the callback writes ``apikey.release.refused_leaver`` audit + reverts the row + lets offboarding handle the actual cleanup. Without this, the leaver's id would end up as ``revoked_by`` on the row, masking the offboarding handoff.
>   * ``extend_validity`` on a ``pending_release`` row raises a dedicated ``PENDING_RELEASE_BLOCKS_EXTEND`` 409 with actionable message (was generic ``INVALID_STATUS_FOR_EXTEND`` before — UX issue, but also a signal that the row is parked, not malformed).
>
> - **Dedup-flow correctness fix** caught by self-review on PR3-2's first cut. Initial ``evaluate_assignment`` used ``try INSERT / catch IntegrityError → session.rollback()`` to handle dedup hits — but ``session.rollback()`` wipes the ENTIRE session including dedup rows written by EARLIER iterations of the same sweep, so the cron lost all the day's notification ledger on the first dedup-hit assignment. Fixed to SELECT-then-INSERT with the outer ``execute_job`` lock as the concurrency guard.
>
> Final state machine after PR1+PR2+PR3:
> - ``pending_approval`` → admin-fill → ``approved`` → autograny → ``active``
> - ``active`` → admin revoke (``actor_role=admin``) → ``revoked`` (cloud teardown, notify assignee + manager)
> - ``active`` → owner request release → ``pending_release`` → Feishu leader approve → ``revoked`` (``actor_role=self_with_approval``, cloud teardown); OR Feishu reject / owner cancel → ``active``
> - ``active`` → owner request transfer → AssetTransferRequest(pending) → recipient accepts → ownership flips (status stays ``active``, ``transferred_from`` captured, cost rule + IAM tag rewrites)
> - Offboarding cascade: leaver → ``RES_API_KEY`` blocker → escalation cron creates AssetTransferRequest to manager (default) → manager accepts → owner flips; admin escape valve → ``auto_revoke_blocker`` → ProvisioningTask → ``revoke_key(actor_role=system_offboarding)``.
>
> MCP-sync rule ✓ — bumped 160 → **161** with ``retry_api_key_iam_retag``. Cloud-lifecycle-ops doc updated: the offboarding auto-revoke path is now actually wired end-to-end + the retry-iam-retag operation is documented.
>
> ----
>
> Earlier (Round-44, 2026-05-19 — **API-Key lifecycle closure PR2**: self-service ownership transfer. The key keeps working through the handover — cloud credential survives, ``assigned_to`` flips, ``transferred_from`` captures the prior owner, ``CmdbCostAllocationRule.assign_person_id`` swaps so post-transfer cost-ingest attributes to the new owner, and AWS Bedrock IAM user's ``Owner`` / ``helix-owner-email`` tags are pushed via [`AwsCloudDriver.tag_iam_user`](../../server/app/integrations/drivers/aws_cloud.py) so AWS Cost Explorer attributes future spend correctly.
>
> Three new entry points all delegate into the existing
> [`OffboardingService.create_transfer`](../../server/app/domains/offboarding/service.py:3929) /
> [`_apply_transfer`](../../server/app/domains/offboarding/service.py:5138) machinery — re-using the
> shared "recipient active + not initiator + initiator holds asset + no duplicate pending" invariants
> rather than re-implementing them in the apikey domain:
>
> - **Employee self-service** — [`POST /apikeys/assignments/{id}/transfer-request`](../../server/app/domains/apikey/router.py)
>   takes a ``recipient_person_id`` + optional ``note``. Owner-only; state-gate restricts to
>   ``active`` / ``expired``. Creates an ``AssetTransferRequest(asset_type='api_key',
>   offboarding_process_id=None)`` — recipient sees the card in ``/my/transfers`` (the existing
>   MyTransfersPage already had the ``apiKey`` label string under ``myTransfers.assetType.apiKey``).
> - **Offboarding cascade** — already wired pre-this-PR via [`LeaverEscalationService._escalate`](../../server/app/domains/offboarding/escalation.py:275):
>   on ``leaver_date`` the scanner walks the manager chain via ``_walk_to_active_manager`` (skipping
>   inactive / leaver-pending managers) and creates an ``AssetTransferRequest`` per pending
>   ``RES_API_KEY`` blocker, with ``offboarding_process_id`` set. The manager accepts via the same
>   ``/my/transfers`` page; on accept the same [`ApiKeyService.apply_transfer`](../../server/app/domains/apikey/service.py)
>   runs.
> - **Owner cancel** — [`POST /apikeys/assignments/{id}/transfer-request/cancel`](../../server/app/domains/apikey/router.py)
>   flips an in-flight ``AssetTransferRequest.status`` to ``cancelled``. Idempotent on already-
>   responded transfers.
>
> Temporal cost-attribution split semantics (the user-stated requirement: "old owner backs spend
> before the transfer, new owner backs after"): falls out of the rule-update timing rather than
> requiring a time-windowed schema. ``cmdb_cost_facts.attributed_person_id`` is stamped at
> **ingest** time by the cost-ingest cron walking the rule ladder; on transfer we update
> ``CmdbCostAllocationRule.assign_person_id`` to the new owner, append a transfer-date suffix to
> ``description``, and let the next cron tick pick up the new mapping. Pre-transfer rows that
> were already ingested keep their stamped ``attributed_person_id = old_owner``. Read-time
> re-attribution is deliberately not done — that would rewrite history.
>
> Cloud-side tag rewrite is **best-effort**: a tag-update failure does NOT roll back the transfer
> (local row + rule are already authoritative for Helix attribution; an admin can re-tag via the
> AWS console or a future "rebind IAM" sweep). Aliyun Bailian and Azure OpenAI don't need a tag
> rewrite — Bailian's auto-grant doesn't emit a cost-allocation rule (per-key BSS cost isn't
> available); AOAI's rule keys on the per-employee resource-group name, which doesn't change on
> ownership transfer.
>
> Migration [184](../../server/alembic/versions/184_apikey_transferred_from.py) adds
> ``api_key_assignments.transferred_from`` (FK → ``identity_persons``, ON DELETE SET NULL) — the
> denormalised hint for "where did this key come from?" without an audit-log join. Multi-hop
> history continues to live in ``audit_logs.apikey.transfer.complete`` rows (the only authoritative
> source for the full chain).
>
> MCP-sync rule ✓ — two new tools registered: ``transfer_my_api_key`` + ``cancel_my_api_key_transfer``.
> ``_REGISTERED_TOOLS_COUNT_HINT`` bumped 158 → 160. Cloud-lifecycle-ops doc updated: IAM tag
> rewrite is a new cloud-side operation that fires during ownership transfer.
>
> ----
>
> Earlier (Round-43, 2026-05-19 — **API-Key lifecycle closure PR1**: self-service release + revoke metadata + admin-revoke notification. Three flows now exist on `api_key_assignments`, distinguished by the new `revoke_actor_role` column written at the status-flip moment:
>
> - **`admin`** — direct admin revoke via `POST /apikeys/assignments/{id}/revoke` (the historical path; `ApiKeyRevokeRequest` now accepts an optional `reason_code` bucket — `admin_revoke` / `leaked` / `compliance` / `other` — defaulting to `admin_revoke` for backwards-compatibility with table-row clients). Notification fan-out is new: assignee + their HRIS direct manager both receive a Feishu/email card so the "production code suddenly 401s" event has a human-readable heads-up. Notification failure does NOT roll back the revoke (cloud teardown already happened; the row is in `STATUS_REVOKED`; the audit row is committed).
>
> - **`self_with_approval`** — employee self-service release via `POST /apikeys/assignments/{id}/release-request`. Owner-only; takes `reason_code` (enum `no_longer_needed` / `project_ended` / `wrong_provider` / `other`, with `other` requiring `reason_note` per schema validator) + optional note. Parks the assignment in new `STATUS_PENDING_RELEASE` status, stashes the prior status in `detail.release.previous_status` for the rollback path. Writes companion `EmployeeRequest(request_type='api_key_release')` + `ApprovalRequest(approval_type='api_key_release')` and creates a Feishu approval instance via the same machinery as `api_key_request`. **Reveal is blocked while in `pending_release`** (`PENDING_RELEASE` 409 unless caller is admin) — closes the "grab one last plaintext while the leader's deciding" gap. The leader's Feishu callback dispatches into [`ApiKeyService.handle_release_feishu_callback`](../../server/app/domains/apikey/service.py): approved → calls `revoke_key(actor_role='self_with_approval')` which runs the same cloud teardown as admin revoke (`_revoke_cloud_resources` deletes AWS IAM user / Aliyun RAM user + DashScope sk- / Azure Cognitive Services account); rejected → reverts the assignment to its prior status and notifies the assignee via `apikey.release.rejected`. `revoked_by` on the row points to the **assignee** (the employee initiated; the leader's id only lives in the Feishu approval payload). Companion `POST .../release-request/cancel` lets the employee withdraw; cancels the Feishu instance, reverts status, marks approval+EmployeeRequest as `cancelled`.
>
> - **`system_offboarding`** — reserved for PR2 (offboarding cascade auto-revoke when the leaver has no transfer target). Code path exists in `revoke_key()` but the offboarding scanner doesn't wire `RES_API_KEY` yet.
>
> Forensic metadata is now on the row itself, not buried in `audit_logs.detail` JSON: [migration 183](../../server/alembic/versions/183_apikey_revoke_metadata.py) adds `revoked_at`, `revoked_by` (FK → `identity_persons`, ON DELETE SET NULL), `revoke_reason_code`, `revoke_reason_note`, `revoke_actor_role` columns plus composite index `ix_apikey_assignments_revoke_actor_at(revoke_actor_role, revoked_at)` for governance reports ("show me every self-release this week" is now a single-segment index scan). Historical revoked rows backfilled with `revoked_at = updated_at` and `revoke_actor_role = 'admin'` only; reason fields stay NULL for pre-migration rows (audit_logs remains source of truth for those).
>
> MCP-sync rule ✓ — two new tools registered: `release_my_api_key(assignment_id, reason_code, reason_note?)` + `cancel_my_api_key_release(assignment_id)`. `revoke_api_key` extended with optional `reason_code` parameter. `_REGISTERED_TOOLS_COUNT_HINT` bumped 156 → 158. Cloud-lifecycle-ops doc unchanged (cloud teardown shape itself didn't change — same three-step `_revoke_cloud_resources` for AWS / Aliyun / Azure, regardless of which actor_role triggered it). PR2 (transfer + offboarding cascade) builds on the state machine + metadata columns introduced here.
>
> ----
>
> Earlier (Round-42 — 上线前全模块 3 轮 review + 13 bug 整改, connectors / IAM / billing / recycling / notifications):
>
> - **Mass-revoke safety on every cloud driver's `list_scope_grants`** — Round-42 audit confirmed every provider (AWS / Azure / GCP / Aliyun / Aliyun-Intl / Tencent / UCloud / Ksyun) now RAISES on transient API failure rather than returning a partial snapshot. The grants-sync sweep treats the returned snapshot as authoritative, so any swallow-and-return-partial path used to translate into mass-revoke of every Helix-tracked grant on the next drift tick. UCloud (`list_scope_grants`, `_resolve_user_id`, per-user `ListPoliciesForUser`) + AWS IAM fallback (`_list_scope_grants_iam_sync` outer `list_users` + per-user `list_attached_user_policies`) were the holdouts and now raise. Soft signals like `get_last_login` keep returning `None` on transient errors — the inactivity sweep treats `None` as "unknown" and does NOT revoke.
> - **`approve_grant` / `renew_grant.reinstate` hard-gate on scope status** — `lifecycle_service.py`. Both now do `SELECT … FOR UPDATE` on the scope and raise 409 `CLOUD_SCOPE_NOT_ACTIVE` if `status != active` (or `deleted_at != NULL`). Previously approve held `approve_group:` and decommission held `decommission_scope:` — different lock namespaces, so a decommission committed mid-approve was invisible under REPEATABLE READ. Submit/owner_add_member already enforced this; approve/renew were the outliers.
> - **GCP `_list_billing_daily_sync` parity with `_list_cost_facts_sync`** — both BQ paths now pin `project=bq_project` (avoids hard-fail when SA's home project lacks BQ API), set `maximum_bytes_billed=50_000_000_000` + `job_timeout_ms=300_000`, and `client.close()` in a finally block (avoids gRPC channel leakage on per-scope iteration).
> - **`orphan_cleanup._recover_emails_for_principals` is now integration-scoped** — `orphan_cleanup.py`. AWS Identity Store principal_ids are unique within an Identity Store but NOT across Orgs; the lookup now filters via `scope_id IN (SELECT id FROM cmdb_cloud_scopes WHERE integration_id=?)` so a multi-Org tenant cannot cross-attribute the wrong email/display_name onto an orphan-cleanup history row.
> - **`cloud.grant.revoke` audit status reflects idempotent success** — `lifecycle_service.py:2887`. `already_absent=True` (cloud-side already gone — offboarding sweeper double-revoking is normal) now writes `status="success"` rather than `failure`, so forensic dashboards stop double-counting it.
> - **autogrant `_to_response` redacts SDK error blobs from non-admin readers** — `apikey/service.py::_to_response`. `detail.bedrock|bailian|azure_openai.{error, *_attempted, integration_id, integration_slug}` are stripped for non-admin readers; admins (`can_manage_assets`) still see the full payload for troubleshooting. SDK errors can include master integration SP ARNs, internal integration UUIDs, and `InvalidTag` hex fragments — none of which the assignment owner has any operational use for.
> - **`raise_if_total_failure` progress_keys discipline** — `google_handlers.group_sync` / `google_handlers.seat_sync` / `zoom_handlers.seat_sync` no longer include fetch counters (`groups_listed` / `tenant_users_listed` / `zoom_users_listed`) in `progress_keys`. Only mutation counters (`created/updated/soft_deleted`, `seats_imported/revoked`, `seat_changes_total`) count as progress, so an all-failed run with a successful roster fetch correctly raises.
> - **API Token expiry notification channel fix** — `handlers.py:2398`. Was `channels=["inapp", "email"]` — the dispatch loop only recognises `feishu` and `email`, so `inapp` rode in todo forever, attempts++ until DEAD even though email actually delivered. Now `["feishu", "email"]` matching the PAT-expiry sibling at line 2652.
> - **`feishu_seat_sync` raises on missing driver** — `feishu_handlers.py:107`. Per `feedback_handler_status_must_raise`: returning `{"errors": [...]}` leaves `scheduled_job_runs.status='ok'` and the dedup failure-notifier stays silent. Now raises `RuntimeError` so admins see the broken cron in the admin console.
> - **Aliyun `_acs3_call` runs error text through `redact_provider_error`** — `aliyun_cloud.py:347`. BSS signature-mismatch 400s occasionally echo the AccessKey-id; the redactor (already used by AWS / Azure / GCP / Tencent / UCloud) is now wrapped around the raw `resp.text` before it lands in `capability_results['error']`, failure-notifier cards, and audit retention.
> - **Bailian reissue endpoint rate-limited** — `apikey/router.py:227`. Admin-only `/assignments/{id}/bailian/reissue` now depends on `check_sensitive_endpoint_rate_limit` (each call mints a fresh sk-, non-idempotent on the Aliyun side; runaway frontend Modal or scripted retry would burn through the workspace's 30-key quota).
> - **Azure `list_scope_grants` reports principals dropped due to missing email** — `azure_cloud.py:1098`. AAD guest users with `mail=None` and `userPrincipalName=None` are still skipped (only one user can't be mass-revoke'd), but a non-zero drop count now emits an `INFO` log so persistent Graph projection bugs are operator-visible.
>
> ---
>
> **Earlier** (2026-05-19, Round-41 — billing/cost 模块上线前 3 轮 code review + 14 bug 整改):
>
> - **`recompute_allocation` single-flight serialised across cron / HTTP / MCP** — three callsites (`scheduled_jobs/handlers.py::run_cost_nightly_recompute_allocation`, `cost_router.py::recompute_allocation_endpoint`, `mcp/tools.py::cost_recompute_allocation`) all use `distributed_lock("cost_recompute_allocation", ttl=1800)` (raised from 600 on 2026-05-27 — see top digest; 600s was shorter than real runtimes and expired mid-run, allowing concurrent contending recomputes). HTTP `LockBusy` → 409 `ConflictError`; cron `LockBusy` → `status='skipped'`; MCP path unchanged (existing).
> - **`sync_billing_daily_for_integration` envelope contract matches `sync_cost_facts_for_integration`** — both return `{status: ok|partial_failure|failed|skipped, integration_id, start_date, end_date, upserted, ...}` plus a per-service `reason` on skip / failed. Cron handler at `handlers.py::run_billing_daily_sync` reads `status` from the inner response and raises on `failed`/`partial_failure` (or non-empty `errors`) so the failure-notifier fires consistently across both billing pipelines.
> - **`integration.enabled` short-circuit on every cloud cron** — disabled cloud integrations no longer burn provider quota (AWS Cost Explorer / Aliyun BSS); both `sync_cost_facts_for_integration` (since #338) and `sync_billing_daily_for_integration` (this round) skip-and-return.
> - **apikey enumeration-oracle posture is now uniform** — every non-admin-gated endpoint that does `_get_assignment` then ownership check collapses 404→403 for non-admin callers: `extend_validity`, `get_assignment_spend`, `get_assignment_spend_detail`, `update_assignment.peek`, `reveal_key` (router-level), `notify_candidates`. Admins keep distinct 404. Pattern: `try: _get_assignment(...) except NotFoundError: raise ForbiddenError(...) from None` if not admin.
> - **AI spending forecast `days_elapsed` clamp** — clamped to `min(elapsed_anchor, last_with_data)` so cost-facts cron lag (1-2 days behind) no longer under-projects `daily_pace` by ~12%; the dashboard's overview tile, forecast tile, and trend chart now read the same elapsed-days window.
> - **`compute_fact_key` separator collision-hardening (#386) — known scope** — separator changed from `|` to `\x01` to prevent Azure resource-id collisions; pre-existing rows pre-#386 carry the old hash. Impact analysis: scope-bound drivers (AWS / Azure / GCP scope-bound / Aliyun / Aliyun-intl) are covered by the per-day pre-window DELETE in the rolling 3-day window — orphans are evicted. Tenant-wide ingest path (no scope rows synced) would silently double-count; prod currently has zero such integrations so impact is zero. The path is documented in `sync_cost_facts_for_integration` as a TODO for whenever a tenant-wide cost-fact provider lands.
> - **Modal-confirmLoading contract enforced on apikey admin page** — `ApiKeyManagementPage`'s three async `onOk` modals (`handleProviderSave`, `handleFillKey`, `submitIamRebind`) all hold a `xxxSaving` state, pass `confirmLoading`, and early-return on re-entry. Pairs with the project-wide [`feedback_modal_confirm_loading`](.) contract.
>
> ---
>
> **Earlier** (2026-05-19, GCP provider — `is_gcp_personal_sandbox_project` helper drops employee-owned sandbox projects from every scope-enumeration path so the 2026-05-16 flood class can't recur):
>
> - **Bug class** — `cloudresourcemanager.projects.search` returns every project the SA has any IAM on, including employee-owned personal projects auto-created when an employee enables Gemini API / Google AI Studio (`gen-lang-client-*`) or first opens the Cloud Console (`displayName == "My First Project"` / `"Untitled project*"`). 2026-05-16 incident: one GCP sync flooded `cmdb_cloud_scopes` with 1028 such rows, ballooning the visible scope count from 58 → 1086. SQL soft-deleted 1018 rows post-hoc; this fix is the durable filter so the next sync tick cannot reflood.
> - **Fix** ([providers/gcp.py::is_gcp_personal_sandbox_project](../../server/app/domains/cmdb/providers/gcp.py)) — new helper, pure function, takes the raw project dict from `projects.search`. Matches `projectId.startswith("gen-lang-client-")` OR `displayName == "My First Project"` OR `displayName.startswith("Untitled project")`. Intentionally does NOT filter on `parent.startswith("organizations/")` alone — legitimate business projects can live at the org root (some customers run org-root infra). Three call sites updated: (a) [`GcpProvider._list_active_projects_sync`](../../server/app/domains/cmdb/providers/gcp.py) — same family of skip as `is_gcp_system_project`, runs for every CMDB resource-sync fan-out; (b) [`GcpCloudDriver._list_projects_sync`](../../server/app/integrations/drivers/gcp_cloud.py) — the scope-write surface, so new flood-class rows never get created; (c) [`GcpCloudDriver._list_scope_grants_sync`](../../server/app/integrations/drivers/gcp_cloud.py) — defense-in-depth for residual scope rows from before this filter landed (matches `gen-lang-client-*` only, since the grants surface only has the projectId, not displayName).
> - **Later (2026-05-19, billing-account gate)** — the displayName / projectId filter caught ~3 named families but a follow-up audit found ~111 org-root personal sandboxes with no regular naming pattern (random employee names, hackathon codenames, `"My Project NNNNN"` family that leaked past the exact-match check). PR #389's commit message acknowledged these as "manual decommission only" — unacceptable since employees keep creating new ones. Durable signal added: **billing-account membership**. New helpers in [providers/gcp.py](../../server/app/domains/cmdb/providers/gcp.py) — `normalize_gcp_billing_account_name(str | None) -> str | None` (config-form normalization, accepts short `01D088-…` or full `billingAccounts/01D088-…`), `fetch_gcp_billed_project_ids(cloudbilling_svc, billing_account_name) -> set[str]` (paginated `cloudbilling.billingAccounts.projects.list`, filters `billingEnabled=True`, raises on API failure — never silently falls back), `is_gcp_org_root_unbilled(project, billed_ids) -> bool` (predicate: folders/* fast-path, org-root requires membership). Two call sites gated: [`GcpProvider._list_active_projects_sync`](../../server/app/domains/cmdb/providers/gcp.py) and [`GcpCloudDriver._list_projects_sync`](../../server/app/integrations/drivers/gcp_cloud.py); each reads `create_scope_billing_account` from `integration.config_public` (or `ProviderCredentials.fields` for the provider — `cmdb/sync.py::ProviderCredentials` already merges public config into fields). Failure-mode contract: cloudbilling 403/401 → `ProviderAuthError` (provider) / `HttpError` (driver), scope sync lands as `status='failed'`, failure_notifier alerts. The PingCAP SA `pch-resource-manager@gcp-digital-infra` has `roles/billing.viewer` on `billingAccounts/01D088-8F9CF2-8AF1C6` (grant operation logged in cloud-lifecycle-ops.md).
> - **Gate is opt-in** — when `create_scope_billing_account` is unset on the integration, `_fetch_billed_project_ids` returns `None` and the gate is disabled (legacy behavior). Existing tests that don't configure the field stay green. Orgs that haven't centralized billing yet keep the old filtering surface.
> - **OAuth-scope follow-up (2026-05-19, same day)** — first prod ticks after #397 deployed all 403'd with `Request had insufficient authentication scopes` on the new `cloudbilling.billingAccounts.projects.list` call. Not an IAM gap; the CMDB provider's `_READ_SCOPES` had been narrowed to `cloud-platform.read-only` in Round-21 (PR #334) and that scope is NOT accepted by Cloud Billing API (`cloud-platform` / `cloud-billing` / `cloud-billing.readonly` only). Added `https://www.googleapis.com/auth/cloud-billing.readonly` to [`providers/gcp.py::_READ_SCOPES`](../../server/app/domains/cmdb/providers/gcp.py); now congruent with [`gcp_cloud.py::_BASE_SCOPES`](../../server/app/integrations/drivers/gcp_cloud.py) on the cloudbilling axis. Same bug-class as the in-file `bigquery` (vs `bigquery.readonly`) comment — new API surface forces explicit scope-list extension, no compile-time check.
>
> **Earlier** (2026-05-19, Round-40 — `cmdb_cost_facts` commits the per-day DELETE before moving to the next day; Round-39's `DELETE … LIMIT 1000` chunking caps per-statement working set but kept all 720 chunks of a 16-day backfill in one SQLAlchemy txn, accumulating 500+ chunks of memtable past 1 GiB `tidb_mem_quota_query`. Per-day `session.commit()` resets memtable at every day boundary. 0 migration, 0 MCP, 0 RBAC, 0 driver, service-layer-only — 1 line of code):
> **Earlier**: (2026-05-19, Round-40 — `cmdb_cost_facts` commits the per-day DELETE before moving to the next day; Round-39's `DELETE … LIMIT 1000` chunking caps per-statement working set but kept all 720 chunks of a 16-day backfill in one SQLAlchemy txn, accumulating 500+ chunks of memtable past 1 GiB `tidb_mem_quota_query`. Per-day `session.commit()` resets memtable at every day boundary. 0 migration, 0 MCP, 0 RBAC, 0 driver, service-layer-only — 1 line of code):
>
> - **What Round-39 (PR #383) got wrong** — restored PR #380's per-day-outer + per-1000-row-inner DELETE chunking on top of Round-38's `(scope_id, date)` index, on the theory that `LIMIT 1000` would bound the per-statement working set. It does, but it does NOT bound the **transaction memtable**, which is what `tidb_mem_quota_query` actually checks. 2026-05-18 15:23 prod v5 backfill failed on AWS `385595570414 PingCAP` scope `date=2026-05-12` — 11 days × ~45 chunks each = ~500 chunks had succeeded, and the 501st chunk found its own ~100 KB working set added to ~500 prior chunks' accumulated undo + lock list + index project entries → past 1 GiB.
> - **The "~16 KiB raw" assumption from PR #380's comment was wrong.** Measured live: `SELECT AVG(LENGTH(raw))` on PingCAP scope = **48 bytes**, max 61 bytes. Every working-set estimate downstream of PR #380's comment was off by 300×. The actual contention isn't row payload — it's the transaction memtable accumulating across hundreds of chunks in one un-committed txn.
> - **Fix** ([cost_service.py::sync_cost_facts_for_integration](../../server/app/domains/cmdb/cost_service.py)) — keep Round-38 + Round-39's stack (index + per-chunk LIMIT), add one line: `await session.commit()` after each day's inner LIMIT loop completes. The txn memtable now resets to empty at every day boundary, capping the in-flight working set at a single day's eviction (~45k rows of undo+lock on the heaviest scope, well under 1 GiB).
> - **Why per-day commit instead of per-chunk commit** — 720 commits/scope vs 16 commits/scope. Each TiDB commit is a 2PC roundtrip ~10ms, so per-chunk = 7.2 s pure network overhead vs per-day = 160ms. Per-day matches the natural logical unit (a day's eviction is one logical operation; downstream UPSERT also replaces one day's facts).
> - **Atomicity** — the old "eviction DELETE is durable atomically with the first INSERT batch" comment was never actually true once chunking landed (PR #380 → Round-39); the DELETE was already multiple statements per day per scope. Per-day commit just makes it explicit: a mid-window crash leaves earlier days fully evicted and the failed day in its pre-eviction state. The next cron tick repairs naturally (idempotent ON DUPLICATE KEY UPDATE).
> - **Postmortem note for future rounds** — Round-37 / 38 / 39 / 40 all hit 8175 on the same SQL family. Each hotfix patched the "previously wrong assumption" but missed others. Next time a `cmdb_cost_facts` DELETE 8175s, do a **working-set decomposition** before choosing a fix: row payload × matching rows + lock list + undo log + index update buffer + planner scratch — all four can independently push past `tidb_mem_quota_query`. PR #380's "~16 KiB raw" assumption escaped review because nobody ran `SELECT AVG(LENGTH(raw))` to verify. **Verify-before-believe** for memory estimates, same as for any other production claim.
>
> **Earlier** (2026-05-19, Round-39 — `cmdb_cost_facts` puts the `DELETE … LIMIT 1000` per-day row-chunking back on top of Round-38's `(scope_id, date)` index; **superseded by Round-40 above** which added the missing per-day `session.commit()` after the inner LIMIT loop because Round-39's chunking accumulated all 720 chunks of a 16-day backfill in one un-committed txn and still 8175'd at chunk ~500):
>
> - **What Round-38 (PR #381) got wrong** — bet that adding `ix_facts_scope_date (scope_id, date)` and letting TiDB pick a tight `IndexRangeScan` plan would keep the per-day DELETE under the 1 GiB `tidb_mem_quota_query` cap, and removed PR #380's `DELETE … LIMIT 1000` inner chunking loop. 2026-05-18 14:36 prod re-ran backfill for AWS Org integration; AWS `385595570414 PingCAP` payer scope hit 8175 again on `date=2026-05-12`. EXPLAIN was clean (`IndexRangeScan` on `ix_facts_scope_date`, estRows=49721, no over-scan, no secondary `Selection`) — TiDB was already choosing the optimal plan. The cap got tripped by the **row-payload total**, not over-scanning: 49721 rows × ~16 KiB JSON `raw` = ~800 MiB, right at the edge of 1 GiB once SQLAlchemy buffering / transaction undo log / JSON header overhead is counted in. **NOTE (Round-40 revision)**: the "~16 KiB raw" estimate is wrong — actual `AVG(LENGTH(raw))` on PingCAP scope = 48 bytes. Round-39's working-set arithmetic was off by 300×; the real failure mode was un-committed cross-chunk memtable accumulation (see Round-40 above).
> - **What Round-39 then added** — per-day-outer + per-1000-row-inner `text("DELETE … LIMIT :n")` chunking restored. Single-statement working set ~100 KB. But all 720 chunks of a 16-day backfill remained in one SQLAlchemy txn; chunk 501 found its own 100 KB added to ~500 prior chunks' accumulated undo+lock+index-update entries → past 1 GiB.

> **Earlier** (2026-05-19, Round-38 — `cmdb_cost_facts` adds composite `(scope_id, date)` index `ix_facts_scope_date` via migration 181; pins every per-scope read path to an index-range scan. **Stack today** = Round-38 index + Round-39 chunking + Round-40 per-day commit; read all three rounds together):
>
> - **Bug class** — `cmdb_cost_facts` (~10.9M rows / 4.3 GiB data + 4.6 GiB indexes on prod TiDB) had every existing index leading with `date` or `integration_id`. The Round-37 pre-window DELETE pattern `WHERE integration_id=? AND scope_id=? AND date=?` therefore picked `ix_facts_date_int (date, integration_id)` and had to load the full day's row payload before applying the `scope_id` filter. AWS `385595570414 PingCAP` payer scope at ~45k rows/day × ~16 KiB JSON `raw` per row = ~720 MiB per-statement working set → repeatedly tripped TiDB error 8175 `tidb_mem_quota_query`.
> - **Fix** ([migration 181](../../server/alembic/versions/181_cmdb_cost_facts_scope_date_index.py) + [models.py::CmdbCostFact](../../server/app/domains/cmdb/models.py)) — adds composite `Index("ix_facts_scope_date", "scope_id", "date")` to `cmdb_cost_facts`. `(scope_id, date)` is leading-column equality match for both the cron's DELETE pattern and every per-scope read path → TiDB picks an index-range scan over a single scope's slice of one day.
> - **Deploy order** — merge → Backend Migration Guard CI runs `alembic upgrade head` (prod TiDB online DDL, ~5-15 min backfill on 10.9M rows, no table lock). Post-deploy verification: `EXPLAIN DELETE FROM cmdb_cost_facts WHERE integration_id='<aws-org>' AND scope_id='02592a65-d67f-4767-9b75-437ce392c86f' AND date='2026-05-14'` must show `IndexRangeScan` on `ix_facts_scope_date`.
> - **Tenant-wide drivers (`scope_ref=None`)** stay uncovered (TODO in code).
>
> ----
>
> **Earlier** (2026-05-19, cloud scope-request + access-grant lifecycle timeline — audit-backed event stream reusing migration 180's `ix_audit_logs_resource_lookup` composite index; **+2 MCP tools** `get_cloud_scope_request_timeline` + `get_cloud_access_grant_timeline` (153 → 155); no new RBAC role; no new domain; zero migrations of its own):
>
> - **Audit log is the timeline source.** `scope_request_service` and `lifecycle_service` already emit one `audit_logs` row per state transition (`cloud.scope_request.{submit,approve,approve_resume,cloud_created,provision,manual_provision,reject,withdraw,cancelled_via_feishu}` + `cloud.grant.{request,approve,reject,owner_add,renew,renew_reinstate,revoke,disable}`). New module [`server/app/domains/cmdb/timeline_service.py`](../../server/app/domains/cmdb/timeline_service.py) reads those rows for a single record, whitelists the user-meaningful actions (drops `revoke_retry` / `revoke_attempt_failed` / `callback_ignored_state_advanced` noise), and enriches actor labels via `IdentityPerson`.
> - **Endpoints** — `GET /api/v1/cmdb/scope-requests/{id}/timeline` (admin OR original requester) and `GET /api/v1/cmdb/grants/{id}/timeline` (`can_read_cloud` / `can_view_finance` / grant holder / scope owner — parity with `list_grants` + `get_scope`) return events oldest-first with `id / event_kind / action / status / occurred_at / actor_user_id / actor_label / actor_type / trace_id / detail`. Detail JSON has operator-internal keys redacted (`feishu_instance_code` already surfaces elsewhere; `request_group_id` is not user-meaningful).
> - **Single-fetch read endpoint** (added 2026-06-01) — `GET /api/v1/cmdb/scope-requests/{id}` returns the hydrated scope-request row in the same shape `list_scope_requests` emits per row. RBAC mirrors the timeline endpoint (admin OR original requester); non-UUID and missing ids both return 404 (column is `String(36)`, `db.get` accepts any str and returns None → `NotFoundError`). Replaces the earlier `CloudScopeRequestsPage` orphan-fetch hack that widened `listScopeRequests({page_size:200})` to find a row by id — both fixed the BE `le=100` 422 and removed the silent-break ceiling once a tenant accumulates >200 historical requests. Test coverage: [`tests/test_cmdb/test_scope_request_single_fetch_2026_05_31.py`](../../server/tests/test_cmdb/test_scope_request_single_fetch_2026_05_31.py) (5 cases: admin reads any / requester reads own / non-admin non-requester 403 / missing 404 / non-UUID input 404 not 500).
> - **Performance index** — reuses `ix_audit_logs_resource_lookup (resource_type, resource_id, created_at)` already added in migration 180 for the `/audit/resources/history` endpoint. Query pattern is identical (`WHERE resource_type=:t AND resource_id=:id ORDER BY created_at`), so the existing composite is the optimal shape for this surface too. Secondary sort tiebreaker on `audit_logs.id` for stable ordering when multiple events share `created_at`.
> - **Frontend surfaces** — new components [`CloudLifecycleTimeline`](../../web/src/components/cloud/CloudLifecycleTimeline.tsx) (renders one `Timeline.Item` per event with status badge + actor + redacted detail) and [`CloudGrantTimelineModal`](../../web/src/components/cloud/CloudGrantTimelineModal.tsx). Integrated into:
>     - **Scope-request detail drawer** ([`CloudScopeRequestDetailDrawer`](../../web/src/components/CloudScopeRequestDetailDrawer.tsx)) — timeline section auto-renders below the metadata descriptions.
>     - **New "申请记录" tab** on both [`MyCloudScopeDetailPage`](../../web/src/pages/cloud/MyCloudScopeDetailPage.tsx) and [`CloudScopeDetailPage`](../../web/src/pages/cloud/CloudScopeDetailPage.tsx) — one row per grant (sorted by `granted_at` desc) with a "view timeline" pivot per row. Directly answers "owner 能看到这个订阅下的权限申请的记录" by giving owners a single tab that lists every access request applied against their scope without having to cross-reference members + history tabs.
>     - **Per-grant action column** on the members tab's expanded sub-table + the HistoricalGrantsTab actions — every grant row gets a "view timeline" button that opens the modal.
>     - Modal state is reset on `scopeId` route change so the timeline doesn't survive scope navigation.
> - **MCP tools** — `get_cloud_scope_request_timeline` and `get_cloud_access_grant_timeline` registered with the same RBAC as the HTTP endpoints. `_REGISTERED_TOOLS_COUNT_HINT` 153 → 155.
> - **Why audit_logs vs a new table?** Audit is the authoritative trail per CLAUDE.md ("Audit by design"); adding a second event store would force every emission point to dual-write, inviting silent drift. Every needed audit row already exists — the only thing missing was indexed lookup + a curated read surface. Single source of truth preserved.
>
> ----
>
> **Earlier** (2026-05-19, Round-37 — `cmdb_cost_facts` UPSERT path was missing stale-fact eviction; SP-Negation-late-arrival in AWS BQ CUR + driver `if native == 0.0: continue` left phantom positive facts that UPSERT never overwrote; bit `pingcap-china-outbound` +$165/16d and ~10 other AWS scopes; ingest service layer only, 0 migrations, 0 MCP, 0 RBAC):
>
> - **Bug class** — `cmdb_cost_facts` is UPSERT-keyed on `fact_key = date × integration × scope × resource_id × usage_type`, but the prior ingest path NEVER issued a DELETE. When a previous cron run emitted a fact for a natural key whose later BQ snapshot sums to $0 (driver-side `if native == 0.0: continue` skips it), the stale row survived indefinitely as a phantom positive cost. Canonical trigger on AWS: SP CoveredUsage row published 12–36h before its paired SP Negation row → the first run ingests CoveredUsage as net-positive → later runs see net-zero and skip → stale positive row never overwritten. Symmetric trigger on any provider whose driver's natural-key set shrinks across runs (usage_type vanishes, resource_id reclassified, etc.).
> - **Fix** ([cost_service.py::sync_cost_facts_for_integration](../../server/app/domains/cmdb/cost_service.py)) — Inside the per-scope iteration, after a successful driver return (empty OR non-empty), issue a `DELETE FROM cmdb_cost_facts WHERE integration_id=? AND scope_id=? AND date=?` per-day. **Current shape (Round-39):** per-day outer loop + per-1000-row inner `text("DELETE … LIMIT :n")` chunking, on top of Round-38's `ix_facts_scope_date (scope_id, date)` index (migration 181). The Round-38 attempt to drop the chunking and rely on the index alone was proven wrong on 2026-05-18 — the index gives TiDB a tight `IndexRangeScan` plan but does not bound row-payload total memory, and the ~50k rows/day × ~16 KiB JSON `raw` on the AWS PingCAP payer scope still blew the 1 GiB `tidb_mem_quota_query` cap. Tenant-wide drivers (`scope_ref=None`) are still NOT covered — facts span multiple scopes within one driver call and DELETE bounds would need different derivation; no current cost_facts provider goes through that path so impact is zero, TODO noted in code.
> - **Idempotency invariant** restored: each cron run replaces the entire (integration, scope, window) sub-table with the driver's current snapshot. UPSERT remains for chunk-level safety (mid-window partial-failure recovery).
> - **What does NOT need to change**: driver SQL on either side (`_list_billing_daily_bq_sync`, `_list_cost_facts_bq_sync`) — verified mathematically equivalent on the BQ raw ($411.93 for `pingcap-china-outbound` 5/1–5/16); the `if native == 0.0: continue` in `_list_cost_facts_bq_sync` stays (zero-sum groups contribute $0 anyway); per-batch commit pattern in `ingest_cost_facts` stays.
> - **Backfill (operator action after merge)** — `POST /admin/cmdb/cost-facts/backfill` (or `scripts/backfill_cost_facts.py`) for the May 1–16 window will now produce CORRECT numbers (post-fix DELETE-then-UPSERT is what makes backfill load-bearing — the Round-32 / Round-34 / Round-36 commentary that backfill doesn't fix SQL-source bugs was correct for those rounds; this round's bug IS in the ingest path so backfill IS the fix vehicle).
>
> ----
>
> **Earlier** (2026-05-18, Round-36 — `list_billing_daily` 改用 `DescribeInstanceBill`,Round-33 的 BillOwnerId 不够,Reseller mode 才是真根因;zero migrations; no new MCP tools; no RBAC role; 1 BLOCKER):
>
> **Round-33 部署后立即发现 fix 不彻底。** `BillOwnerId=<sub>` 在 Reseller-mode RD master 上无效:Aliyun BSS `QueryAccountBill` 返回的是「sub 自己应付的账单」,但 Reseller mode 下 master 帮 sub 付钱(cost_facts raw 字段验证:每行 `BillAccountID=master + OwnerID=sub + Item="PayAsYouGoBill"`),所以 sub 的 own bill 只剩跨账号残留费(Data Transfer / K8s mgmt / Cloud Monitor / ApsaraMQ)—— $7/day vs 真实 $4500/day。Round-33 加对了 BillOwnerId 但选错了 API。
>
> Round-36 把 `list_billing_daily` 从 `QueryAccountBill` 换到 `DescribeInstanceBill`(cost_facts 已经在用的 line-item API),父类 CN 驱动 + intl override 同步改。`DescribeInstanceBill` 默认返回 RD-consolidated 全清单,每行带真实 `BillAccountID` (payer) + `OwnerID` (resource owner),按 OwnerID client-filter 后 per-(date, scope) 聚合就是正确日总。
>
> - **父类** ([aliyun_cloud.py::list_billing_daily](../../server/app/integrations/drivers/aliyun_cloud.py)):sum 原始 `PretaxAmount` (CNY raw),`currency="CNY"`,downstream `_to_usd` 读 currency 字段决定是否转 USD(向后兼容)。CN dormant 但同病,symmetry 修。
> - **intl override** ([alicloud_intl_cloud.py::list_billing_daily](../../server/app/integrations/drivers/alicloud_intl_cloud.py)):逐行 `_convert_pretax_amount` 转 USD,`currency="USD"`。同时移除 Round-33 加的 `BillOwnerId` 参数(DescribeInstanceBill 不需要,client-side OwnerID filter 已处理)。
> - **结果**:`list_billing_daily` 与 `list_cost_facts` 同源,per-(date, scope) 数字 1:1 一致(`SUM(cost_facts.amount_usd) GROUP BY date, scope == billing_daily.total_cost_usd`),by construction。
> - **重新 backfill**:Round-33 跑过的 7-chunk billing_daily backfill 写入的还是 pre-fix 小数字;Round-36 部署后需要重跑相同的 backfill 命令让 billing_daily 真正对齐 cost_facts。脚本 idempotent,`dispatch_alerts=False`,不发卡。
> - **R3-F3 pre-stage 仍有效**:126 行 `cmdb_cloud_scope_alert_fired` 占位继续保护 5/17-5/23 spike 不发卡。
> - **5 scope 阈值已校准**(Round-33 期间执行)— cost_facts May MTD × 1.2 设的 100% budget。
> - **测试**:`x-acs-action: DescribeInstanceBill` + sibling sub client-filter pin,37 个 aliyun/intl 测试全绿。
>
> ----
>
> **Earlier same-day** (2026-05-19, 电脑更换 repoint to new Feishu approval code + upload-write-timeout fix — [`migration 183`](../../server/alembic/versions/183_update_laptop_replacement_approval_code.py) UPDATEs `integration_approval_flows.external_code` for `flow_key='laptop_replacement'` from `3DF39DB6-…` to `7694D6BE-7FEE-4E49-96B5-A106FC4623DD` (the new Feishu definition with refreshed widget layout + display_condition gates) and DELETEs `platform_settings['approval.asset_template_cache']` so the next `/approval/asset/templates` read refetches the schema. UPDATE is guarded on the old code (admin-edited rows are not clobbered). `feishu_client.py` upload paths got an explicit `httpx.Timeout(connect=10, read=30, write=180, pool=10)` because the 30 s default was timing out 5–10 MB uploads on slow links; `_request` also gained WARNING-level logging on every Feishu non-zero `code` so opaque rejections are diagnosable from log alone. **Note**: image-widget upload to `/im/v1/images` requires the Feishu app to have the `im:resource:upload` (or `im:resource`) scope — this app does not yet, so the next admin action is granting the scope at `https://open.feishu.cn/app/cli_a9257636ecf91bcb/auth?q=im:resource:upload,im:resource`. `attachmentV2` widgets continue to work via `/drive/v1/medias/upload_all` (drive scope already granted).
>
> **Earlier same-day** (2026-05-19, laptop_replacement / 离职回购 supplement-uploads — Feishu approval-instance comments channel; zero migrations; **+1 MCP tool** `supplement_request_uploads` (152 → 153); no RBAC role):
>
> - **In-flight approval supplementation** — Feishu locks form fields on submitted approval instances. The sanctioned channel to add material later is the approval-instance comment stream (`POST /approval/v4/instances/{instance_code}/comments`); approvers see the comment + attachments inline on the approval card. New [`FeishuApprovalClient.add_instance_comment`](../../server/app/approval/feishu_client.py) wraps this.
> - **New endpoint** `POST /api/v1/asset/employee-requests/{id}/supplement-uploads` ([asset/router.py](../../server/app/domains/asset/router.py)) accepts `{uploads: {widget_id: [tokens]}, note?}`; requester or asset_admin; only while `status='pending'`; only image / attachmentV2 widgets. Service writes the new tokens into `detail.feishu.form_snapshot.<widget>.answer` (appended) AND an audit-trail entry into `detail.feishu.supplements[]` (actor + at + per-widget summary + note) so admins can answer "who supplemented what when" without scanning audit_logs.
> - **Frontend UX** — new "补传材料 / Supplement Uploads" tab in [`ApprovalDetailModal`](../../web/src/components/ApprovalDetailModal.tsx) (shown only when form_snapshot has an upload widget AND `status=pending`, OR when a supplement history exists). Create-form upload widgets now render an `asset.request.uploadCanSupplement` hint when `widget.required === false`, so requesters know they can defer the screenshot / video. Component: [`RequestSupplementUploads`](../../web/src/components/RequestSupplementUploads.tsx).
> - **MCP tool** `supplement_request_uploads` registered alongside the HTTP endpoint (MCP-sync rule). `_REGISTERED_TOOLS_COUNT_HINT` 152 → 153.
> - **Why not field-mutation?** Feishu Open Platform offers no API to mutate the form payload of a submitted approval instance. The Feishu form designer's "提交人可申请修改 N 天内通过的审批" toggle applies only AFTER terminal approval (one-time, time-windowed) and does not solve the in-flight gap. Comments-with-attachment is the only in-flight, approver-visible channel.
>
> **Supplement panel now PREVIEWS already-attached files** (2026-06-03). The original panel only showed a "已有 N 个文件" count — the `form_snapshot` answer holds opaque Feishu file tokens (`7CB7BB98-…`), which are unviewable. The real signed URLs live in `detail.feishu.latest_instance_detail.form`, which the detail endpoints refresh (`refresh=true` default) and persist on every drawer open. New [`extractExistingUploadUrls`](../../web/src/components/formSnapshot.ts) parses that form for image / attachmentV2 widget URLs (handles both bare-URL-string arrays and `{url,title}` objects); [`ApprovalDetailModal`](../../web/src/components/ApprovalDetailModal.tsx) passes the per-widget-id map into [`RequestSupplementUploads`](../../web/src/components/RequestSupplementUploads.tsx), which renders image widgets as `<Image.PreviewGroup>` thumbnails and attachmentV2 as new-tab download links. Employee view still iterates only the approver-only-filtered snapshot widgets, so nothing leaks.
>
> **员工申请列表 keyword 现在也搜申请人** (2026-06-03). [`list_asset_approval_requests`](../../server/app/approval/service.py) `keyword` previously matched `request_number` + `reason` only; now adds `requester_id IN (SELECT id FROM identity_persons WHERE display_name/work_email ILIKE …)` — same IN-subquery shape the unified `list_admin_approval_requests` has long used (no row-count change vs an outer join). [`EmployeeRequestPage`](../../web/src/pages/asset/EmployeeRequestPage.tsx) admin view also gains an applicant column (name + email; hidden on the self-scoped `/my/requests` view). Search placeholder updated in zh/en/ja.
>
> **+ 独立的「按申请人」筛选字段** (2026-06-03 follow-up). On top of the keyword substring match, `/asset/requests` + `list_asset_approval_requests` gained an exact `requester_id` param (admin views; stacks safely with `force_self`). The admin filter bar renders the shared [`UserSelect`](../../web/src/components/UserSelect.tsx) person-picker (NOT a client-filtered prefetch — honours the person-picker contract) emitting `requester_id`. Mirrors the unified 审批中心 [`AllApprovalsPage`](../../web/src/pages/admin/AllApprovalsPage.tsx) two-track pattern (keyword search + dedicated requester picker side by side).
>
> ----
>
> **Earlier** (2026-05-18, Round-35 — Bailian autogrant was handing employees RAM AK/SK with a help-doc snippet claiming `dashscope.access_key_id` works as SDK auth; the DashScope SDK only reads `dashscope.api_key` / `DASHSCOPE_API_KEY` and threw `No api key provided` for every employee credential issued before this fix; reported by `liwen.xu@pingcap.com`; zero migrations; one new admin HTTP endpoint; no new RBAC role; no new MCP tool surface (existing `ListApiKeys` Bailian tool unchanged)):
>
> - **Root cause**: [`bailian_autogrant.py`](../../server/app/domains/apikey/bailian_autogrant.py) minted a RAM sub-account, attached `AliyunBailianFullAccess`, and called `CreateAccessKey` to produce an LTAI… / secret pair. The bundle written to `assignment.key_value` was `ACCESS_KEY_ID=… / ACCESS_KEY_SECRET=… / WORKSPACE_ID=… / DASHSCOPE_ENDPOINT=…`, and the help-doc / reveal-modal example wired those into `dashscope.access_key_id` / `dashscope.access_key_secret`. **The DashScope Python SDK does not consult those attributes** — its `_get_api_key()` only walks `dashscope.api_key`, `DASHSCOPE_API_KEY` env, then `dashscope.api_key_file_path`, and throws `No api key provided` for everything else. The "RAM-auth mode" wording in the prior code's docstring was wishful thinking; AK/SK is the auth mode for the **management** plane (Bailian/RAM Open API), not the **inference** plane (DashScope SDK). Earlier comment claiming `CreateApiKey` is "console-only" was outdated for the international tenant — ModelStudio/2026-02-10 has had a programmatic `CreateApiKey` action since rollout.
> - **Fix**: [`BailianClient.create_api_key`](../../server/app/domains/bailian/client.py) + [`BailianClient.delete_api_key`](../../server/app/domains/bailian/client.py) wrap ModelStudio `POST /modelstudio/apikeys` and `DELETE /modelstudio/apikeys/{id}`. [`maybe_auto_grant_bailian`](../../server/app/domains/apikey/bailian_autogrant.py) now (a) still creates the companion RAM sub-account + attaches the policy for audit / `cmdb_iam_users` visibility, BUT (b) does NOT mint an AccessKey for that user — the RAM identity is anchor-only — and (c) calls `CreateApiKey` against the resolved default workspace using the master integration's AK to mint a real sk- DashScope API key. New bundle shape: `API_KEY=sk-… / WORKSPACE_ID=… / DASHSCOPE_ENDPOINT=… / REGION=… / API_KEY_ID=<numeric> / RAM_USER_NAME=…`. The numeric `api_key_id` is stamped into `detail.bailian.api_key_id` so the revoke path can call `DeleteApiKey` to invalidate the sk- before tearing down the RAM user.
> - **Revoke order**: [`ApiKeyService._revoke_cloud_resources`](../../server/app/domains/apikey/service.py) now invalidates the sk- via `BailianClient.delete_api_key` BEFORE dispatching the RAM-user teardown via the Aliyun driver. Order matters: a leaked sk- loses inference access on the first call, even if the slower RAM teardown fails or is retried. Idempotent on `NotFound` (sk- already deleted in console).
> - **Sibling-page coverage**: detail.bailian also now stamps `iam_user_name` (mirror of `ram_user_name`) so the existing `_dispatch_cloud_revoke(kind="bailian", iam_user_name=…)` actually fires — pre-fix the Bailian branch wrote `ram_user_name` only and the revoke path looked up `iam_user_name`, meaning legacy Bailian RAM users were never cleaned up on revoke (Bedrock + Azure OpenAI already used `iam_user_name`; this aligns the trio).
> - **Migration path for liwen.xu and ~N legacy assignments**: new admin endpoint [`POST /api/v1/apikeys/assignments/{id}/bailian/reissue`](../../server/app/domains/apikey/router.py) → [`ApiKeyService.reissue_bailian_api_key`](../../server/app/domains/apikey/service.py) → [`bailian_autogrant.reissue_bailian_api_key`](../../server/app/domains/apikey/bailian_autogrant.py). Re-uses the existing detail.bailian integration + workspace, calls `CreateApiKey` to mint a fresh sk-, rewrites the bundle, preserves the legacy `api_key_id` (RAM AK ID for pre-fix rows) under `detail.bailian.legacy_api_key_id`. UI button: ApiKeyManagementPage actions column shows `ReloadOutlined` for any Bailian-family assignment in `approved`/`active` status (Popconfirm-gated; not idempotent on the Aliyun side). Audit trail: `apikey.key.reissue` action with `provider=bailian` + the new api_key_id.
> - **Help center + reveal modal**: [`HelpCenterPage.tsx:bailian-employee`](../../web/src/pages/help/HelpCenterPage.tsx), [`UserItUniverseTab.tsx::RevealHowTo`](../../web/src/pages/admin/UserItUniverseTab.tsx), and the `helpCenter.employee.bailian.*` + `apikey.reveal.bailian.*` i18n bundles in zh-CN + en-US + ja-JP all switched to `dashscope.api_key = "<API_KEY>"`. Added an OpenAI-SDK example block (`https://dashscope-intl.aliyuncs.com/compatible-mode/v1`) since the OpenAI-compatible mode is the more familiar surface for many users.
> - **Notification text** rewritten: prior text told the employee "this is a RAM AccessKey/Secret pair (LTAI...) for the DashScope SDK in RAM-auth mode" — the SDK doesn't have a "RAM-auth mode". New wording: "This is a DashScope API key (sk-...). Plug it into ``dashscope.api_key`` or the ``DASHSCOPE_API_KEY`` env var and you're ready to call the models."
> - **No backfill cron / migration needed**. Existing legacy rows' bundles stay decryptable (no schema change) — admins reissue them one-by-one via the new button when employees report the SDK error, or proactively in bulk by hitting the endpoint for every Bailian assignment with `granted=True` and no `detail.bailian.api_key_id`.
>
> ----
>
> **Earlier same-day** (2026-05-18, Round-34 — drawer / sparkline zero-fill rendered as misleading `$0 spend` bars on trailing un-ingested days; `_projection` `days_elapsed` anchored on `last_fact_date` for current month (past months keep finance-conventional `total/days_in_month`); data-lag warning threshold `> 2` → `>= 2` (off-by-one fix); follow-up to Round-32 cost_facts cron window; zero migrations; no new MCP tools; no RBAC role):
>
> **Triggered by `aws-byoc-deployment` user report 2026-05-18.** Drawer showed chart bars at $0 for the latest 3 days (5/15, 5/16, 5/17 — cost_facts max_date was 5/16), making the user think they had spent nothing. Round-32 (just earlier today) widened the cost_facts cron window from 1d to 3d to fix the upstream data-lag, but the rendering layer's zero-fill across the whole month was a **separate, independent bug** — even with cost_facts fully caught up, the chart still drew a trailing $0 spike for "today" since `_month_bounds` clamps `month_end` to today.
>
> - **Root cause** ([scope_billing_detail.py::_daily_trend + _billing_daily_fallback](../../server/app/domains/cmdb/scope_billing_detail.py) + [apikey/spend.py::_sum_with_service_patterns](../../server/app/domains/apikey/spend.py) + [ai_spending/service.py::get_trend](../../server/app/domains/ai_spending/service.py)). Whenever cost_facts lagged today by 1+ days, the daily-trend series was zero-filled across the whole month (`per_day.get(cur, Decimal("0"))`); the un-ingested trailing days rendered as $0 bars on the chart ("I spent nothing yesterday"), AND those zeros got included in `month_total` while `days_elapsed` was anchored on `month_end` clamped to today — dividing $710 by 18 days-to-today instead of 16 days-to-last_fact_date understated `daily_average` and `month_end_projection` by 11%.
> - **Fix #1: backend series truncation**. `_daily_trend` and `_billing_daily_fallback` both truncate at `last_fact_date` — no trailing zeros emitted. `_projection` takes `last_fact_date` as a new kwarg and anchors `days_elapsed` on it for the CURRENT month (past months still use `month_end` per finance convention: `total / days_in_month`, to avoid inflating daily_avg on partially-backfilled past months). AK spend panel's `_sum_with_service_patterns` got the same truncation; three buckets (foundry / bedrock / vertex) share this function.
> - **Fix #2: sibling-page coverage**. AI Spending dashboard's `get_trend` (daily granularity) had the same zero-fill pattern and was fixed in lockstep — `/admin/ai-spending` trend chart now truncates at the last day with data instead of showing $0 trailing bars. Monthly granularity in `get_trend` uses `YYYY-MM` grouping and doesn't zero-fill, no change.
> - **Fix #3: data-lag warning off-by-one**. [ScopeBillingPanel.tsx:523](../../web/src/components/cloud/ScopeBillingPanel.tsx) + [AssignmentSpendPanel.tsx:156](../../web/src/components/apikey/AssignmentSpendPanel.tsx) threshold `if (ageDays > 2)` did not fire on a 2-day lag (`last_fact_date = today - 2`), exactly the case `aws-byoc-deployment` was in. Changed both surfaces to `>= 2`.
> - **MCP tool descriptions updated** for `get_api_key_spend` and `get_cloud_scope_billing_detail` to document the new truncation contract: `daily_series` / `daily_trend` runs from month_start through `last_fact_date`, NOT zero-filled past the last day with data. AI clients should cite `last_fact_date` as the freshness anchor. `cost_timeseries` MCP tool (power-user analytic surface with user-supplied start/end) kept its existing zero-fill contract — different surface, different semantics.
> - **Intentional exceptions** (NOT fixed, kept by design): `_zero_spend` placeholder shape for AK buckets with `data_available=False` (Bailian, unknown) — FE renders caveat banner in place of sparkline so zeros never reach bars; `cost_timeseries` HTTP endpoint and MCP tool — caller-supplied explicit window. Mid-month single-day gaps (cron failed on one day but recovered) still zero-fill inside the kept range — chart "holes" mislead worse than a single $0 dip.
> - **5-round audit clean** before merge:
>   - **Round 1 (code review)**: caught my own bug — initial draft anchored `days_elapsed` on `last_fact_date` for past months too, which inflated daily_avg on partially-backfilled past months. Fixed to keep `month_end` anchor for `is_partial=False`.
>   - **Round 2 (logic scan)**: agent flagged 2 BLOCKERs, both turned out to be false positives (the agent's own F-1 admitted "semantically correct per your docstring"). Verified time-zone (`datetime.now(UTC)` everywhere), empty cost_facts edge case, mid-month gap behaviour, cross-source consistency.
>   - **Round 3 (security)**: verified `sync_cost_facts_for_integration` has NO alert dispatch (grep `_alert_spike` / `evaluate_scope_alert_rules` / `maybe_send_budget_alert` / `Feishu` / `notify` / `push_notification` returns ZERO callsites in the cron path or its callees). BQ scan amplification concern: `_bq_month_predicate` already does partition pruning by Hive `year` + `month` columns, so the 3-day window scans at most 2 partitions (worst case month-boundary crossing) vs the old 1-day window's 1 partition. `_BQ_MAX_BYTES_BILLED = 50 GB` cap bounds any runaway. Lock `cloud_sync:cost_facts:{integration_id}` ttl=900s held by HTTP, MCP, cron, and backfill — no concurrency regression.
>   - **Round 4 (sibling-page coverage)**: discovered AI Spending `get_trend` had the same zero-fill pattern; fixed in lockstep. Verified `cost_service.timeseries`, `top_resources`, `admin_overview_service::top_spenders`, `_cost_by_service` all use SUM aggregations (not daily series) and are unaffected.
>   - **Round 5 (final combined audit)**: caught 3 docstring lies claiming `daily_series` is "zero-filled across the requested month" when the fix had changed that contract; updated `ApiKeyService.get_assignment_spend` docstring, `compute_assignment_spend` docstring, `_zero_spend` docstring (clarifies the zero-fill there is intentional because `data_available=False` short-circuits FE rendering).
> - **Regression tests** ([test_scope_billing_detail.py](../../server/tests/test_cmdb/test_scope_billing_detail.py) + [test_assignment_spend.py](../../server/tests/test_apikey/test_assignment_spend.py)): `test_billing_detail_truncates_trailing_missing_days` (current-month, asserts series ends at `last_fact_date`, `days_elapsed` anchored there, `daily_avg = total / days_to_last_fact_date`); `test_billing_detail_past_month_uses_full_calendar_for_daily_avg` (past month with single fact on day 5, asserts `days_elapsed == days_in_month` and `daily_avg = total / 31`, NOT `total / 5`); `test_daily_series_truncated_at_last_fact_date` (AK spend, asserts mid-month gap still zero-fills inside the kept range).
> - **No backfill needed**: Round-32's widened cron window already self-heals cost_facts data; Round-34 changes only display-layer truncation + projection anchor + warning threshold. Next page load picks up the new rendering.
>
> ----
>
> **Earlier same-day** (2026-05-18, Round-33 — alicloud_intl + aliyun `list_billing_daily` missing `BillOwnerId` parameter → RD member sub-accounts undercounted ~200× on the daily-billing surface while cost_facts had the correct numbers all along; zero migrations; no new MCP tools; no RBAC role; 1 BLOCKER):
>
> **`list_billing_daily` 在 RD master setup 上漏传 `BillOwnerId` → 子账号每日账单只反映 master 自付的跨账号费（Data Transfer / K8s mgmt / Cloud Monitor / ApsaraMQ），ECS/EBS/OSS/SLB 全部不见。** Prod 实测（2026-05-18，alicloud_intl prod tenant，May 1-18 区间，18 个 RD sub-account scopes）：cost_facts 日总稳定 ~$8K/day，billing_daily 日总 ~$50/day，**比例 165-200×** —— 不是 cost_facts inflation，是 billing_daily 在 master payer 上调用 `QueryAccountBill` 时**没传 `BillOwnerId=<sub>`**，Aliyun BSS 文档明确该模式下只返回 calling account 自己的账单，RD 成员上的资源消耗不在 response 里。两表在重叠服务上数字精确一致（`Cloud Data Transfer` 验证：`$7.0352 == $7.0352` for dataplane-001 on 2026-05-15），证明差异来自缺失行不是数值漂移。`list_cost_facts` 用 `DescribeInstanceBill`，该 API 默认返回 RD-consolidated 全清单，每行带真实 OwnerID，所以 cost_facts 长期一直正确；只有 daily-billing 这条路径中招。
>
> - **Fix** ([aliyun_cloud.py::list_billing_daily](../../server/app/integrations/drivers/aliyun_cloud.py) + [alicloud_intl_cloud.py::list_billing_daily](../../server/app/integrations/drivers/alicloud_intl_cloud.py)) — params 在 `scope.external_id` 存在时增 `BillOwnerId=scope.external_id`。同补丁覆盖父类 CN 驱动 + intl override（intl override 因 USD-currency 语义自有，但 BillOwnerId 漏传是同病）。Standalone-IAM 模式 caller==external_id 时 Aliyun 视为 no-op，无副作用。Client-side OwnerID filter 保留作为 defense-in-depth。
> - **Pre-deploy spike-alert dedup**（R3-F3 mitigation，合并前已执行）：向 `cmdb_cloud_scope_alert_fired` 预 INSERT 126 行（18 scope × 7 day，period_key=2026-05-17..2026-05-23，rule_type=daily_spike，threshold_usd=1.5）。原因：`lifecycle_service.py:6946-7114` call-site spike 检测在每次 daily-billing 写入后比较 `today_usd > avg×1.5 AND delta>$10`，首 cron tick 的 trailing 7d window 还是 pre-fix 数据（avg ≈ $0-$16/scope/day），今日 post-fix（$20-$4500/scope/day）会触发 11/18 scope 各发一张飞书 spike 卡。Pre-stage rows 占用 UNIQUE(scope_id, rule_type, period_key, threshold_usd) slot → `_try_claim_cloud_scope_alert_slot` INSERT IGNORE 落空 → 不发卡。7 天后 trailing window 已被 daily cron 自然替换为 post-fix → spike 检测恢复正常。完全可逆（`DELETE WHERE rule_type='daily_spike' AND period_key BETWEEN '2026-05-17' AND '2026-05-23' AND scope_id IN (...)`）。
> - **Influence radius**: 仅影响 daily-billing 路径（`cmdb_cloud_billing_daily` 写入端），cost_facts 端不变。线上 alicloud_intl 单 enabled cloud integration（`alicloud-intl-primary`，18 个 active sub-account scopes），CN aliyun integration（`aliyun-primary`）目前 `enabled=0` 无 scopes 不在受影响清单内但同样 fix。
> - **Tests** ([test_aliyun_cloud_driver.py::test_list_billing_daily_sends_bill_owner_id](../../server/tests/test_integrations/test_aliyun_cloud_driver.py) + [test_alicloud_intl_cost_facts_currency.py::test_intl_list_billing_daily_sends_bill_owner_id](../../server/tests/test_integrations/test_alicloud_intl_cost_facts_currency.py)) — 父类 + intl override 各自一条 respx-mocked behavioural test，断言 `QueryAccountBill` 请求查询串里出现 `BillOwnerId=<external_id>`。56 个既有 aliyun/alicloud_intl 测试 0 regression。
> - **Backfill** (operator action after merge)：`uv run python scripts/backfill_cost_facts.py --prod --integration-id 62d312e2-589a-4fa2-894c-f38b6ac59f87 --months 6 --skip-recompute`。脚本走 `dispatch_alerts=False` 路径 → backfill 不发飞书卡片（2026-05-16 prod incident 已确认该 flag 在 [`backfill_cost_facts.py:396`](../../server/scripts/backfill_cost_facts.py:396) 行为正确）。cost_facts 路径同步触发但本身数据正确所以是 idempotent no-op，只是消耗 Aliyun BSS API 配额。
>
> ----
>
>
> ----
>
> **Earlier same-day** (2026-05-18, Round-32 — cost_facts cron default window 1→3 days, parity with billing_daily; zero migrations; no new MCP tools; no RBAC role):
>
> **GCP / Azure `cmdb_cost_facts` 较 `cmdb_cloud_billing_daily` 系统性少 5–12% 修复。** Prod 验证 (2026-05-18, May 1–18):每个 GCP scope / Azure subscription **older days (5/01–5/13) cf=bd 100% 完美匹配**,只在最近 2–3 天 cf < bd (per-day 例:`pingcap-testing-account` 5/14 cf=$1382 bd=$1835 ratio=0.753 / 5/15 cf=$1079 bd=$1922 ratio=0.561 / 5/16 cf=$0 bd=$1191)。根因不是折扣 / credit / 货币 / Marketplace bucket,而是**两条 cron 默认窗口不对称**。
>
> - **`sync_cost_facts_for_integration` 默认窗口 1 → 3 天** ([cost_service.py:2495](../../server/app/domains/cmdb/cost_service.py)) — Pre-fix `start_date = end_date = yesterday` 单日窗口。Post-fix `start_date = end_date - timedelta(days=2)`,与 `sync_billing_daily_for_integration` ([lifecycle_service.py:6831](../../server/app/domains/cmdb/lifecycle_service.py)) 的 3-day 滚动一致。背景:云账单 export 持续往后追加 late-arriving rows 约 2–3 天(GCP BQ billing export 回填 usage + SUD/CUD credit,Azure Cost Management retroactive re-rate,AWS CUR re-publish day partitions ~24–48h)。billing_daily 3-day 重抓全部抓得到,cost_facts 单日窗口只抓 ~24h 内 ingested 行,**永远漏 5–12% 不会再补**。UPSERT 按 `fact_key = date × integration × scope × resource × usage_type` 幂等覆盖,重跑同日安全。无 alert 依附 cost_facts,故 BQ scan 成本 ~3× 之外零副作用(billing_daily 已经 3-day 跑,代价类似)。
> - **次级问题**:GCP integration cost_facts cron 在 2026-05-17 因 TiDB `Lock wait timeout exceeded` 失败,导致 5/16 数据未写入(`tidbcloud-prod-000` / `gcp-autonomous-prod` 等 6 个 scope cf=$0 完全缺失)。Pre-fix 默认窗口下,单日 cron 失败 = 该日永久丢失。Post-fix 下次 cron tick 自动重抓最近 3 天,5/16 会自动补回。
> - **回归测试**:`test_sync_cost_facts_default_window_is_three_day_rolling` ([test_cost_service.py](../../server/tests/test_cmdb/test_cost_service.py)) — 不传 start/end_date,断言 driver 被以 `(yesterday-2, yesterday)` 调用。同 PR 顺手修了 sibling `test_sync_cost_facts_skips_sub_tenant_scope_rows` 的 stub driver 缺 `bq_billing` kwarg(pre-existing,与本 fix 同文件)。
> - **历史数据**:5/14 / 5/15 的 cf 仍偏低 $453 / $843(pingcap-testing-account),5/16 GCP 仍 cf=$0;下次 cron tick (~04:xx UTC) 自动补抓 5/15–5/17;5/14 不在新窗口内,如需补需手动 `cost_backfill` MCP 工具或 HTTP `/cmdb/cost/backfill` (start=2026-05-14, end=2026-05-14)。AWS / Alicloud Intl / Ucloud / Tencent 的窗口默认同样从 1 天放宽到 3 天 (副作用对称改善),它们 cost_facts 与 billing_daily 是否有其他 driver 层 bug 在别的会话排查。
>
> ----

> **Earlier** (2026-05-18, My API Key detail page + per-model spend breakdown; zero migrations; **+1 MCP tool** `get_api_key_spend_detail` (148 → 149); no RBAC role):
>
> - New `GET /api/v1/apikeys/assignments/{id}/spend/detail?month=YYYY-MM` endpoint backing the new [/me/api-keys/:assignmentId](../../web/src/pages/my/MyApiKeyDetailPage.tsx) per-AK detail page. Payload is a superset of `/spend` plus `monthly_series` (6 calendar-month totals oldest-first; current month is MTD-to-`today_shanghai`) and `by_model` (per-`cmdb_cost_facts.usage_type` rows sorted by spend desc).
> - **Model-level breakdown works on the existing schema**: Azure writes `MeterSubCategory` into `cost_facts.usage_type` (e.g. `"Azure OpenAI - GPT-4o Input Tokens"`), AWS writes `line_item_usage_type` (e.g. `"USE2-Bedrock:Claude-3-5-Sonnet-Inference-Input-Tokens"` — present even on the no-resource-id CUR exports because model lives on a different CUR column than `line_item_resource_id`), GCP writes the SKU name. NULL usage_type rows collapse to a single `"(uncategorised)"` bucket so sum-of-rows reconciles to `mtd_usd`.
> - `effective_unit_price_usd = sum(amount_usd) / sum(usage_quantity)` per row — billing-derived **average rate**, NOT the official per-token list price. `null` when quantity is zero / missing (UI renders "—"). UI tooltip + MCP description explicit.
> - Refactor in [`server/app/domains/apikey/spend.py`](../../server/app/domains/apikey/spend.py): new `_resolve_bucket_filter` collapses the Foundry / Bedrock / Vertex / Bailian short-circuit logic into one helper returning either a `zero_caveat` or an `(integration_id, service_patterns, extra_where)` recipe. Both `compute_assignment_spend` (panel + `apikey.budget_check` cron) AND the new `compute_assignment_spend_detail` route through it — panel MTD, cron alert, detail KPI, and model-table sum-of-rows reconcile to the cent **by construction** (regression pinned by `test_detail_mtd_matches_basic_endpoint`).
> - `by_model = []` + zero-filled 6-row `monthly_series` for buckets without per-AK data (Bailian: account-only BSS; missing IAM binding; missing RG binding; unknown provider) — UI renders the caveat banner as primary content.
> - Admin [`/admin/assets/api-keys`](../../web/src/pages/asset/ApiKeyManagementPage.tsx) `request_number` column links to the same detail page (sibling-page coverage; server-side auth allows asset_admin / it_admin to view any AK).
> - **MCP tool**: `get_api_key_spend_detail` registered alongside `get_api_key_spend`. `_REGISTERED_TOOLS_COUNT_HINT` 148 → 149.
> - 6 new tests in [`server/tests/test_apikey/test_assignment_spend_detail.py`](../../server/tests/test_apikey/test_assignment_spend_detail.py) cover happy path, zero-quantity, null-usage_type, Bailian unavailable, Foundry without RG, and the basic-vs-detail-parity regression. 32 existing apikey spend / budget-alert tests stay green (refactor preserved behaviour).
>
> ----
>
> **Earlier same-day** (2026-05-18, Round-31 — GCP 403 classifier 兼容 legacy `errors[]` body + 严格 message-pattern fallback for billing-disabled; zero migrations; no new MCP tools; no RBAC role; 1 BLOCKER + 0 HIGH + 0 MEDIUM):
>
> **GCP `partial_failure · http_403 × 1 [project:tidb-376913]` 无限循环上报修复。** 用户反馈"这个错误已经无数次了 能不能彻底解决一下" — Round-26/27 已经把现代 gRPC `details[].reason="BILLING_DISABLED"` 路由到 `skipped_legs`,但 GCP Compute V1 实际返回的是 **legacy REST body**(`error.errors[*].reason="billingNotEnabled"` + 顶层 `error.message` 以 "This API method requires billing to be enabled" 开头),分类器只覆盖了现代形态,legacy 形态一直当 `http_403` 真失败上报,**每次 cron 都 page 一次 IT admin,>24h 未止血**。
>
> - **GCP 403 分类器扩展 legacy `errors[*].reason`** ([providers/gcp.py::_classify_gcp_403_category](../../server/app/domains/cmdb/providers/gcp.py)) — 原 legacy 块只识别 `accessNotConfigured`(api_disabled);新增 `billingNotEnabled` / `billingDisabled`(billing_disabled)+ `consumerInvalid` / `accountDisabled`(consumer_invalid)同义词。同精度 = 现代路径,IAM denial dominance 守卫不变。
> - **GCP 403 strict message-pattern fallback for billing-disabled** ([providers/gcp.py:1067-1097](../../server/app/domains/cmdb/providers/gcp.py)) — 当 `details[]` 与 `errors[]` 都没结构化标记时,顶层 `error.message` 匹配 `requires billing to be enabled` / `please enable billing` / `billing account ... closed/disabled` 三种 imperative 短语(回避 `enabled billing` 过去分词,防 IAM denial remediation 提示误命中)→ `billing_disabled:unknown`。再加 `_GCP_REAL_PERMISSION_RE`(`Permission ... denied on` / `caller does not have permission` / `IAM_PERMISSION_DENIED` / `does not have access to`)反向守卫:即便正向命中,出现真权限词汇也回退 None,**绝不静默掩盖真 IAM gap**。
> - **失败-notifier 行为变化**:Round-31 起,任何 `tidb-376913` 这类 billing-disabled 项目下一次 cron 立即由 `partial_failures` 转 `skipped_legs`,`reason` 从 `http_403` 变成 `billing_disabled:unknown`,从 IT admin 的 Feishu 卡片消失,只留 sync-history 行可见。一次首发 deploy 会因 `compute_failure_signature` hash 变更产生**一次** dedup-miss 通知(同 Round-27 deploy 路径)。
> - **测试**:14 个新单测覆盖三层(modern → legacy → message-pattern)+ 三层交叉 precedence + IAM dominance + 过去分词 `enabled billing` regression + 端到端 leg routing。`pytest tests/test_cmdb/test_gcp_provider.py` 83/83 green。
> - **2 轮 code review + 1 轮 security/logic scan clean**:Round-2 review 发现 `enable billing on project` 模糊与 `enabled billing` 过去分词冲突 → 收紧到 imperative-only(`requires billing` / `please enable billing` / `billing account ... closed/disabled`)+ `[^\n]{0,80}` 替换无界 `.*` 加固 ReDoS 边界。Security scan 确认 `error.message` 内容不进入 leg label / DB / log(仅用于 regex match,detail 写死 `"unknown"`)。
>
> **不变 / 已确认干净的安全面**(Round-31 0 改动):IAM denial dominance(modern + legacy + message-pattern 三层均守卫)、`redact_provider_error` 全路径覆盖、`_GCP_SAFE_TOKEN_RE` 仍是 leg.reason 唯一插值入口、未引入新 partial-failures→audit 路径。
>
> ----

> **Earlier same-day** (2026-05-18, Round-30 — network module 12-agent supplementary pre-launch audit: 10 fixes (3 BLOCKER + 4 HIGH + 3 MEDIUM); zero migrations; no new MCP tools; no RBAC role):
>
> **网络模块(域名/DNS/CDN)全生命周期 12-agent 补充审计 — 4 轮 code review + 4 轮 logic + 4 轮 security。** Round-28(14-agent)/29(5-agent)上线前审计后,用户再追加专项审计,针对 Round-26/27/28 之后的残余风险。所有 finding 逐项对源码验证 — 10 是真 bug,其余为重复或 false positive。
>
> - **DNS owner change resets recertify dedup** ([service.py::update_dns_record_in_db / batch_update_dns_record_owner](../../server/app/domains/cmdb/service.py)) — PATCH `/dns-records/{id}` 或 batch 改 owner 时 `last_recertify_nag_at` / `last_owner_review_at` 置 NULL(单 PATCH 总是,batch 仅当 prev != new 跳过 idempotent retry)。Pre-fix:新 owner 会落在前任的 30 天 dedup 窗内,整整一个季度收不到首次 recertify 提醒。
> - **DNS recertify first-observation suppression** ([handlers.py::run_dns_owner_recertify](../../server/app/scheduled_jobs/handlers.py)) — 新增 `cmdb.dns.recertify_suppress_first_obs` platform_setting(默认 true),首轮 `last_owner_review_at IS NULL AND last_recertify_nag_at IS NULL` 行只 stamp `last_recertify_nag_at` 不发 DM,下次 cron tick 走正常路径。Pre-fix:首次启用 kill switch 会 carpet-bomb 每个 owner 收到全量历史记录。比照 `domain_expiry_alert` 已有的 prev=None 抑制。
> - **DNS request target row FOR UPDATE** ([dns_request_service.py::_execute_update/_execute_delete/_execute_set_status](../../server/app/domains/cmdb/dns_request_service.py)) — 三个 leg 把 `self.db.get(...)` 改成 `select(...).with_for_update()` 锁目标 `CmdbDnsRecord` 行。Round-28 给 request row 加锁防 webhook 重投,但目标行还在 race admin soft-delete。锁在 enclosing tx 提交前持有。
> - **DNS recertify DM 不含 record value** ([handlers.py:~2814](../../server/app/scheduled_jobs/handlers.py)) — 之前 DM 显示 `r.value[:60]`(IP/CNAME/TXT),value 经常编码内网拓扑(VPN 网关、staging endpoint、私网 IP、MX 路由),Feishu DM 永久缓存。改为只显示 `name.zone_name record_type`,owner 凭深链回 Helix 看 RBAC 受控的完整 detail。
> - **审批提交 30 req/h/user rate limit** ([core/dependencies.py::check_approval_submit_rate_limit](../../server/app/core/dependencies.py)) — 新 dep 挂在 `POST /cmdb/dns-record-requests` + `POST /domain-registrations`,Redis 滑窗,fail-open + 300ms 兜底。Pre-fix:任一 employee 可 POST 上万次淹没 Feishu 审批人 inbox + 写爆数据库。
> - **Audit `detail` JSON 16KB cap** ([audit/service.py::log](../../server/app/audit/service.py)) — `json.dumps(detail, default=str)` 超 16 KiB 替换为 `{_truncated, _action}` 占位行。User_agent 已 R29 截 200。Pre-fix:任一 admin 调用方传超大 dict 把 audit 行膨成 MB 级,行式 binlog 复制慢 TiDB slave。
> - **T+1 post-leaver verify 扩展 CDN + LB** ([post_leaver_service.py::_scan_person](../../server/app/hr_lifecycle/post_leaver_service.py)) — `CmdbCdnDomain` + `CmdbLoadBalancer` 加入残留扫描,与 T+0 `_force_transfer_all` 的 5 类网络资产覆盖对齐。Pre-fix:T+0 drop 了某条 CDN/LB 转移(recipient mid-batch 解析失败)永远不被发现。
> - **list_dns_record_requests router 层 page_size 校验** ([cmdb/router.py:list_dns_record_requests](../../server/app/domains/cmdb/router.py)) — `Query(20, ge=1, le=100)`,service 层的 clamp 之外加 defense-in-depth。
> - **NetworkTab.tsx ECharts tooltip XSS hardening** ([cloudOverview/NetworkTab.tsx](../../web/src/pages/cloud/cloudOverview/NetworkTab.tsx)) — `donutByProvider` / `donutByIntegration` 改函数版 formatter + `escapeHtml()` helper(对齐 `NetworkAnalyticsDashboard.tsx` Round-26)。Pre-fix:cloud admin 命名 integration 为 `<img src=x onerror=alert(1)>` 即 XSS。
> - **MyCloudDnsPage onlyMine 按钮 i18n parity** ([MyCloudDnsPage.tsx](../../web/src/pages/cloud/MyCloudDnsPage.tsx)) — 改为 `onlyMine ? onlyMineOn : onlyMineOff` 三元,对齐 MyCloudCdnPage。补 ja-JP 双键。Sibling-page 规则。
>
> **不变 / 已确认干净的安全面**(Round-30 0 改动):`domain_registration.handover()/revoke()` 是 admin-only by design(`Depends(require_admin())`,不是 missing owner check 的 BLOCKER 而是预期行为);`batch_update_dns_record_owner` 已校验新 owner 为 active employee(Round-28);pre-leaver cron 已过滤 `employment_status='left'`;Feishu webhook signature 验签 + 5min freshness 已在 R28 加固;DNS request 单元已有 SELECT FOR UPDATE 防 webhook 重投;`network.user_notifications_enabled` kill switch + `first_observation_emitted_short_bucket` 已防 domain_expiry_alert 首轮 carpet-bomb。
>
> **deferred follow-ups**(范围外):
> - approver Feishu webhook 内角色再验 — webhook 在 router 层已 HMAC + freshness,trust-on-issue 设计 trade-off,后续单独 PR
> - JWT role-revision counter 每请求重验 — 已有 5min TTL for non-active 足够覆盖大多数场景
> - 通知 outbox dead-letter queue — Helix 当前直发 Feishu,DLQ 是 next-iteration 工作
> - 域名注册 TTL sweeper — Feishu 卡片本身有过期机制,不强行回收避免与 Feishu 端打架
> - MCP tool coverage on domain_registration — 后续单独 PR
>
> ----

> **Earlier same-day** (2026-05-18, Round-29 — platform-wide 5-agent pre-launch audit: 7 fixes (1 BLOCKER + 3 HIGH + 2 MEDIUM + 1 LOW); zero migrations; no new MCP tools; no RBAC role):
>
> **跨模块上线前 5-agent 综合审计 — 连接器 / IAM / 账单 / SaaS 坐席 / MCP+CLI / 数据同步 / JML / 跨切面认证。** 5 agent 并行 read-only,~85 候选 finding,逐项对照源码后 7 是真 bug。
>
> - **Feishu webhook v1 token-only path mandatory timestamp** ([feishu_chat_approval.py:249](../../server/app/integrations/drivers/feishu_chat_approval.py)) — `verify_webhook` 在 v1 静态 `event_verification_token` 路径上把 `X-Lark-Request-Timestamp` 改成**强制**;header 缺失 / 非数 / 超 ±300s window 一律返 403。Pre-fix `if ts_v1:` 条件检查,header 缺失时直接返 ok=True,**捕获过一次合法 callback 的攻击者可用静态 token 无限重放老审批**。`approval/router.py` 在 HTTP 层做了同步检查,但 driver 自身契约是兜底入口,未来 MCP / batch reprocessor 调用 driver 会继承裸 token-only。
> - **Auth email lookup case-insensitive** ([auth/service.py::find_user_by_email + login_via_google](../../server/app/auth/service.py)) — SQL `func.lower(work_email) == email.lower().strip()`,SSO 边界把 Google id_token email 规范化成小写。Pre-fix:HRIS 与 Google 偶发大小写漂移会导致 SSO 拒登("No user found");反之未来若 HRIS 写入大小写漂移有账号撞击风险。所有 *写* 路径(google_workspace provisioner / hr.service / successfactors_sync)已经在落盘前 `.lower()`,unique 索引不会撞。
> - **Refuse to remove the last global_admin** ([auth/service.py::_assert_not_last_global_admin](../../server/app/auth/service.py)) — `remove_role` / `remove_role_by_name` 两个路径都调用守卫。`canonicalize_role_name(role)` 不是 `global_admin` 等价直接放行;否则按 `db_role_names_for(GLOBAL_ADMIN_ROLE)` 数除当前 user 外仍持任一 admin-equivalent 的 distinct user 数,< 1 时抛 409 `LAST_GLOBAL_ADMIN`。Pre-fix:误操作可移除系统**最后一个 global_admin**,所有 admin-gated 接口立即全 403。
> - **Aliyun monthly billing dynamic CNY FX rate** ([scheduled_jobs/handlers.py::run_billing_sync](../../server/app/scheduled_jobs/handlers.py)) — provider 返回后,对 CNY-native 账单用 `app.core.fx_rate.resolve_cny_per_usd`(daily cost_facts pipeline 同一 resolver)Decimal 端到端重算 `total_cost_usd`(quantize 2dp ROUND_HALF_UP),替换 provider 内 `_FX_TO_USD["CNY"]=0.14` 硬编码。ECB 漂 3-5%,headline 金额 finance 会信。USD/EUR 走 provider 值不动;resolver raise 时 fallback 至 driver 值并 warning。**monthly rollup 与 daily cost_facts 现在 USD 数字一致**。
> - **Scheduled-job per-handler hard timeout** ([scheduled_jobs/service.py::_execute_job_locked](../../server/app/scheduled_jobs/service.py)) — 每条 cron `handler.run(...)` 包 `asyncio.wait_for(timeout=timeout_secs)`,默认 1800s (30min),`job_config["timeout_secs"]` 可配,clamped `[60, 7200]`s。APScheduler `max_instances=1 + misfire_grace_time=900` 防 re-fire 但不回收挂死 handler;asyncio.wait_for 是缺失的回收路径。Timeout 触发后 work_session async-context-manager 在 `CancelledError` 上 rollback 不留半成品。
> - **Slack reactivate validates response active=true** ([slack_software.py:535](../../server/app/integrations/drivers/slack_software.py)) — SCIM PUT 200 之后解析 body,`if active_resp is False:` 显式失败(identity check,非 truthiness;None / 缺字段安全)。Pre-fix Slack SCIM v1 实现 silent ignore 最小 body 可能让 Helix 标记 seat 已激活而 Slack 端仍 suspended → 用户登录失败但 audit 全成功。204 路径不变。
> - **Audit user_agent capped to 200 chars** ([audit/service.py::log](../../server/app/audit/service.py)) — `user_agent[:200] if user_agent else user_agent`(None 透传)。企业 / 移动 UA 经常 500+ 字符,堆积会让 `audit_logs` 表臃肿;尾部往往是设备 fingerprint 类 quasi-PII(硬件 model / build SHA),取证只需前 200 字符的 family + version。
>
> **不变 / 已确认干净的安全面**(Round-29 0 改动):JWT `es` claim + 5min TTL for non-active(已防 leaver mid-session)、JWT alg pin (`HS256` 单一允许)、refresh token JTI rotation(Redis-backed)、HRIS empty-roster brake + mass-leaver threshold(25 或 5%)、CMDB sync force-sweep distributed_lock、CMDB resync owner_person_id 不被 on_duplicate_key_update 覆盖(只更新 metadata 字段)、Slack SCIM filter escape、SuccessFactors SSRF 守卫、Feishu HRBP 缓存 by app_id、google SSO nonce / aud / iss / email_verified 校验、provider error 全部经 redactor。
>
> **deferred follow-ups**(范围外):
> - 审计日志 DB-level immutability(MySQL trigger 阻断 UPDATE/DELETE)— 需要 migration + 应用 grant 改动,后续单独 PR。
> - HRIS dept/manager change downstream propagation event 总线 — M3+ 范畴。
> - MCP middleware response-size cap — 暂未发现 OOM 真实案例,deferred。

> ----

> **Round-30 follow-up #7** (2026-05-18) — `POST /cmdb/sync/{id}/force-sweep` 改异步 + nginx `/api/` proxy_read_timeout 300s 兜底:
>
> - **Root cause**:GCP `c1a4f301...8212268c46` 触发"servers: 953 rows vs 1006 baseline (>5% drop) — refusing to sweep",admin 点「强制以最新同步为准」收到通用文案"后端返回错误，但没有详细消息。请检查服务端日志或重试"。沿调用链反推:endpoint 同步执行 `run_sync` 内置 `cloud_sync:<id>` 分布式锁,多 project GCP fan-out 60-120s,跨过 nginx 默认 `proxy_read_timeout 60s` → 504 → 响应体是 nginx HTML(非 `{success:false,errors:[...]}` 信封)→ 前端 `apiMsg = undefined` → fallback `scheduledJobs.resourceSweep.failureBody` 那条通用文案。**力气全花在 polite error 渲染上,bug 是 transport-layer timeout**。
> - **Endpoint 改异步** ([cmdb/router.py:529](../../server/app/domains/cmdb/router.py) + [cmdb/sync.py::run_sync_with_lock](../../server/app/domains/cmdb/sync.py)):`POST /cmdb/sync/{id}/force-sweep` 200 → **202**,响应即返回 `status='queued'` 的 sync_job 行;真活通过 FastAPI `BackgroundTask` 在 `run_sync_with_lock` 内拿 `cloud_sync:<id>` 锁后再跑 `run_sync`。锁竞争(原 `CLOUD_SYNC_ALREADY_RUNNING` 409)改成 BG task 内 `LockBusy` 后把 job 写 `status='failed'` + 显式 retry 文案,前端轮询读到并展示。锁 TTL 600s 不变,`async_session_factory()` fresh session 与 `run_sync` 自身 session discipline 一致。
> - **前端轮询** ([ScheduledJobRunsPage.tsx:handleForceResourceSweep](../../web/src/pages/admin/ScheduledJobRunsPage.tsx) + [cloud.ts:getSyncJob](../../web/src/api/cloud.ts)):新增 `cloudApi.getSyncJob(jobId)` wrapper(命中既存 `GET /cmdb/sync-jobs/{id}`),`handleForceResourceSweep` 收到 202 后 2s 节奏轮询,5min deadline。terminal `success`/`partial_failure`/`skipped` 走原 success modal;terminal `failed` 走 error modal 渲染 `job.error_message`(含 LockBusy 文案);deadline 命中走 warning modal 提示"BG task 可能仍在跑,稍后查 sync-jobs 页"。新增 i18n 三套 `scheduledJobs.resourceSweep.timeoutTitle` / `timeoutBody`。
> - **Nginx defense-in-depth** ([web/nginx.conf:29](../../web/nginx.conf)):`/api/` 加 `proxy_read_timeout 300s; proxy_send_timeout 300s;` — 5× 头部裕量覆盖任何当前 / 未来同步端点(billing 大表 export、CSV 导出、grants force-drift-sweep 等)超过 60s 的场景,避免再次踩"504 → HTML body → 通用文案"的失败模式。
> - **`force_cloud_resource_sweep` 仍不暴露 MCP** ([mcp/tools.py:6376-6385](../../server/app/mcp/tools.py)) — 与 `force_cloud_grants_drift_sweep` 同一 defense-in-depth 决策:admin-override 类工具刻意不上 PAT 可达面,防止凭证泄露后绕过 5%/0-row 安全阈值。
> - **测试**:`test_force_sweep_override.py` 全面重写 endpoint 测试,happy-path 改成 202 + 跨 session 重读 job 行;原 lock-busy 409 测试改成 lock-busy → 202 + job `status='failed'` + 含 "already running" / "retry" 的 error_message;新增 `test_run_sync_with_lock_marks_job_failed_on_lock_busy` 单元测试 patch `distributed_lock` 验证 wrapper 自身 LockBusy 处理路径。`_patched_session_factory` 同时 patch `db_module` 与 `cmdb_sync` 模块的 `async_session_factory` 局部名,真路径单测 cross-session 写能落到测试 DB。

> ----

> **Round-30 follow-up #5** (2026-05-18) — `OffboardingReminderLog` UNIQUE constraint hardening; migration 179:
>
> - **`(process_id, reminder_kind)` UNIQUE constraint** ([migration 179](../../server/alembic/versions/179_offboarding_reminder_log_unique_constraint.py)) — the cron-side dedup (SELECT-then-add under the scheduled-job single-flight lock) was sufficient *today*, but had no DB-level guard so any future writer outside the cron (admin manual re-send, parallel handover trigger, backfill script) would race silently and double-fire reminders. Migration collapses pre-existing duplicates first (keep MIN(id) per group) then `ALTER TABLE` with idempotent `_constraint_exists` check. Model gains `__table_args__ = (UniqueConstraint(...),)` so SQLAlchemy `metadata.create_all` (used by the pytest fixture) enforces it from a fresh schema as well.
>
> ---
>
> **Earlier same-day** (2026-05-18, Round-28 — network module full-lifecycle pre-launch audit: 25+ fixes; migration 178; no new MCP tools; no RBAC role; no new domain):
>
> **网络模块上线前 14-agent 审计循环 — 域名 / DNS / CDN / LB 全生命周期 hardening。** 12 初轮 + 4 re-review + 2 final scan,收敛到 clean。
>
> - **DNS 自助审批 hardening** ([dns_request_service.py](../../server/app/domains/cmdb/dns_request_service.py)) — 自助路径(`POST /dns-record-requests`)增加多层守卫:(1) 角色门 `_can_submit_self_service_dns` 仅放过 `employee` + `global_admin`,排除只读 `auditor` / `management` / 永久授权 `cloud_admin` 单独角色;(2) wildcard `*` 整体拒绝(zone 内所有未绑定子域一次劫持);(3) reserved underscore label 任意位置拒绝(`_dmarc` / `_acme-challenge` / `_spf` / `_dkim` / `_domainkey` / `_smtp._tls` / `_mta-sts` / `_tls` / `_tlsa` 等);(4) TXT 值 SPF/DMARC/DKIM/域名所有权前缀拒绝,迭代式 leading-junk strip 支持 `'"v=spf1..."` 混合引号。Admin direct-write 路径(`POST /dns-records`)不受影响。
> - **DNS 审批失败错误消息脱敏** ([dns_request_service.py::_public_error_message](../../server/app/domains/cmdb/dns_request_service.py)) — 失败的 DNS 审批不再把 `str(exc)[:1000]` 直接落 `cmdb_dns_record_requests.error_message`(employee 可通过 `GET /dns-record-requests/{id}` 读),改按异常类名分类返通用文本。Raw exception 仍写 `audit_logs.detail.extra.error`,但经 `redact_provider_error(max_len=500)` 处理(AccessKeyId / ARN / signed URL strip),与 driver 既有 redact 一致。
> - **DNS TXT 值审计脱敏** ([router.py::_audit_dns_value](../../server/app/domains/cmdb/router.py)) — TXT 记录在 `audit_logs.detail.value` / `before.value` / `after.value` / `patch.value` 写入时改为 `[TXT redacted; sha256:<16char> length=<n>]`。TXT 携带 ACME-DNS-01 challenge / SaaS verification token / 域名所有权 token,plain-text 落 audit_logs(security_auditor 可读)是长期遗存风险。Non-TXT 不变。
> - **owner_history 标签快照 + 死常量清理** ([owner_history.py::record_owner_change](../../server/app/domains/cmdb/owner_history.py)) — 每次 `record_owner_change` 解析 prev/new/actor 的 `IdentityPerson.work_email or display_name`,塞入 `context.previous_label` / `new_label` / `actor_label`。Leaver IdentityPerson 几月后被 hard-delete,FK 变 NULL(`ondelete=SET NULL`)但 label string 在 JSON 里保留 — forensic 仍可回答。Batch endpoints 预解析 `new_label` + `actor_label` 一次塞 context,`record_owner_change` 用 `if key not in enriched` 短路(`setdefault` 不短路 `await`)。同时删除两个死常量(`CHANGE_REASON_SYNC_INITIAL_ATTRIBUTION` / `CHANGE_REASON_ORPHAN_RECOVERY`)和"sync auto-attribute"虚假 docstring — 真实 sync 路径不做 owner attribution,domain landing 时 `owner_person_id=NULL`,admin 显式 PATCH 才写第一条 history。
> - **DNS create + delete owner_history atomicity** ([service.py::create_dns_record_in_db + delete_dns_record_in_db](../../server/app/domains/cmdb/service.py)) — 两个 helper 改成 flush-only,caller 在写完 `record_owner_change` 之后才 `commit()`。Pre-fix:helper 内部 commit 把 row insert / soft-delete 和 history seed / terminal 行拆成两个 tx,第二个 commit 死 → cloud-side 已写但 Helix 缺 history 链路。Admin `POST /dns-records` + `DELETE /dns-records/{id}` + DNS approval `_execute_create` / `_execute_delete` 4 个 caller 全部按此模式落地 + 写 history。Admin POST 额外加 `body.owner_person_id` 必须 active employee 守卫。
> - **`_safe_sweep` 防误删新创建行** ([sync.py::_safe_sweep](../../server/app/domains/cmdb/sync.py)) — sweep filter 新增 `model.first_seen_at < sync_job.started_at`(`hasattr(model, "first_seen_at")` 自动启用)。Pre-fix:sync 拉云数据完成后,employee 自助 DNS approval 在中间 commit 一条 `last_seen_sync_job_id=NULL` 的新记录 → sweep 的 `IS NULL OR != job_id` filter 匹配 → 新记录立即被软删。
> - **Annotate / batch FOR UPDATE 行锁** ([service.py](../../server/app/domains/cmdb/service.py)) — `annotate_domain` / `annotate_load_balancer` / `annotate_cdn_domain` / `update_dns_record_in_db` 的 SELECT 加 `.with_for_update()`;`batch_update_dns_record_owner` / `batch_annotate_load_balancers` 同样。Pre-fix:两个并发 PATCH 读同一 `previous_owner`,timeline 缺中间过渡。Batch vs 单条 annotate 的非对称竞争一并修复。
> - **网络告警 cron 上线 hardening** ([handlers.py](../../server/app/scheduled_jobs/handlers.py)):
>   - `cmdb.domain_expiry_alert` **first-observation 抑制**:`prev is None` 时 stamp state 但不发 DM,owner 下次 bucket 翻转才告警。例外:`first_seen_at >= now - 24h` 且 bucket 在 `<=1d` / `<=7d` / `expired` 中(刚同步进来的紧急域名)绕过抑制立即发。
>   - `cmdb.dns_owner_recertify` 排除 `termination_date <= today + 7d` 的 owner(与 pre_leaver_handover 错开)。
>   - 两 cron 在 Feishu 未配置时 `raise RuntimeError`(而非 `status=ok` 静默)。
>   - `cmdb.dns_pre_leaver_handover` 覆盖 LB + CDN(之前只 DNS + domain),DM 文案各 section 加 "...and N more" 截断尾,`employment_status.in_(("active", "leaver_pending"))`。
> - **DNS recertify cron 性能索引** ([migration 178](../../server/alembic/versions/178_cmdb_network_audit_hardening.py)) — `ix_dns_recertify_due (deleted_at, last_recertify_nag_at, last_owner_review_at)` 复合索引覆盖 recertify cron 的"due"查询,10k+ DNS 行 prod 不再做日全表扫描。
> - **Schema strict mode** ([schemas.py](../../server/app/domains/cmdb/schemas.py)) — 7 个 employee-reachable 输入 schema 加 `model_config = ConfigDict(extra="forbid")` + `min_length=1` on `zone_name`/`name`/`value` + annotation 字段 `max_length` 对齐 DB 列(`owner_raw=255` / `icp_filing_no=64` / `environment=16`)。
>
> **不变 / 已确认干净的安全面**(Round-28 0 改动):SQL 注入、`dangerouslySetInnerHTML`、SSRF、CSRF、UUID 枚举、Feishu webhook 签名验证(已有 timestamp/nonce check)、404 vs 403 disclosure、`_RESERVED_UNDERSCORE_LABELS` ASCII-only(Unicode 类 wildcard `∗`/`·` 已被 `_SUBDOMAIN_RE` 拒绝)。
>
> **deferred follow-ups**(不影响上线):
> - MCP `submit_dns_request` 暂未镜像 `_can_submit_self_service_dns` 角色门(per CLAUDE.md 所有用户都是 employee,实际无 bypass 风险);
> - `_RESERVED_UNDERSCORE_LABELS` 与 `_TXT_RESERVED_PREFIXES` 未来加 `_bimi` / `v=bimi1` / `v=arc1` 等新兴标准;
> - `test_owner_confirmation::test_escalate_overdue_writes_escalation_row` pre-existing TypeError 已 spawn 独立任务跟进。

> ----

> **Earlier same-day** (2026-05-18, Round-27 — GCP 403 classifier expansion + 4xx leg `error_message` enrichment + Azure/AWS admin-state skip parity + failure-summary mode rendering; zero migrations; no new MCP tools; no RBAC change):
>
> - **GCP 403 classifier expanded from 1 to 5 admin-state categories** ([providers/gcp.py::_classify_gcp_403_category](../../server/app/domains/cmdb/providers/gcp.py)). Round-25 only recognized `SERVICE_DISABLED` / `accessNotConfigured` as "structurally expected skip" — but GCP returns HTTP 403 for at least SIX semantically distinct cases and the other four (VPC Service Controls, Org Policy, Billing Disabled, Consumer Invalid) still bucketed into `partial_failures` with bare `http_403` reason and zero context. Production incident 2026-05-18: `project:tidb-376913` flagged `partial_failure · 1 leg(s): http_403 × 1` for >24h with the admin unable to tell whether IAM was still propagating, VPC-SC was blocking, or billing was off (the SAME diagnostic friction memory `feedback_gcp_403_audit_log_source_of_truth` describes — pre-Round-27 the only path was pulling Cloud Audit Logs). Round-27 categories:
>   - `api_disabled` — `details[*].reason=SERVICE_DISABLED` or legacy `errors[*].reason=accessNotConfigured`. Carries `metadata.service`.
>   - `vpc_sc` — `reason=VPC_SERVICE_CONTROLS` direct OR `reason=SECURITY_POLICY_VIOLATED` **with** `metadata.violationReason==VPC_SERVICE_CONTROLS` (positive match required — Round-2 review P0-1 caught the pre-fix "anything not explicitly contradicted" path silently masking real Cloud Armor / BeyondCorp denials); also PreconditionFailure `violations[*].type=VPC_SERVICE_CONTROLS`.
>   - `org_policy` — `reason=ORG_POLICY_CONSTRAINT_VIOLATED` (carries `metadata.constraint=constraints/foo.bar`) or PreconditionFailure `violations[*].type=constraints/*`.
>   - `billing_disabled` — `reason=BILLING_DISABLED`.
>   - `consumer_invalid` — `reason=CONSUMER_INVALID` (project delete pending / suspended).
>
>   **IAM priority preserved**: any `IAM_PERMISSION_DENIED` in `details` returns None unconditionally → leg stays in `partial_failures` → failure-notifier pages. Silently masking a real permission gap is the failure mode this classifier will never permit. Returns `tuple[category, detail]` where detail is the canonical service name / constraint id / literal `"unknown"`. Body cap 64 KB pre-JSON-parse; tokens sanitized through `_GCP_SAFE_TOKEN_RE=[^a-zA-Z0-9._/-]` + 128 char cap (`/` added vs Round-25 since `constraints/iam.foo` contains it). Wide `bytes/bytearray/memoryview/str` acceptance. Legacy `_classify_gcp_api_disabled(exc) -> str|None` shim preserved as a thin wrapper returning the service name only when category is `api_disabled` — keeps Round-25 test surface intact while routers migrate to the new tuple-returning entry point. All four GCP 4xx branches (servers / DNS managedZones / DNS rrsets / GCS buckets) call the new classifier; non-None categories route to `skipped_legs` with reason `<category>:<detail>`; None → `partial_failures` with `error_message` field enriched (see next bullet).
> - **GCP 4xx legs now carry `error_message` field** ([providers/gcp.py::_gcp_403_leg, _gcp_403_error_message](../../server/app/domains/cmdb/providers/gcp.py)). Pre-fix the bare `http_403` / `http_404` reason gave admins zero context — they couldn't tell if a `[project:tidb-376913]` 403 was an IAM gap, propagation lag, perimeter rule, or billing issue without SSH-ing the worker logs. The `error_message` leg field carries the redacted `error.message` snippet (capped 160 chars). **Critically the bare token reason is preserved** (`http_403` / `managedZones.list_http_403` / etc.) — keeps `_summarize_leg_failures` group-by-reason stable and the `_STRUCTURAL_AUTH_REASON_RE` match in sync.py unchanged. The unified `_gcp_403_leg(project_id, capability, exc, *, bare_reason, zone_name=None)` factory centralizes the leg shape so all 4 branches emit consistent fields. `redact_leg_entry` now redacts the new field as second-line defense (`exceptions.py:198`).
> - **Azure ARM admin-state 4xx routes to skipped_legs** ([providers/azure.py::_classify_azure_4xx_admin_skip](../../server/app/domains/cmdb/providers/azure.py)). Symmetric to GCP — ARM returns 4xx for both "real RBAC gap" and "resource provider not registered on this subscription" / "subscription deleted" / "billing disabled" / "subscription does not exist". Recognized ARM error codes: `MissingSubscriptionRegistration` → `rp_not_registered:<Microsoft.Compute>` (namespace from `details[0].target` preferred, fallback regex-extract from `error.message`'s `Microsoft.*` token), `SubscriptionNotFound` / `SubscriptionDoesNotExist` → `sub_not_found`, `DisabledSubscription` → `sub_disabled`. Real `AuthorizationFailed` stays in `partial_failures` with enriched `error_message`. Body parser handles empty / non-JSON / oversized / nested / missing-code / non-string-code safely (every malformed body returns None → falls through to `partial_failures`). 64 KB cap pre-JSON-parse. Diagnostic `logger.info("...routed to skipped_legs (sub=%s, %s: %s)", sub_id, category, detail)` for grep-friendliness (P1-3 review).
> - **AWS region opt-in 4xx routes to skipped_legs** ([providers/aws.py::_list_servers_sync](../../server/app/domains/cmdb/providers/aws.py)). Symmetric to GCP/Azure — `OptInRequired` (region not opted-in on the account — `me-south-1` / `af-south-1` / `ap-east-1` / `eu-south-1` / `ap-southeast-3` etc.) routes to `skipped_legs` with reason `region_not_opted_in`. Real `UnauthorizedOperation` / `AccessDenied` intentionally NOT routed (could mask a real IAM gap the admin must see) — stays in `partial_failures` with enriched `error_message` from `ClientError.response['Error']['Message']`. `AuthFailure` / `InvalidClientTokenId` still raises `ProviderAuthError` globally (creds broken — per-region skip would hide it).
> - **Failure-summary renders most-common `error_message` per reason group** ([_cloud_shared.py::_summarize_leg_failures](../../server/app/integrations/drivers/_cloud_shared.py)). When legs carry `error_message`, the per-reason rendered line gains `(msg: <mode>)` suffix — admin sees actionable text directly in the Feishu alert card and the sync-history UI without grepping logs. **Mode (most-common) tiebroken by lexicographic-min** keeps the summary stable across cron ticks even when leg arrival order varies (failure-notifier `compute_failure_signature` would otherwise flip per tick). Per-message budget tightened to 120 chars (P1-5 review — keeps total summary under the 1KB `last_test_message` cap even with 5 reason groups each carrying a message). Existing 2000-char hard ceiling, `_LEG_SUMMARY_MAX` legs-per-group, and reason-group counts unchanged.
> - **Re-page expected on first deploy after Round-27.** The `(msg: …)` suffix changes `compute_failure_signature`'s hash input → existing `last_failure_signature` rows persisted from Round-25 won't match → every previously-acked failed cron tick will fire one additional notification on first run. This is **expected behavior** — the admin finally sees the real GCP error message and the alert becomes actionable. Subsequent runs dedup as normal.

> ----

> **Earlier same-day** (2026-05-18, network-module pre-launch 12-agent audit — BLOCKER + HIGH 集中修复;migrations 174 → 177;no new MCP tools;无新增 RBAC role):
>
> **网络模块上线 hardening — 一次性整改 10 个 BLOCKER + 7 个 HIGH。**
>
> - **网络告警 kill switch** ([migration 174](../../server/alembic/versions/174_network_notifications_kill_switch.py) + `app/domains/cmdb/network_notifications.py`) — 新 platform_setting `network.user_notifications_enabled`(默认 false)总开关三个网络系统推送 cron(`cmdb.domain_expiry_alert` / `cmdb.dns_owner_recertify` / `cmdb.dns_pre_leaver_handover`)。false 时 handler 进入即 short-circuit 返回 `status="skipped"`,完全不向员工 / 共享群推送任何 Feishu。用户主动发起的 DNS / 域名审批流不受影响(那是他们自己请求的)。上线 dry-run 后由 admin 在 Settings UI / SQL 翻 true 启用。
> - **网络 owner 历史表 `cmdb_network_owner_history`** ([migration 175](../../server/alembic/versions/175_cmdb_network_owner_history.py) + `app/domains/cmdb/owner_history.py`) — append-only 4-resource-type 通用表,每次 owner 变动(`admin_patch` / `batch_owner` / `dns_request_create` / `offboarding_force_transfer` / 等 6 种 `change_reason`)写一行。Pre-fix:`cmdb_domains` / `cmdb_dns_records` / `cmdb_cdn_domains` / `cmdb_load_balancers` 全部就地覆盖 `owner_person_id`,半年 owner ping-pong 后链路彻底丢失。Migration seeds 一行 `sync_initial_attribution` 给每个现有有 owner 的资源做初始归属。Service-layer 自动接入:`annotate_domain` / `annotate_load_balancer` / `annotate_cdn_domain` / `update_dns_record_in_db` / `batch_annotate_load_balancers` / `batch_update_dns_record_owner` / `dns_request_service._execute_create` / `advance_service._handle_one_blocker(domain/dns/lb/cdn)` 全部 wired。
> - **离职扫描覆盖 LB + CDN** ([offboarding/service.py:_scan_blockers + transferable_summary](../../server/app/domains/offboarding/service.py)) — 新增 `RES_LOAD_BALANCER` + `RES_CDN_DOMAIN` 两个 resource kind。`advance_service` force-transfer 同样新增两个 branch + 写 `audit_logs` + 写 `cmdb_network_owner_history` IN THE SAME TX。Pre-fix:migration 173 / 160 给两张表加了 `owner_person_id` FK 但 offboarding 完全没扫,离职员工永远是 LB / CDN owner。
> - **离职接收人必须是 active** ([cmdb/service.py:_resolve_owner_input + update_dns_record_in_db + batch_update_dns_record_owner + _resolve_person_id_from_raw](../../server/app/domains/cmdb/service.py)) — 所有 owner 解析路径加 `employment_status == 'active'` 守卫。Pre-fix:admin PATCH 可以把资源转给 `leaver_pending`,offboarding scan 已经跑过 → 永远孤儿。
> - **DNS 审批事务原子性 + 执行时 active 员工守卫** ([dns_request_service.py:_execute_approved + _execute_create](../../server/app/domains/cmdb/dns_request_service.py)) — `_execute_approved` 异常分支添加 `await self.db.rollback()` BEFORE re-fetch(原代码 session 中毒后 `.get()` 抛 `InvalidRequestState`,row 永卡 EXECUTING)。Pre-execute 重检 requester `employment_status`,变 `leaver_pending` 则 FAIL + audit `cmdb.dns_request.execute_blocked_inactive_requester` 替代落盘。`_execute_create` cloud-write 成功但 Helix DB 插入失败时 loud-log 包含 `provider_record_id` 供 admin 手动清理 cloud 孤儿。
> - **DNS submit 强制 zone 授权检查** — `submit()` 在 Feishu 创建前 hard-check `(integration_id, zone_name)` 必须存在 `cmdb_domains` 行且 `allow_dns_request=True`。Pre-fix:UI picker 是唯一防线,任何登录用户都能 POST 任意 zone 字符串走 Feishu 审批。
> - **网络告警 cron 重写** — `cmdb.domain_expiry_alert` 改成 per-owner Feishu DM,按 bucket(`<=1d`/`<=7d`/`<=30d`/`expired`)分级,`cmdb_domain_expiry_alert_state` ([migration 176](../../server/alembic/versions/176_cmdb_domain_expiry_alert_state.py)) 表做 per-domain × per-bucket × per-owner dedup,只在 bucket 跨越或 owner 变更时才再发。已过期 > 30 天的视为有意未删,不再打扰。leaver_pending owner 跳过(由离职流接管)。`cmdb.dns_owner_recertify` 新增 `last_recertify_nag_at` 列([migration 177](../../server/alembic/versions/177_cmdb_dns_record_nag_dedup.py))做 30 天 nag dedup 窗,owner 至多每月被提醒一次。`cmdb.dns_pre_leaver_handover` 用 `OffboardingReminderLog` 按 T-7 / T-3 / T-1 bucket 做幂等,至多 3 条 DM。三个 handler 都改成 total-failure 时 `raise RuntimeError` 让 failure_notifier 接管。
> - **同步软删 sweep 写 audit** ([sync.py:_safe_sweep](../../server/app/domains/cmdb/sync.py)) — 每次 `cmdb_domains` / `cmdb_dns_records` / `cmdb_cdn_domains` / `cmdb_load_balancers` 的 sweep soft-delete 后(rowcount > 0),写一条 `cmdb.sync.sweep_soft_deleted` 审计行 with `swept_ids_sample[:500]`、capability、baseline / upserted、`force_sweep` 标志。Pre-fix:routine sweep 零留痕,admin 问"什么扫掉了这条 DNS 记录"只有 `last_seen_sync_job_id` 可查。
> - **Analytics 对账修复**:
>   - `get_analytics` 接受 `owner_person_ids` 列表参数,`/analytics/my` router 现在按 manager-subordinate scope 展开 → 与 `/cmdb/domains` / `/dns-records` / `/cdn-domains` 列表页 scope 对齐,manager 看到的数字现在能对上。
>   - Global 聚合自动排除 `Integration.enabled=False` tenant — admin 禁用一个 tenant 后总数会真的掉。
>   - `icp_missing` / `icp_filed` 限定在 zh-CN registrar(`{aliyun, tencent, huawei}`),Cloudflare/Route53 `.com` 不再被算"未备案"。
>   - `/cmdb/domains/selectable-for-dns` join `Integration` 且过滤 `enabled=True` — 禁用 tenant 的 zone 不会出现在 DNS 申请 picker 里。
> - **Annotate audit 包含 owner before/after** ([router.py:annotate_domain + annotate_load_balancer + annotate_cdn_domain](../../server/app/domains/cmdb/router.py)) — 三个 annotate endpoint 现在在 service 调用前 snapshot 旧值,audit detail 形如 `{patch, before:{owner_person_id, ...}, after:{...}}`。Pre-fix:audit_log 只记 patch payload,无法答复"上周谁是 owner"。
> - **日志凭证泄漏** — `router.py` 的 3 个 DNS direct endpoint + IAM cleanup 把 `exc_info=True` 改为 `exc_info=False`,与 `_set_dns_record_status_direct` 既有的防泄漏注释一致 — Aliyun / Tencent SDK exception args 含 signed URL (AccessKey + signature),全栈打印会泄到 ELK。
>
> **前端**:6 个网络页面(`Cloud{Domains,Dns,Cdn}Page` + `My{Cloud,Cdn}{Domains,Dns,Cdn}Page`)分页 contract 修复(`pageSize` 状态 + `onShowSizeChange`,默认 20 而非硬编 50)。`MyCloudDomainsPage` 到期 banner 改用后端 `expiring_within_days=30` 过滤而非前 200 行 peek,任何 owned set 都准确。`MyCloudCdnPage` 加 `onlyMine` 切换 + `source_address` 列与 admin 对齐 — 之前 admin 在"我的 CDN"看到整 fleet。`MyNetworkOverviewPage` 删除关于 CDN 不带 owner 的过时 docstring。
>
> **已确认干净的安全面**(无需改动):SQL 注入、`dangerouslySetInnerHTML`、SSRF、CSRF、UUID 枚举、Mass-assignment、Audit ON DELETE CASCADE、Feishu webhook 签名验证。
>
> **deferred follow-ups**(不影响上线;kill switch 兜底):
> - ~~DNS 审批 requester ≠ approver 4-eye 留给 Feishu 模板配置(代码侧无法强制)。~~ — **landed Round-30 follow-up #2**:[`dns_request_service.py:_is_self_approval`](../../server/app/domains/cmdb/dns_request_service.py) walks `instance_detail.task_list` and rejects execution if any APPROVED task's `open_id` equals the requester's (resolved via `FeishuApprovalClient.resolve_open_id_by_email(work_email)`). Fail-closed on resolve / lookup failure. Row → `DNS_REQ_FAILED`, audit `cmdb.dns_request.execute_blocked_self_approval`.
> - ~~MCP 工具覆盖 CDN ops / domain handover / revoke 暂缺~~ — **landed Round-30 follow-up #1** (`_REGISTERED_TOOLS_COUNT_HINT` 148 → 151:`annotate_cdn_domain`、`handover_domain_registration`、`revoke_domain_registration`).
> - ~~`annotate_object_storage` audit gap~~ — **landed Round-30 follow-up #6**(网络模块无关 follow-up). 
>

> ----
>
> **Earlier same-day**: 2026-05-18 (Round-26 — 网络资产总览 Tab 化 + DNS/CDN 列宽溢出修复 + ECharts tooltip XSS 加固; zero migrations; no new MCP tools; no RBAC change):
>
> - **网络资产总览改成 Tab 工作台** ([web/src/pages/cloud/NetworkOverviewPage.tsx](../../web/src/pages/cloud/NetworkOverviewPage.tsx)) — 镜像 `CloudOverviewPage` 的 4-tab 模式 (`概览 / 域名 / DNS / CDN`)，URL 通过 `?tab=overview|domains|dns|cdn` 保持，刷新跳转后回到原标签。`CloudDomainsPage` / `CloudDnsPage` / `CloudCdnPage` 新增 `embedded?: boolean` + `embeddedTenantId?: string | null` props（沿用 PR #267 `CloudBillingPage` 的 embedded 约定），父级 PageHeader + `CloudTenantFilter` 是单一源头，所有 Tab 共享租户选择。`NetworkAnalyticsDashboard` 加 `hideTenantFilter` 防止双重 filter 行。`destroyInactiveTabPane` + `lazy()` + `Suspense` 让每个 Tab 第一次访问才下载列表 bundle。
> - **DNS / CDN / Domains 列宽溢出修复** — 用户反馈 (2026-05-18): "DNS的显示超出了屏幕 / CDN的显示也很丑 / 像是生产一样，现在的太儿戏了"。原因：admin 端 CloudCdnPage 的 `domain_name` 与 `cname` 都没设 `width`，加上同行 8+ 个 fixed-width 列把 flex 列压成单字符竖排（截图里 "加速域名" 被压成竖排一字）。修复：每列显式宽度 + 左/右 `fixed` 关键列 + `scroll={{ x: 'max-content' }}` 让超出宽度的行水平滚动而不是挤压列；DNS `value` 列限 260px + ellipsis，超长 TXT 不再撑爆行高。同步覆盖 `MyCloudCdnPage` / `MyCloudDnsPage`（sibling-page coverage 规则）。
> - **`embeddedTenantId` 切换重置 page=1** — 防止父级 Tab 切租户后子页面停在不存在的页码（小租户 50 条以下 → 当前 page>1 永远空表）。三个 list page 各加一个 `useEffect`，只在 `embedded=true` 时触发。
> - **ECharts tooltip XSS 加固** ([NetworkAnalyticsDashboard.tsx](../../web/src/pages/cloud/NetworkAnalyticsDashboard.tsx)) — `donutByIntegration` / `donutByProvider` / `donutGeneric` 三个 formatter 之前用字符串模板 `{b}: {c} ({d}%)` 直接拼 HTML；ECharts 把 formatter 返回值按 HTML 渲染，能改租户名的 cloud admin、provider slug / registrar / record_type / source_type 数据源同源。复用 `CloudCostAnalysisPage` 已有的 `escapeHtml` 模式（PR #267 同思路），三个 formatter 全部改函数式 + 转义动态字段。
>
> ----
>
> **Earlier**: 2026-05-17 (Round-25 — GCP API-disabled 403 disambiguation + skipped_legs channel + force-sweep/ack feedback UX; zero migrations; no new MCP tools; no RBAC change):
>
> - **GCP driver distinguishes "API not enabled" 403 from real permission denial** ([providers/gcp.py::_classify_gcp_api_disabled](../../server/app/domains/cmdb/providers/gcp.py)). Pre-fix: every per-project 403 hit by `compute.instances.aggregatedList` / `dns.managedZones.list` / `dns.resourceRecordSets.list` / `storage.buckets.list` was bucketed into a single `http_403` `partial_failures` leg. In a 500-employee org with ~474 BigQuery/Storage/AppEngine/Firebase-only projects (each legitimately *without* Compute / DNS / Storage enabled), every cron tick produced 474 noise legs, flipped the capability to `partial_failure`, and paged IT admin. After the admin added the SA permissions the noise persisted because the projects fundamentally have no Compute API to enable. Fix: parse the structured error body — `error.details[*].reason == "SERVICE_DISABLED"` (current shape, carries `metadata.service`) or `error.errors[*].reason == "accessNotConfigured"` (legacy shape, no service name). Default safe — any parse / shape / size failure returns None (treat as real 403). **Hybrid-body priority**: if the same body carries both `SERVICE_DISABLED` AND `IAM_PERMISSION_DENIED` (rare race during a project rollout where the API was just enabled but the SA still lacks the binding), the IAM denial **always** wins → return None → leg stays in `partial_failures` → admin sees the real gap. 64 KB body cap short-circuits oversized HTML error pages; service-name `metadata.service` sanitized to `[a-zA-Z0-9._-]` + 128 char cap as defense against log injection / JSON parse DoS / unbounded reasons polluting `cmdb_sync_jobs.capability_results`. Accepts bytes / bytearray / memoryview / str for `exc.content`.
> - **New `CloudProvider.skipped_legs` channel** ([providers/base.py:128](../../server/app/domains/cmdb/providers/base.py)). Mirror of `partial_failures` for "structurally expected" skips — populated by drivers when a leg legitimately has no resources (GCP API disabled on a project). **Critically distinct**: skipped_legs do NOT bump `capability_results['status']` to `partial_failure`, do NOT increment `total_errors`, and the failure-notifier never reads them. Drained per-capability via `consume_skipped_legs()` (mirror of `consume_partial_failures()`); surfaced as `capability_results['<cap>']['skipped_legs']` + `skipped_leg_count` JSON keys for admin visibility (which project has which API disabled) without turning every sync red. Both success-path and error-path drains in `sync.py::_run_sync_impl` wrapped in `AttributeError` (forward-compat for older provider classes) + `Exception` (logger.exception, doesn't mask original exception). `redact_leg_entry` symmetric on both channels.
> - **GCP `_fan_out_per_project` catch-all leg now stamps `capability`** ([providers/gcp.py:308](../../server/app/domains/cmdb/providers/gcp.py)). Pre-fix the bare-except fallback emitted partial_failures legs WITHOUT a `capability` field — downstream admin UI rendered them as `(unknown capability)`. Now takes `capability: str | None` kwarg passed by all three callers (`list_servers="servers"`, `list_dns_records="dns_records"`, `list_object_storage="object_storage"`). Leg `reason` now carries `f"{exc_class}: {redacted}"` (capped 240 chars) instead of just exception class name — a network timeout, JSON decode failure, and credential expiry no longer all collapse to `"HttpError"`. Parametrized test covers all three callers to catch future kwarg-drops in CI.
> - **404 split from 403 path** ([providers/gcp.py](../../server/app/domains/cmdb/providers/gcp.py) in 4 branches). Pre-fix `if exc.resp.status in (403, 404)` collapsed both into the same `http_<status>` leg. 404 means "project deleted between projects.search and this call" — a real lifecycle event admin should see. Now 404 lands in `partial_failures` with `reason="http_404"` (or `managedZones.list_http_404` / `rrsets.list_http_404` / `buckets.list_http_404` per capability) regardless of body shape, while 403 routes through the API-disabled classifier first.
> - **Force-sweep / drift-sweep / ack buttons — persistent feedback UX** ([web/src/pages/admin/ScheduledJobRunsPage.tsx](../../web/src/pages/admin/ScheduledJobRunsPage.tsx) + [web/src/pages/admin/ScheduledJobsPage.tsx](../../web/src/pages/admin/ScheduledJobsPage.tsx)). Pre-fix: `message.success(...)` 3-second auto-dismiss toast + the ack'd row simultaneously vanishing from the default failed-unacked filter made operators miss the only confirmation — bug report 2026-05-17: "点了也不知道点了". Force-sweep can take 60-120s on a large GCP fan-out, magnifying the window. New contract on BOTH sibling pages: (a) the moment the click registers, `notification.open({key:'force-resource-sweep-<runId>', duration:0, icon: <SyncOutlined spin />})` shows a keyed in-flight indicator in the top-right that stays until destroyed; (b) on success, `notification.destroy(key)` + `Modal.success({ title, content, okText })` — explicit OK click required, content includes the upsert/delete counts AND a footer line explaining "auto-acked + removed from default red queue, next sync will re-page if it fails"; (c) on failure, `Modal.error` with `failureBody` fallback when apiMsg is empty (no more title-equals-content). `handleAcknowledge` upgraded from `message.success` to `notification.success({ duration: 6, description: ack.doneDescription })` whose body text explains the row's new state explicitly. `handleForceDriftSweep` gains auto-ack for parity with force-resource-sweep (backend `acknowledge_run` already idempotent). 13 new i18n keys present in both zh-CN.ts and en-US.ts.
> - **`cmdb_sync_jobs.capability_results` JSON shape (additive)** — alongside existing `{status, upserted, deleted, partial_failures, leg_count, forced_sweep, forced_sweep_*}` keys, capabilities now optionally carry `skipped_legs: [{leg, capability, reason}]` + `skipped_leg_count: N` when the driver classified any legs as structurally-expected skips. Backward compat: every consumer (failure-notifier `handlers.py:849`, scheduled-job rollup, MCP serialization, admin UI's `Record<string, unknown>` type) gracefully ignores unknown keys; no migration needed.

> **Earlier same-day**: 2026-05-17 (Round-23 follow-up — sync error-path partial_failures drain; zero migrations; no new MCP tools; no RBAC change):
>
> - **`_run_sync_impl` error path now drains `provider.partial_failures` into `capability_results`** ([sync.py::_run_sync_impl exception branch](../../server/app/domains/cmdb/sync.py)). Pre-fix the `except Exception` branch wrote only `{status: failed, error: ...}` and threw away any legs the driver had already populated before raising. The success path drains via `provider.consume_partial_failures()` at the per-cap tail, but a `_safe_sweep` `ProviderTransientError` (>5%-drop guard) fires BEFORE that drain runs — so legs accumulated during fan-out never reached the persisted `cmdb_sync_jobs.capability_results.<cap>.partial_failures` JSON column. Production symptom (post-PR #340 deploy, 2026-05-17): the new GCP zone-warning legs sat in `provider.partial_failures` exactly as designed, but every `:55` sync_jobs row showed `partial_failures: null` and the admin sync-history UI still rendered "refusing to sweep" with NO clue which zone was bad — exactly the visibility goal PR #340 was meant to deliver. Pre-existing gap for every fan-out driver (AWS / Aliyun / Tencent / GCP) but PR #340's GCP fix multiplied the driver's leg cardinality on the very codepath that trips the sweep guard, making the gap operationally visible. Fix: symmetric drain on the error branch — same `redact_leg_entry` wrapper as the success path, wrapped in a second-level try/except so leg-collection failure can't mask the original exception. New entry shape: `{status: failed, error: ..., partial_failures: [...], leg_count: N}` when legs exist, original shape otherwise. Pinned by `test_run_sync_error_path_persists_provider_partial_failures`.
>
> ----
>
> **Earlier same-day**: 2026-05-17 (Round-23 — cmdb.resource_sync 双 driver bug 修复; zero migrations; no new MCP tools; no RBAC change):
>
> - **AliCloud OSS `list_object_storage` now sends STS SecurityToken** ([providers/aliyun.py::list_object_storage](../../server/app/domains/cmdb/providers/aliyun.py)). Pre-fix: the OSS list-buckets codepath was the ONLY place in the AliCloud driver that bypassed `self._security_token` plumbing — every other RPC call injected it via `_common_params`/`_call`, but OSS uses REST-style header auth and required explicit wiring. Under STS-assumed sub-account credentials (`AlicloudIntlProvider._impersonate`) every OSS request hit `InvalidSecurityToken.NotFound` regardless of the role's policy attachments, surfacing as 100% `auth_error` legs in `alicloud-intl-primary` `object_storage` cron (production 2026-05-17, 18/18 RD members). Fix: token (when non-empty) emitted as `x-oss-security-token` header AND included in OSS Auth v1 `CanonicalizedOSSHeaders` line of StringToSign (signing protocol requires both halves — header alone gives `SignatureDoesNotMatch`). Re-reads `self._security_token` per pagination iteration so mid-fetch STS rotation propagates. Master-tenant tokenless path bit-for-bit identical to pre-fix behaviour. `_redact_aliyun_error` extended to strip `x-oss-security-token:<value>` from OSS XML error bodies (OSS echoes server-computed `<StringToSign>` on `SignatureDoesNotMatch` which embeds the full ~1h-TTL bearer token).
> - **GCP `compute.instances.aggregatedList` surfaces zone warnings as partial_failures** ([providers/gcp.py::_collect_zone_warnings](../../server/app/domains/cmdb/providers/gcp.py)). Pre-fix: driver passed `returnPartialSuccess=True` (so a single dead zone can't 503 the whole call), but only iterated `zone_block["instances"]` and never read `zone_block["warning"]` — GCP replaces `instances` with `warning` on zone failure WITHOUT raising. Production incident 2026-05-17: `gcp-primary` `servers` cron blocked for 8+ hours by the >5%-drop sweep guard (provider returned ~870 vs baseline ~938) with `partial_failures = NULL` — no signal to identify which zone was dropping instances. Fix: `_collect_zone_warnings` helper handles both singular `warning` (dict) and plural `warnings` (list) shapes; emits `partial_failure` legs with `reason=aggregatedList.zone_warning_<CODE>` and `leg=project:<id>:zone:<name>` (matches the sibling DNS leg shape so failure-notifier dedup keys stay consistent across capabilities). Per-call dedup set collapses cross-pagination duplicates (a problem zone that persists across N pages emits ONE leg, not N). Cloud-controlled `code` is sanitized to `[A-Z0-9_]` (defense against log injection via warning code). `_BENIGN_ZONE_WARNING_CODES = {NO_RESULTS_ON_PAGE, NO_RESULTS_FOR_LIST}` stay silent (every empty zone returns these — surfacing them would flood `partial_failures` with ~30 legs/project/tick). Real codes like `UNREACHABLE` / `ZONE_NOT_AVAILABLE` / `DEPRECATED_RESOURCE_USED` now route through the existing failure-notifier dedup by code, so the same problem zone fires once not hourly.
>
> ----
>
> **Earlier (2026-05-16)**: 全平台发布前多轮 review — 跨域 P0/P1 修复; migration head 169 → 170; no new MCP tools; no RBAC change:
>
> - **SSO Google grant cron wired in worker** — `sso.execute_pending_google_grants` (registered in `app/domains/sso/scheduler_handlers.py`) is now imported by `app/worker.py::_run` and seeded into `scheduled_jobs` by migration 170 (interval=300s, enabled). Pre-fix the API process saw the decorator via `app.main`'s router tree (local-dev hid the gap), but the worker's narrower import graph dropped it AND no migration seeded a row — so approved Google SSO grants sat in `status='approved' AND created_app_id IS NULL` forever with no IT-admin notification. Same bug family as PR #316's `identity_sync.dispatch` 9-day silent miss.
> - **CSV BOM on `cmdb/router._csv_response`** — `cmdb_servers.csv` / `cmdb_domains.csv` exports now prepend `\xef\xbb\xbf` so Excel-on-Windows opens Chinese resource names without Latin-1 / GBK autodetect mangling them. `cost_service.stream_facts_csv` already had this (Round-19); this is the missing twin.
> - **Dual-sign override active gates** ([`leaver_blocked/override_service.py`](../../server/app/domains/leaver_blocked/override_service.py)) — `initiate` / `secondary_approve` / `execute` each enforce `signer.deleted_at IS NULL AND employment_status == "active"`. The dual-sign contract is "still-present human co-signs the IT admin's hard override"; a soft-deleted or leaver/left secondary defeats that. The approved-but-unexecuted row can sit up to `expires_at` days before the IT admin clicks Execute, so all three phases re-check rather than relying on initiate-time state.
> - **`_is_in_manager_chain` rejects deleted intermediates** — the 4-level walk now short-circuits to `False` if any intermediate is `deleted_at IS NOT NULL`, and verifies the candidate match is also active + non-deleted. Pre-fix a historic accountability chain (manager-of-manager soft-deleted via HRIS correction) authorized fresh override approvals.
> - **Seat-sync handlers raise on total failure** ([`_seat_sync_helpers.raise_if_total_failure`](../../server/app/scheduled_jobs/_seat_sync_helpers.py)) — new shared gate applied at the tail of Feishu / Slack / Jira / Zoom / Google / Bailian sync handlers. When `summary["errors"]` is non-empty AND no progress counter advanced, raises `RuntimeError` so `scheduled_job_runs.status` flips to `failed` and the dedup-aware failure-notifier fires. Closes the "credentials rotated on every plan, admin console stays green for days" gap codified in memory `feedback_handler_status_must_raise.md`. Defensive refusals from `safe_to_drift_revoke` / `is_safe_max_seats_change` (vendor returned empty roster → refuse mass-revoke) are correctly classified as **non-failures** via the `_DEFENSIVE_REFUSAL_FRAGMENTS` filter so they don't false-fire on legitimate safety pauses.
>
> ----
>
> **Earlier same-day**: 2026-05-16 (MCP/CLI 发布前多轮安全审计 + 修复; zero migrations; no new MCP tools; tightened RBAC parity):
>
> - **MCP authorization parity** — `_require_audit_access` collapsed onto the same gate set as HTTP `require_audit_access()` (`{GLOBAL_ADMIN_ROLE, AUDITOR_ROLE}`; `MANAGEMENT_ROLE` dropped). New `_require_global_admin` helper for compliance-incident tools mirroring HTTP `require_admin()`. `reveal_api_key` on MCP now hard-rejects admin-on-behalf — the "stolen IT-Admin PAT cannot dump every employee's cloud credential" invariant documented in `apikey/router.py` is now MCP-enforced. `list_team_assets` admin branch returns empty (was global) + uses transitive `_resolve_subordinate_ids`. `list_cloud_servers` / `list_cloud_domains` fan out to manager subordinates (parity with `cmdb/router.py`).
> - **MCP middleware force_logout** — `PatAuthASGIMiddleware` now calls `_enforce_force_logout(user, pat_row.created_at)` after PAT auth + ``is_effectively_departed`` check. Force-logout terminates MCP sessions, not just REST.
> - **Outbound serializer key normalization** — `serialize()` runs `_is_sensitive_key(..., strict=True)` against a precomputed `_SERIALIZE_DENY_FIELDS_FLAT`. Catches header-style names (`Hashed-Password` → matches `hashed_password`) without the dash-split component scan that previously over-rotated on legitimate snake_case fields containing a deny short word (`last_token_rotation_at`, `pin_location`).
> - **Token-redactor patterns** — OpenAI `sk-` now two-alternative (prefix mode allows hyphenated body; legacy mode requires contiguous body) so real `sk-proj-AbCd-EfGh-...` keys are captured without over-rotating on hyphenated English. GitLab `glpat-` + Notion `secret_` added.
> - **`_SENSITIVE_KEYS` ⊆ `_SERIALIZE_DENY_FIELDS`** invariant pinned by a CI test (`test_sensitive_keys_subset_of_serialize_deny_fields`).
> - **CLI hardening** — `auth token set` uses `getpass.getpass` (no echo); `CliConfig.__repr__` overridden so dataclass auto-repr doesn't leak PAT; `_MIN_PAT_LEN` 16 → 32 to match real mint length; `output.redact()` strips terminal-escape + bidi-override codepoints; 30+ destructive MCP tools now prompt for tool-name confirmation on TTY (bypassable via `--yes`).
> - **New helper**: `_bound_free_text(value, field, max_len)` in `app/mcp/tools.py` for MCP-boundary free-text bounding when the underlying service doesn't enforce a cap.
>
> ----
>
> **Previously refreshed**: 2026-05-12 (PR #263 — AI 大盘归属下钻 + 云子账号 Owner 筛选; zero migrations; no new MCP tools; no RBAC change):
> - **`CloudScopesPage` owner filter** wired to backend `owner_person_id` param (was UI-only "无 Owner" sentinel) — `UserSelect` + sentinel Select side-by-side, mutually exclusive.
> - **Bailian three-level owner ladder** in `_bucket_bailian`: `api_key_owner` → `scope_owner` → `none`. Pre-fix every Bailian row hard-coded `user_id=None`, making "aggregate / 未分摊" the entire bucket. New `auto_owner_source` enum values: `api_key_owner`, `ak_ram_user`, `scope_owner`, `rule`, `manual`, `none`.
> - **AI Spending detail drawer** now shows per-row `auto_owner_source` tag + integration·scope·RG + resource_id + AK request_number, plus a "归属构成" summary card at the drawer top. Admin can finally tell whether a person's spike is real personal AK usage or subscription-owner fallback. Schema additions to `AiSpendingDetailRow`: `auto_owner_source`, `resource_id`, `ak_assignment_id`, `ak_request_number`, `ak_owner_label`. `AiBucketAttributionRow.auto_owner_source` now populated from the dominant ladder step per slot.
>
> **Previously (PR #257, 2026-05-12) — AI Spending Phase 2 refactor; migration head 149 → 154; no new MCP tools; no RBAC change**:
>
> - **4-bucket model** replaces the legacy "5 sources + dedup ladder" shape. Buckets: Seats / Bailian / Foundry / Bedrock (+ Vertex). Each is physically disjoint by table or WHERE clause — the grand total is their sum, not a deduplication of overlapping sources. See §12.9 for the table mapping.
> - **`platform_settings.ai_spending.service_whitelist`** is the new source of truth for "is this cost-fact row AI" (migration 150). Tenant-editable per-provider substring patterns; loaded at request time by [`app.domains.ai_spending.whitelist`](../../server/app/domains/ai_spending/whitelist.py). Settings page exposes a per-provider editor.
> - **Retired**: `cmdb_ai_resources` table (migration 152), `cmdb_ai_resource_cost_daily` table (migration 151), `cmdb_cloud_scopes.is_ai_dedicated` column (migration 153), `ai_spending.azure_openai_resources_sync` cron row (migration 154). The `PATCH /chargeback/{resource_pk}/owner` admin owner-override endpoint and its frontend drawer are also gone — owner overrides flow through the cost-allocation rule ladder.
> - **PII pseudonymisation on LLM prompts**: [`_hash_user_id`](../../server/app/domains/ai_spending/service.py) hashes user ids before they enter the Bailian DashScope payload (third-party); the server de-pseudonymises at render time by re-hashing known users. Pinned by a test that asserts no substring of the raw input leaks into the hash.
> - **Side-fix during audit**: [`_parse_owner_from_resource_id`](../../server/app/domains/ai_spending/service.py) used to return the value of the first `key=value` pair in an `aws-principal:...` resource_id regardless of key. A row like `aws-principal:service=bedrock` (no Owner tag) was being attributed to "bedrock" the user. Now checks the key is literally `Owner`.
> - **MCP tool docstrings** for `ai_spending_overview` + `ai_spending_movers` refreshed to reflect 4-bucket model and the new dimension set (`bucket / user / department / model / service`).
> - **Backend tests**: 64 new tests in `tests/test_ai_spending/` cover the whitelist module, pure service helpers (parse-owner, money, pct_change, period shift, hash_user_id, sum/distinct with Bailian exclusion), and Pydantic schemas (AiServiceWhitelist `extra="forbid"`, AiBucketAttribution + AiBucketAttributionRow shapes, AiSpendingSettingsUpdate partial semantics).
>
> ----
>
> **Earlier (2026-05-12, round-19 — Feishu `user_create` driver mode, default OFF)**: no migration; no new MCP tools; no RBAC change:
>
> - **FeishuSoftwareDriver gains 4th mode `user_create`** ([`server/app/integrations/drivers/feishu_software.py`](../../server/app/integrations/drivers/feishu_software.py)) — POST `/contact/v3/users` to actively CREATE a Feishu account on hire. Default OFF in the binding dropdown: HRIS sync remains the canonical source for account existence. Capability built for the narrow case where Helix must front-run HRIS (day-0 contractor access, Helix-managed tenant without HRIS coverage). Binding REQUIRES `default_department_id` (an `od-...` open_department_id from Feishu admin) because Feishu's create endpoint rejects requests with no department.
> - **Revoke symmetry** — `user_create.revoke` reuses the user_suspend freeze (default) / delete paths, gated by a per-mode `user_create_revoke_to_delete` flag that is INDEPENDENT of user_suspend's `hard_delete_on_offboard`. Two orthogonal opt-ins so an aggressive-removal policy on legacy user_suspend bindings doesn't accidentally cascade to user_create bindings.
> - **Idempotency** — Feishu codes 43002/43003/43005 (`mobile/email/employee_no already exists`) collapse to ok with `already_exists=True` so an HRIS race or partial-failure retry doesn't fail the grant. Department-not-found (99991664) is a hard ok=False with a clear admin-facing message pointing back to the binding's `default_department_id`. Other AppExceptions (token rotated, scope downgrade, transport blip) bubble as ok=False so the worker retries.
> - **Explicitly NOT built**: Feishu → Helix identity sync. Per round-17 design discussion, Feishu is downstream like the finance system, never an identity source.
>
> ----
>
> **Earlier (2026-05-12)** — round-20 comprehensive pre-launch audit, 6 P0 + 8 P1 + 8 P2; no migration; no new MCP tools; no RBAC change:
>
> - **`apikey/service.revoke_key` is now end-to-end** — cloud-side IAM teardown (Bedrock / Bailian / Azure OpenAI) runs BEFORE the local row is flipped to `revoked`. Failed cloud revoke raises `AppException(CLOUD_REVOKE_FAILED, 502)` and leaves the local row untouched so the admin retry is idempotent. Helper: [`_revoke_cloud_resources`](../../server/app/domains/apikey/service.py) dispatches by `assignment.detail.<kind>.granted`: Bedrock → AWS driver `revoke_account` (DeleteAccessKey → DetachPolicy → DeleteUser); Bailian → Aliyun driver `revoke_account` (same shape); Azure OpenAI → new Azure driver method [`delete_azure_openai_account`](../../server/app/integrations/drivers/azure_cloud.py) (ARM DELETE on the cognitive-services account — invalidates `key1`/`key2` AND stops the per-resource billing meter). Revoke is admin-only now (the self-revoke branch was removed for forensic integrity).
> - **Auto-grant matcher mutual-exclusion** ([`apikey/service`](../../server/app/domains/apikey/service.py)) — previous shape called every `maybe_auto_grant_*` unconditionally and trusted that "at most one matcher fires". A provider renamed "Aliyun Bailian (use Bedrock instead)" would match BOTH and double-mint cloud resources. Now: check matchers explicitly; if more than one claims the provider, refuse (assignment lands `approved` but key_value empty, `detail.autogrant_ambiguous` set so admin can rename + retry).
> - **AAD binding on Bedrock + Bailian autograny** — both autogrants now pass `assignment_id=` to `_encrypt_key_value` (mirrors AOAI / fill_key). DB-write attacker can no longer cross-row ciphertext-swap. Decrypt MUST pass the matching `assignment_id` (the function falls back to returning the raw envelope on AEAD mismatch — not the plaintext).
> - **`_to_response` strips sensitive Feishu payload** — `feishu.latest_instance_detail` (approver names + comments + full submitted form JSON) is removed from emitted `detail`; DB row unchanged.
> - **Cloud billing alerts now FX-convert per row** ([`cmdb/lifecycle_service.sync_billing_daily_for_integration`](../../server/app/domains/cmdb/lifecycle_service.py)) — CN-cloud drivers write native CNY into the legacy `total_cost_usd` column with `currency='CNY'`; the alert evaluator converts to USD via `app.core.fx_rate.resolve_cny_per_usd` before comparing against `threshold_usd`. `_alert_spike` Feishu card renders `¥`/`$` based on the row's currency. Closed scopes (`status != ACTIVE`) excluded from the billing poll loop (no more wasted Cost Explorer paid calls).
> - **Billing-alert recipient set skips leavers** — `_resolve_billing_alert_recipients` filters `employment_status in (left, leaver_pending)` across the scope-owner + grant-holder + cc paths. Stops routing operational alerts to soon-to-be-revoked corporate emails.
> - **`_dispatch_grant` switched to allowlist for secret-stripping** — `external_detail` persisted on `cmdb_cloud_access_grants.detail` is filtered through an explicit allowlist (`account_id`, `role_arn`, `external_id`, …) rather than a 3-key denylist. Future drivers returning `session_token` / `private_key` / `client_secret` / `password` / `service_account_key` no longer durably store credentials in DB.
> - **HR `_assign_manager` filters by `hris_instance_id`** ([`hr/service.py`](../../server/app/domains/hr/service.py)) — multi-region SF deployments no longer cross-wire an EU employee's `managerId` to a CN person with the same `userId`. Self-reference guard added. Mirrors the established `(hris_instance_id == self.hris_instance_id OR IS NULL)` pattern used by every other lookup in this file.
> - **HR mass-leaver brake helper** — new [`_count_active_persons_for_instance`](../../server/app/domains/hr/service.py) helper called from all three brake call sites (`confirm_batch` / `run_scheduled_incremental` / `process_webhook_events`). Scopes the `active_total` denominator so a CN-tenant batch is judged against CN's active population, not the global total.
> - **`leaver_blocked/advance-now` is now single-process** — admin's "Re-advance" click no longer calls `run_tick(today=None)` (which iterated every leaver_pending person and fired force-transfer on unrelated people). Direct `_advance_single(person)` inside a SAVEPOINT, with post-commit identity-sync event hook for transitioned persons. Idempotency-key wrapper preserved.
> - **IAM user-name SHA1 fallback** ([`aws_cloud._iam_user_name_for`](../../server/app/integrations/drivers/aws_cloud.py) + [`aliyun_cloud._ram_user_name_for`](../../server/app/integrations/drivers/aliyun_cloud.py)) — emails with empty-sanitized local-parts (fully non-ASCII like `张三@x.com`) no longer collapse to the literal `helix-user` (which collided for multiple such employees on one IAM user, shared policies, broke offboarding). SHA1-derived `helix-<12hex>` keeps the mapping 1:1 and deterministic.
> - **SSO grant admin-on-behalf cancel** ([`sso/service.cancel`](../../server/app/domains/sso/service.py)) — admin can cancel a leaver's orphan pending SSO grant; audit emits `sso.grant_request.admin_cancel` (distinct from `sso.grant_request.cancel`) with `cancelled_by_admin=true`.
> - **`mark_blocker_resolved_manual` appends notes** ([`offboarding/service.py`](../../server/app/domains/offboarding/service.py)) — empty caller no-ops instead of clobbering scan-time forensic context; non-empty appends with separator.
> - **`getattr(owner, "employment_status", "active")` → direct access** — fail-OPEN default removed from the scope-owner-rebind leaver guard (per [feedback_no_getattr_default_for_model_columns.md](../../.claude/projects/-Users-ucjmh-Desktop-Helix/memory/feedback_no_getattr_default_for_model_columns.md)).
> - **MCP error scrubbing** — `mark_person_leaver_manual` post-commit error message now routed through `_scrub_message` (prevents Feishu / Google bearer tokens leaking to LLM client).
> - **`valid_from` cannot be more than 1 day in the past** ([`apikey/schemas.ApiKeyRequestSubmit`](../../server/app/domains/apikey/schemas.py)) — `extend_validity` Feb-29 crash fixed (year+1 fallback to Feb 28).
> - **10 new regression tests** in [`server/tests/test_round_20_audit/test_round_20_audit_fixes.py`](../../server/tests/test_round_20_audit/test_round_20_audit_fixes.py) — covers revoke admin-only, revoke cloud-side dispatch + failure propagation, AAD round-trip + cross-row reject, latest_instance_detail strip, Feb-29 fallback, valid_from past rejection.
>
> ----
>
> **Earlier**: 2026-05-12 (round-18 — unified offboarding console + structured-error storage on `offboarding_blockers`; migration head 147 → 148; no new MCP tools; no RBAC change):
>
> - **Migration 148** ([`server/alembic/versions/148_offboarding_blocker_error_columns.py`](../../server/alembic/versions/148_offboarding_blocker_error_columns.py)) adds three columns to `offboarding_blockers`: `last_error_message TEXT NULL`, `last_error_at TIMESTAMP NULL`, `error_count INT NOT NULL DEFAULT 0`. Replaces the legacy "append every failure to `notes`" pattern from round-13 — new columns are OVERWRITTEN on each retry (no accumulating multi-KB stack traces). Migration's Python-side backfill batched at 500 rows/UPDATE extracts every `[T+0 自动转让失败] ...` line from `notes` into the new columns then strips them from `notes`. Idempotent (predicate filters processed rows).
> - **`compute_triage_bucket` helper** ([`server/app/domains/offboarding/models.py`](../../server/app/domains/offboarding/models.py)) — server-side classifier mapping (resolution × severity × error_count × provisioning_task state) into `needs_it` / `auto_in_progress` / `resolved`. Single source of truth: list endpoint, detail endpoint, leaver-blocked dashboard, admin frontend all consume this. Exposed via `OffboardingBlockerResponse.triage_bucket` + `OffboardingProcessResponse.{needs_it,auto_in_progress,resolved}_count`.
> - **`OffboardingService._batch_triage_counts(process_ids)`** ([`server/app/domains/offboarding/service.py`](../../server/app/domains/offboarding/service.py)) — collapses the per-row N+1 to a single GROUP-BY SQL query for list pagination. NULL-safety via `COALESCE(pt.status, '')` against the LEFT JOIN on `provisioning_tasks` (plain `pt.status='failed'` yields `NULL` for missing-task rows which then mis-routes through `CASE WHEN NULL → ELSE`).
> - **`advance_service` direct-revoke branches no longer swallow connector exceptions**. VPN / AI tool / cloud_access_grant revokes previously caught driver errors, logged at WARN, and marked the blocker `revoked_auto` regardless — silently obscuring vendor outages. Now exceptions propagate to the outer per-blocker SAVEPOINT handler which writes the failure to `last_error_*` + `error_count`, explicitly resets `resolution=pending`, and lets the next 15-min tick retry. Triage_bucket auto-promotes any pending blocker with `error_count ≥ 1` into `needs_it` so admins see it.
> - **New full-page admin route** `/admin/offboarding/process/:processId` ([OffboardingProcessDetailPage.tsx](../../web/src/pages/offboarding/OffboardingProcessDetailPage.tsx)). Replaces the right-side Drawer in `OffboardingAdminPage` + the offboarding-context use of `/admin/lifecycle/person/:personId`. One URL per leaver: flow pipeline + 3-bucket blockers (needs_it expanded, auto_in_progress + resolved collapsed) + override section + workflow timeline + audit + compliance incidents + reminders + notifications. Drawer / DetailDrawerBody / FlowTimeline + the per-blocker modals are all deleted from the list page.
> - **Shared lifecycle components**: [`FlowPipeline.tsx`](../../web/src/components/lifecycle/FlowPipeline.tsx) and [`LifecycleAuxiliaryPanels.tsx`](../../web/src/components/lifecycle/LifecycleAuxiliaryPanels.tsx) (compliance incidents + reminders + recent-notifications panels with embedded detail modals). Used by both the new offboarding detail page AND the existing `LifecyclePersonFlowPage` (kept alive for onboarding admin + compliance + insights).
> - **Sibling-page coverage**: `LifecycleModule.upcoming_leavers` row navigation retargeted to `/admin/offboarding/process/:processId`; `recent_hires` + `OnboardingProgressPage` retain `/admin/lifecycle/person/...` (onboarding-only).
>
> ----
>
> **Earlier (2026-05-11)** — Lifecycle pages consolidation (round-17): 1 new visual page + 1 unified offboarding page + 4 obsolete files deleted; no migration; no new MCP tools; no RBAC change:
> - **`入离职管理` menu collapsed from 6 → 5 children**, with the duplicate `LifecycleNav` horizontal strip deleted from every lifecycle page (sidebar already covered the same destinations). New shape: 总览 → **入职策略 (new visual page)** → 入职进度 → **离职管理 (unified — see below)** → 任务队列. Removed: 规则配置 (replaced by 入职策略) and 离职阻断 (merged into 离职管理).
> - **New `OnboardingStrategyPage`** ([web/src/pages/provisioning/OnboardingStrategyPage.tsx](../../web/src/pages/provisioning/OnboardingStrategyPage.tsx)) at `/admin/lifecycle/onboarding-strategy` — visual layout: KPIs → trigger → policy-match router → outcomes flow diagram → 3 columns by execution path (`auto_api` / `manual_notify` / `sso_auto`). Each policy renders as an inline-editable card. Execution path is computed client-side mirroring the worker decision: software with `offboarding_policy='sso_cascade'` → `sso_auto`, else `(connectorsBySoftware.get(software_id)?.is_active)` → `auto_api`, else `manual_notify`.
>   - **2026-05-14 consolidation**: `OnboardingStrategyPage` + `OnboardingProgressPage` are now hosted inside a single [OnboardingManagementPage](../../web/src/pages/provisioning/OnboardingManagementPage.tsx) at `/admin/lifecycle/onboarding` (sidebar entry renamed to **入职管理** — mirrors 离职管理). Both children gained an `embedded` prop that suppresses their inner `PageHeader` when the wrapper renders them. Active tab mirrors `?tab=progress|strategy`. Legacy `/admin/lifecycle/onboarding-strategy` redirects to `?tab=strategy`.
>   - **2026-05-15 同款 pattern for offboarding**: `OffboardingAdminPage` (进度) + `OffboardingRuleMap` (策略) are now hosted inside [OffboardingManagementPage](../../web/src/pages/offboarding/OffboardingManagementPage.tsx) at `/admin/offboarding`. The collapsible inline rule-map section in `OffboardingAdminPage` is hidden when the page is rendered with `embedded` (the wrapper owns the rule map as a sibling tab now). Tab key mirrors `?tab=progress|strategy`. Drives identical UX/affordances across onboarding + offboarding admin surfaces.
>   - **2026-05-15 RBAC drawer rewrite**: the role-catalog "查看详情" drawer previously repeated the table's blueprint columns (scope / grantMode / surfaces). Replaced with `RoleMembersPanel` that shows the **actual people** holding the role (avatar + display name + email + department) via `userApi.list({ role, page_size: 500 })`. `employee` / `leader` (auto-derived roles) render an `<Alert>` hint instead — every active user has `employee`, and `leader` is computed from `manager_id`, neither lives in `auth_role_persons`. The drawer seeds rows from the already-loaded `users` slice before the fetch so switching roles doesn't flash the previous role's members during the spinner.
>   - **2026-05-15 ITServiceDeskMascot upgrade**: the floating IT 服务台 mascot ([ITServiceDeskMascot](../../web/src/components/ITServiceDeskMascot/ITServiceDeskMascot.tsx)) now (1) broadcasts admin-configured security tips inside its speech bubble (same `/security-tips` feed the old header `SecurityTipsBanner` used — the freed header slot is now the [`PolicyNoticesBanner`](../../web/src/components/PolicyNoticesBanner.tsx) 制度公告 ribbon, migration 254); (2) is drag-repositionable via pointer events with `localStorage` persistence + viewport-clamped reads; (3) is hide-able via an on-hover × button, with a 44×44 purple restore pill that appears in the original corner. `z-index` lowered to 900 so Antd Modal/Drawer (default 1000) correctly overlay it. `onPointerCancel` is a separate handler from `onPointerUp` so an OS gesture-cancel doesn't leave `dragRef.moved=true` and dead-tap the next click.
> - **`OffboardingAdminPage` rewritten as unified management page** ([web/src/pages/offboarding/OffboardingAdminPage.tsx](../../web/src/pages/offboarding/OffboardingAdminPage.tsx)) at `/admin/offboarding` — collapsible `OffboardingRuleMap` section + 2 rows of 4 KPI tiles (combining the prior offboarding-progress and leaver-blocked KPIs) + process table + unified detail drawer. The drawer fetches `offboardingApi.getProcess()` AND `leaverBlocked.fetchDetail()` in parallel when `status === 'blocked'`, surfacing dual-sign overrides + audit log + workflow timeline inline. All operations (revoke / mark-resolved / skip / handover / suspend-sso / complete / cancel / advance-now / initiate-override / execute-override / cancel-override) happen in the drawer.
> - **`OffboardingRuleMap` reusable section** ([web/src/pages/offboarding/OffboardingRuleMap.tsx](../../web/src/pages/offboarding/OffboardingRuleMap.tsx)) — extracted from the old `OffboardingConfigOverviewPage`. Same flow-diagram + 3-column policy view + transferable-category grid, but with no internal PageHeader so it embeds cleanly inside the unified offboarding page.
> - **4 obsolete files deleted**: `web/src/components/jml/LifecycleNav.tsx`, `web/src/pages/admin/LeaverBlockedDashboardPage.tsx`, `web/src/pages/provisioning/ProvisioningAdminPage.tsx`, `web/src/pages/admin/OffboardingConfigOverviewPage.tsx`. Old URLs (`/admin/lifecycle/rules`, `/admin/leaver-blocked`, `/admin/lifecycle/offboarding-config`, `/admin/provisioning/policies`, `/admin/connectors`) redirect via `<Navigate replace>` so notification deep-links / bookmarks don't 404. The dual-sign secondary-signer review page at `/admin/leaver-blocked/override/:id` is preserved (notification template links to it).
>
> **Previously refreshed**: 2026-05-11 (AI-spending RG-aware attribution + cost_facts_sync Azure RG fix + cost resource table overflow — single PR, 3 issues; no migration; no new MCP tools; no RBAC change):
> - **AI Spending dashboard now splits dedicated-scope residual by RG ownership**: For an `is_ai_dedicated=true` Azure subscription, pre-fix the full residual (sub_total − Foundry per-resource chargeback) went to the SUBSCRIPTION owner — meaning Log Analytics / Storage / networking cost inside an auto-granted RG was misattributed to the IT-admin who registered the tenant instead of the API key applicant who owns the RG. The per-resource resolver ([`_resolve_owner`](../../server/app/scheduled_jobs/ai_spending_handlers.py)) ladder pre-fix was `tag → subscription scope owner`; the Azure OpenAI auto-grant's `Owner=<project>` tag never matched a person email, so attribution always fell through to the subscription owner. Now the resolver walks `tag → RG-scope owner → subscription scope owner` (the helix-owned `helix-owner-email` tag is also matched), and [`_collect_dedicated_scope`](../../server/app/domains/ai_spending/service.py) splits the sub residual by RG ownership using `cmdb_cost_facts` for the per-RG breakdown and subtracts priority-1 Foundry chargeback inside each RG to avoid double-counting. See §12.9 invariants for the full ladder. Pinned by 7 + 4 new tests.
> - **Resource-ranking table overflow on `/cloud/overview` 账单 tab**: Pre-fix the table had no per-column width or `tableLayout` set, so Antd's `auto` layout grew the resource_id column to fit ~200-char Foundry ARM ids and pushed the 14d trend + cost columns off-screen. Fix: `tableLayout="fixed"` + explicit widths on all columns except resource_id (which inherits remaining space) + `ellipsis: { showTitle: false }` with `<Tooltip>` so the full ARM id is still visible on hover.
> - **`cmdb.cost_facts_sync` partial_failure on Azure RG rows fixed**: Triggered by every Azure Foundry per-tick run landing `partial_failure` with `Azure Cost Management query failed sub=TiDBDocsTranslation-RG status=400 body='{"error":{"code":"InvalidSubscriptionId",...}}'` for every RG the Azure OpenAI auto-grant flow had ever provisioned. Root cause: cost-sync iterates every active `cmdb_cloud_scopes` row for the integration without filtering by `scope_type`; the auto-grant flow ([azure_openai_autogrant.py](../../server/app/domains/apikey/azure_openai_autogrant.py)) mints `scope_type='azure_resource_group'` rows whose `external_id` is the RG name; the Azure driver then builds `/subscriptions/{rg_name}/...` and Azure rejects with `InvalidSubscriptionId`. Both `sync_cost_facts_for_integration` ([cost_service.py](../../server/app/domains/cmdb/cost_service.py)) and `sync_billing_daily_for_integration` ([lifecycle_service.py](../../server/app/domains/cmdb/lifecycle_service.py)) now `WHERE scope_type IN TENANT_LEVEL_SCOPE_TYPES`. Driver-level defence in depth: Azure driver's `list_cost_facts` / `list_billing_daily` / `list_billing_by_dimension` raise `NotImplementedError` for non-`azure_subscription` scopes — caller maps that to `not_supported` (no error counted). RG-bound spend still attributes to the applicant via the priority-10 allocation rule the auto-grant writes alongside the RG `cmdb_cloud_scopes` row (keyed on `match_resource_group=<rg>`). Pinned by `test_sync_cost_facts_skips_sub_tenant_scope_rows`.

> **Previously refreshed**: 2026-05-10 (Round-19 FinOps comprehensive audit — 1 P0 + 6 P1 + ~15 P2 in one PR; no migration; no new MCP tools; no RBAC change; multi-round audit clean). Cross-cutting platform invariants reinforced:
> - **GCP cost-facts ingest unblocked, multiple structural correctness fixes**: Triggered by every per-scope GCP `cmdb.cost_facts_sync` failing with `400 Unrecognized name: cost_in_usd at [25:19]` (observed 2026-05-10 across `gcp-qa-perf-testing-dev` / `gcp-serverless-engine-dev` / `gcp-pm-preview`). `GcpCloudDriver._list_cost_facts_sync` ([gcp_cloud.py](../../server/app/integrations/drivers/gcp_cloud.py)) referenced `SUM(cost_in_usd)` since PR #226 — but **GCP's BigQuery billing export does not carry a `cost_in_usd` column** (that's AWS CUR). Fix uses `SUM(SAFE_DIVIDE(cost, currency_conversion_rate))` against real schema. The audit-loop also caught a **P0 natural-key collision**: `usage_type` was set to `sku.description` which collapsed cross-region SKUs into one fact_key, silently overwriting cross-region cost on every standard-export tenant; now `f"{sku_id}@{region}"` (sku_id is globally unique within GCP). Plus a **batch of P1s**: currency mix in cross-currency aggregation (currency + usage_unit now GROUP BY keys, not ANY_VALUE), ARRAY_AGG label non-determinism (`ORDER BY ARRAY_LENGTH(labels) DESC, TO_JSON_STRING(labels) DESC LIMIT 1` biases toward populated AND breaks ties), NULL currency silent USD default (now → "UNKNOWN" + amount_usd=0), partition window padding ±3d/+7d for back-dated SUD/CUD credits, BQ Client `project=` pinned to billing-export project, defensive `maximum_bytes_billed=50 GB` + `job_timeout_ms=300s`, `try/finally client.close()`. **`sku_description` is intentionally NOT in GROUP BY** (GCP renames SKUs marketing-side mid-window; kept as `ANY_VALUE`). Pinned by [test_gcp_cloud_driver.py](../../server/tests/test_integrations/test_gcp_cloud_driver.py) (4 new tests).
> - **AlicloudIntl `list_billing_daily` USD bills no longer 7×-undercounted (parallel to PR #235's cost-facts fix)**: subclass inherited the parent's CN-only implementation that hardcoded `currency="CNY"` and stamped raw `PretaxAmount` (USD on intl) into `total_cost_usd`; downstream USD-conversion would divide already-USD by `cny_per_usd` (~7.2). Override now honours per-row `Currency` via `_convert_pretax_amount` (USD identity / EUR static FX / CNY drop-with-WARNING) and emits `currency="USD"` with correctly-converted totals. **`requires_cny_fx = False`** so a platform-FX-setting outage doesn't fail intl ingestion. **FX resolved via `resolve_cny_per_usd`** (short-lived session) for parity with cost-facts. Pinned by structural assertion in [test_alicloud_intl_cost_facts_currency.py](../../server/tests/test_integrations/test_alicloud_intl_cost_facts_currency.py).
> - **Cross-cloud FinOps fail-closed parity**: UCloud `list_billing_daily` `NoAuth`/`Permission` now raises; Azure `list_billing_by_dimension` raises on non-200 + raises when response missing requested grouping column; AWS `list_billing_by_dimension` raises on missing boto3; Tencent `list_billing_daily` raises on row-cap-hit. All four symmetric with their `list_cost_facts` paths. Azure error body is **control-char-stripped + 200-char-capped** before landing in `RuntimeError` / `scheduled_job_runs.errors` / failure-notifier (defends against misconfigured proxy WAF echoing Bearer tokens).
> - **Allocation rule matching case-insensitive** for `match_resource_group` / `match_service` / `match_tag_value` (Azure RG names are case-insensitive on the cloud side). Tag KEYS remain case-sensitive (clouds enforce it at API level). `tag_val` wrapped in `str(...)` before lowercasing to defend against non-str tag values. Stable rule tiebreaker now `(priority, created_at, id)`.
> - **Backfill earliest-allowed floor: 425 days** (~14 months — AWS Cost Explorer retention floor) on both HTTP `/cmdb/cost/backfill` and MCP `cost_backfill`.
> - **Per-integration inner locks** on `run_cost_facts_sync` and `run_billing_daily_sync` (key `cloud_sync:cost_facts:{integration_id}` / `cloud_sync:billing_daily:{integration_id}`). All three call paths (HTTP, MCP, cron) serialise on the per-integration key.
> - **MCP `cost_recompute_allocation` single-flight** with 1800s TTL (raised from 600 on 2026-05-27).
> - **Anomaly detection leave-one-out**: candidate spike day no longer contributes to its own baseline median/IQR.
> - **CSV export UTF-8 BOM** so Excel-on-Windows doesn't mangle Chinese resource names.
>
> **Earlier (2026-05-10, lifecycle pages JML rebuild — LeaverBlocked + extracted ProvisioningQueuePage + shared LifecycleNav + idempotency on 4 admin mutations)**:
> - **Shared `LifecycleNav` strip** ([web/src/components/jml/LifecycleNav.tsx](../../web/src/components/jml/LifecycleNav.tsx)) — sits below `PageHeader` on all four admin lifecycle pages and provides one-click hops with badge counts. Reuses the JML primitive folder convention.
> - **`ProvisioningQueuePage`** ([web/src/pages/provisioning/ProvisioningQueuePage.tsx](../../web/src/pages/provisioning/ProvisioningQueuePage.tsx)) — task queue is now a dedicated page at `/admin/provisioning/tasks` instead of a tab inside `ProvisioningAdminPage`.
> - **`LeaverBlockedDashboardPage` rebuild** — was the last admin page on raw `<Card><Statistic>` + a `document.getElementById('cancel-reason')` hack. Rebuilt against the JML primitives.
> - **`Idempotency-Key` extended to four state-changing admin endpoints**: `POST /leaver-blocked/processes/{id}/advance-now`, `POST /leaver-blocked/overrides/{id}/execute`, `POST /offboarding/processes/{id}/suspend-sso`, `POST /offboarding/processes/{id}/complete`. Each scopes the key per `resource_id` so cross-resource replay is structurally impossible. RBAC check runs BEFORE `idem.lookup()`. State-machine guards in the service layer remain the last line.
>
> **Earlier (2026-05-10, round-19 cross-module pre-launch audit — 4 fixes in one PR)**:
> - **Driver transport-failure rule (extended to GitHub)**: every cloud + SaaS driver MUST distinguish "API said `not found`" from "transport-layer failure" on lookup paths that feed mutate decisions. [github_software.py `_find_user_id`](../../server/app/integrations/drivers/github_software.py) signature returns the third `network_error` slot specifically so `revoke_seat` and `_reactivate` can branch on transport failure independently from "SCIM filter returned 0 rows".
> - **Pagination-cap rule (extended to Tencent daily-billing)**: every cloud billing driver's pagination loop, when it hits its hard-page-cap, MUST raise rather than return the partial snapshot.
> - **OData `$top` pitfall (HRIS sync)**: `SuccessFactorsClient.list_current_jobs` default flipped from `limit=10` → `limit=None`.
> - **CLI explicit-verify rule**: `app/cli/http_client._build_client` now passes `verify=True` explicitly.
>
> **Earlier (2026-05-10, round-18 cloud-connector audit — 1 P1 + 1 P2 in one PR)**:
> - **AliCloud International USD bills no longer 7×-undercounted (cost-facts surface)**: `AlicloudIntlCloudDriver` was inheriting `list_cost_facts` from the CN-denominated parent. Fix introduces a `_convert_pretax_amount` hook on the parent driver and overrides it in the subclass. Pinned by 9 regression tests in [test_alicloud_intl_cost_facts_currency.py](../../server/tests/test_integrations/test_alicloud_intl_cost_facts_currency.py).
> - **Dead-code self-provision check collapsed**: `manual_provision()` in [scope_request_service.py](../../server/app/domains/cmdb/scope_request_service.py) had two consecutive identical `if str(req.requester_person_id) == str(actor.id)` guards (different error codes — copy-paste artifact). The first won every time; the second was unreachable. Collapsed to a single guard emitting `CLOUD_SCOPE_REQUEST_SELF_PROVISION_FORBIDDEN`. Behaviourally a no-op (the four-eyes invariant is unchanged); reduces the chance a future refactor "fixes" the duplicate by deleting the wrong copy and weakening the gate.
>
> **Earlier (2026-05-10)**: round-17 final pre-launch comprehensive audit — 5 P0 + 8 P1 + 14 P2 in one PR; no migration; no new MCP tools; no RBAC change. Cross-cutting platform invariants reinforced:
> - **Mass-revoke safety floor** is now load-bearing on every cloud `list_scope_grants_sync` (auth errors raise; only "not found" returns empty). GCP was the holdout; AWS / Aliyun / Tencent already raised.
> - **Cloud-driver fail-closed credential rule**: every cloud driver MUST validate ak/sk presence before any SDK call. Documented at [aws_cloud.py](../../server/app/integrations/drivers/aws_cloud.py) `_boto_client`, [tencent_cloud.py](../../server/app/integrations/drivers/tencent_cloud.py) `_tc_credential`. Aliyun / Azure already do it. Future driver authors: copy the pattern, never trust the SDK's ambient credential chain on the prod EC2 host.
> - **Google Workspace pagination**: `list_all_groups` / `list_all_users` / `list_group_members` ALL fail-stop on any page failure (raise `GoogleApiError`). Truncated rosters reaching seat-sync would mass-revoke; this is non-negotiable. Defense-in-depth ratio floor on group-level soft-delete now uses ACTIVE-only denominator so it doesn't drift weak as historical 'deleted' rows accumulate.
> - **fx_rate band agreement**: cron writer band `0.05 < rate < 1.0` and reader band MUST agree — the helper at [server/app/core/fx_rate.py](../../server/app/core/fx_rate.py) is the single source. A pinned regression test ([test_get_usd_per_cny_band_matches_cron_write_band](../../server/tests/test_core/test_fx_rate.py)) blocks future drift.
> - **MCP request-body cap** now applies to chunked/streaming bodies via a `_capped_receive` wrapper around `receive`. Authenticated PAT holders can no longer bypass the body limit by switching to chunked transfer encoding.
> - **Sensitive-action gates**: `require_browser_session` covers all integration and platform-setting mutations + `/integrations/{key}/test` (catastrophic-if-stolen-PAT vector). `check_sensitive_endpoint_rate_limit` covers `/auth/me/reveal-contact` (was missing) and `/dashboard/insights/export/{section}` (org-snapshot exfil vector). Notification outbox `/retry` is now idempotency-gated against terminal rows.
>
> **Earlier (2026-05-09, JML console redesign — frontend-only)**. New shared primitive folder `web/src/components/jml/` (`JmlHelpers.tsx` + `jmlTones.ts`) hosts the truncate-with-tooltip cell, status pill, accent-stripe section card, clickable metric tile, and progress bar reused across every JML admin + employee page. Pages rewritten with the new visual language: `LifecycleOverviewPage`, `OnboardingProgressPage`, `OffboardingAdminPage` (incl. `DetailDrawerBody` sub-component with request-counter race guard), `MyLifecyclePage`, `MyTodosPage` (incl. `SafeContextData` redactor that replaces the previous raw `JSON.stringify(context_data)` in the software-owner drawer — see PII coverage in `SENSITIVE_KEYS` / `SENSITIVE_SUBTREES` / suffix matchers). New blocker-action whitelist `REVOKABLE_TYPES` in `OffboardingAdminPage` mirrors the backend's `target_type_map` so the Revoke button is hidden for resource types the auto-revoke path can't handle (cloud_account / software_owner / email_group / domain / dns_record / cloud_server / cloud_access_grant). No backend changes, no new migration, no new MCP tools, no RBAC changes. See ROADMAP.md for the full review-round digest.
>
> **Earlier (2026-05-09)**: admin UX polish on top of round-15: billing absorbed into cloud overview tab + asset workbench multi-tab + onboarding queue filter; no new migration. See ROADMAP.md for the digest. Cross-cutting platform additions: (a) `CloudBillingPage` gained an `embedded?` + `embeddedTenantId?` prop pair so the same component renders both as a standalone page and as a tab inside `CloudOverviewPage` (the new admin "账单" tab). (b) `/admin/cloud/billing` is now a `<Navigate>` redirect to `/admin/cloud/overview?tab=billing` and the sidebar entry is gone; deep links survive. (c) `AssetWorkbenchPage` is now a 4-tab workspace (`?tab=overview|operations|distribution|health`), with the new "资产健康" tab pulling warranty-expiring / in-repair / lost / retired counts straight off the existing `getStats()` payload (no new endpoint).
>
> **Earlier (2026-05-09)**: round 15 — pre-launch JML deep audit, 2 passes: 4 P0 + 5 P1 + ~15 P2 in Round 1, then 1 P0 + 3 P1 + 11 P2 in Round 2 catching net-new bugs from Round 1 fixes. Headline changes anchored in the JML pipeline (offboarding / leaver-blocked / identity-sync / provisioning / hr_lifecycle / SF auth):
> - **Boomerang re-hire reopen**: `OffboardingService.start_process` reopens both `PROC_CANCELLED` AND `PROC_COMPLETED` (re-hired-then-re-leaving employee). Reopen branch now clears ALL `OffboardingReminderLog` rows for the reused process_id, not just `process_started_*` — without this cycle 2's T-7/T-3/T-1/escalation/SLA notifications would silently no-op. Reopen also clears `force_logout_at` on the person + resets `sla_breach_level` / `first_blocked_at` so cycle 2 starts at the un-breached baseline.
> - **HRIS-driven offboarding now wires post-commit dispatch**: `confirm_batch` in `hr/service.py` calls `OffboardingService.dispatch_post_commit_actions` after the per-event commit. Previously the manual-mark path was the only caller, so HRIS-driven leavers (the dominant scaling path) received zero notifications + zero Google `users.signOut`. New admin route `POST /offboarding/processes/{id}/re-notify` lets operators retry the dispatcher when it partial-fails (no automatic retry exists).
> - **Provisioning cron commit-per-task**: `execute_pending_batch` (the `provisioning.dispatch` cron path) now commits after each task and rolls back on failure. Closes the worker-crash-mid-batch ghost-grant window. The `bulk_execute` admin path already had this since 2026-05-06; the cron path was missed until round 15.
> - **`complete_process` state gate**: refuses SCANNING / CANCELLED / READY-without-`sso_suspended_at`; idempotent on COMPLETED. Closes admin-misclick "complete from arbitrary state". Legacy `hr.complete_offboarding` path now mirrors the same `sso_suspended_at` check when an active OffboardingProcess exists.
> - **`force_logout_at` enforcement**: new `_enforce_force_logout(user, token_iat)` helper in `core/dependencies.py` rejects JWTs whose `iat` predates `IdentityPerson.force_logout_at`. Same gate runs for PATs against `pat_row.created_at`. Severs the leaver's existing browser tabs the moment offboarding fires, instead of waiting for natural JWT TTL.
> - **Escalation cluster narrowing**: `MUST_TRANSFER_RESOURCE_TYPES` reduced to `{hardware, api_key}` — the only types `_apply_transfer` and `_initiator_still_holds_asset` actually support. SSO grants / domain registrations / cloud scope ownership / Google orphan ownership are revoked / FK-rewritten directly by `LeaverAdvancer._handle_one_blocker`, NOT via a manager-accept transfer. `_blocker_to_transfer_type` now raises `KeyError` on unmapped types so a future contributor catches the gap immediately. Reminder bands rewritten to fire only the LOWEST un-fired band (T-1 supersedes T-7 + T-3 on cold-start at delta=1) — eliminates the spam case where outage-recovery fanout produced 3 cards in one tick. `_walk_to_active_manager` skips inactive / leaver_pending managers in the chain.
> - **Identity-sync mass-action brake**: refuses to apply destructive `on_offboard` policy when `out_of_scope > max(25, 10% of total_known)` OR when `in_scope_count == 0 and out_of_scope >= 5`. Mirrors the HRIS confirm_batch brake; closes the "edit filter → wipe SCIM directory" path.
> - **`trust_env=False` parity** swept across every remaining outbound HTTP client: `auth/google_sso.py`, `identity/successfactors_auth.py` (SAML 2.0 bearer + OAuth2 client_credentials token exchanges — the highest-consequence gap before this round), all 13 `auditor/integration_adapters.py` sites, `bailian/{client,llm_client}.py`, `cmdb/providers/{aliyun,azure,ucloud}.py`. Closes env-proxy MitM on every long-lived credential exchange and every external read that influences security-auditor decisions.
> - **Admin-override transfer cancel**: new `POST /transfers/{id}/admin-cancel` route + MCP tool `admin_cancel_asset_transfer`. Lets a global_admin clear orphaned peer-to-peer transfers where the initiator has left the company. Audit row `transfer.cancelled_admin_override`.
>
> **Migration head**: 157 (URL setting consolidation — see "Frontend URL — single source of truth" note in §5 / §6 below). **MCP tool count**: 122. **RBAC**: no role changes. **Tests**: 11 new regression tests in `test_round_2026_05_09_jml_audit.py` covering every P0/P1; full suite (offboarding + auth + leaver_blocked + provisioning + hr) green at **178 passed**. See ROADMAP.md round-15 entry for the complete punch list and Round 2 net-new-bug analysis.
>
> **Frontend URL — single source of truth (migration 157, 2026-05-13)**: `platform_settings.app.frontend_base_url` is the ONE key every notification path reads to prefix a clickable absolute URL. The deprecated parallel keys `portal.base_url` (used to be read by 7 caller files including offboarding escalation, advance_service IT grace push, asset inventory, hire_day, provisioning manual-notify, etc.) and `frontend.base_url` (zoom seat coordinator only, never seeded) are GONE — migration 157 deletes the `portal.base_url` row and every caller now reads `app.frontend_base_url`. Because the prior dual-key state silently leaked `localhost:3000` into prod emails / Feishu cards (callers pre-pended `portal.base_url` into already-absolute URLs, defeating `_resolve_absolute_url`'s centralized lookup), any future caller that needs a frontend prefix MUST read `app.frontend_base_url` directly OR pass `url=` as a relative path and let `NotificationService._resolve_absolute_url` resolve it (the centralizer also auto-injects the resolved absolute URL into `format_context["portal_url"]` for template `{portal_url}` substitutions). The round-14 static regression test (`test_round14_severity_and_url_audit.py`) pins the no-inline-hardcoded-URL rule across the codebase.

> **Earlier (2026-05-08)**: round 13 — P01121/Sara Fox walkthrough. Bundled fix for force-transfer cascade-failure root cause (missing `asset_label` IntegrityError → session poisoning), Zoom 429 idempotency, software-seat-skip, per-blocker SAVEPOINT, T+0 1-day grace + IT push, hardware office-admin recipient lookup, leaver self-service action buttons, click-to-expand notes/reminder/notification modals on lifecycle person flow page. No migrations / no new MCP tools / no RBAC changes. Migration head still 135. See ROADMAP.md round-13 entry for the full punch list.
>
> **Latest (2026-05-09)**: 15th audit (round 15) — scheduled-job failure visibility. Migration head 136 → 137 (adds `scheduled_job_runs.acknowledged_at` / `acknowledged_by_id` FK to `identity_persons.id` ON DELETE SET NULL / `ack_reason`; adds `scheduled_jobs.last_failure_signature` + `last_failure_notified_at` for notification dedup; adds `ix_scheduled_job_runs_status_ack` on `(status, acknowledged_at)` to accelerate the cross-job failed+unacked scan). New `app/scheduled_jobs/failure_notifier.py` module: every transition to `status='failed'` (handler exception, "no handler registered" branch, lock-infrastructure failure) computes a stable signature (handler_key + first error line with UUIDs / ISO timestamps / hex / numbers stripped) and dispatches one in-app + Feishu + email notification per active `it_admin` / `global_admin` ONLY when the prior failure had a different signature OR was acknowledged — consecutive same-signature failures are silenced. New ack endpoint `POST /api/v1/scheduled-jobs/runs/{id}/acknowledge` (admin-gated, idempotent, refuses non-failed) keeps `status='failed'` on the row but hides it from the default red queue via the new `include_acknowledged` query param on the run-list endpoint. Frontend `ScheduledJobRunsPage` adds the `标记为已确认` button + show-acked checkbox + 确认人/at/reason banner. New MCP tool `acknowledge_scheduled_job_run` brings the registered count to 122. 13 new tests in `tests/test_scheduled_jobs/test_failure_ack_and_dedup.py` (signature stability + dedup paths + ack endpoint + RBAC + filter).
>
> **Earlier (2026-05-08)**: 14th audit (round 14) — offboarding blocker classification + notification URL leak audit. Migration head 135 → 136 (adds `offboarding_blockers.severity` with `blocker | advisory` enum and an index on `(process_id, severity, resolution)`; backfills 17 known auto-handled types to `advisory`). UI surface change: admin offboarding page now splits the blocker queue into 「需 IT 介入」 (severity=blocker) / 「系统将自动处理」 (severity=advisory, T+0+24h auto-handled) / 「已处理」 (history); stats card replaces "blocked count" with the split metrics. New IT-visibility hook: `respond_to_transfer` accept fires fire-and-forget `transfer_accepted_it` to all active global_admin/it_admin so the inventory team has visibility into ownership changes without an HRIS ticket. Inline `localhost:3000` URL leak in 3 notification body sites (`leaver_it_grace`, `provisioning.manual_notify`, `zoom.seat_evicted`) eliminated by moving body content into bilingual templates and stripping inline `{portal_url}/...` interpolation; the `url=` action button is now the only CTA. Static regression test in `test_round14_severity_and_url_audit.py` walks every `*.py` under `server/app` and pins this rule. **No new MCP tools** — `_REGISTERED_TOOLS_COUNT_HINT` unchanged at 121.

> **Earlier (2026-05-08)**: 12th audit — offboarding flow hardening pass triggered by a real-person mark-leaver walkthrough. 13 fixes landed in one bundled PR. Migration head 134 → 135 (one seed row for `offboarding.reconcile_processes`). MCP tool count 120 → 121 (`get_offboarding_preview` added). Headline changes recorded in §12.5: post-commit dispatch (`dispatch_post_commit_actions`), per-task try/except in `suspend_sso`, `_get_process(lock=True)` opt-in, `force_logout_at` cleared on cancel, sso_cascade blockers re-pended on cancel-restart, `already_advanced` tightened to real-evidence, last_working_day bounded ±7d/+365d, successor TOCTOU re-validated at execute time, multi-transfer race closed via initiator-still-holds check, `start_process` fully transactional + scan-failure flips status to BLOCKED instead of stranding SCANNING, leaver_date update on existing process, dry-run preview endpoint + MCP tool + frontend modal preview, 5-minute reconcile cron, 4 new bilingual notification templates (process_started_leaver / _manager / ready / completed). 12 regression tests in `test_pre_launch_audit_2026_05_08.py`.
>
> **Earlier (2026-05-08, round 12 P2 closeout)**: pre-launch P2 closeout from Kevin Lu's IT 宇宙 screenshot. Migration head = 134. Four fixes in one PR: (a) **Overview Tree pending-cancel tag** — `UserItUniverseTab.tsx` overview tree now renders the 「待取消」 marker (was already on the seat sub-table but missed on the tree, leading admins to think `cancel-next-month` had failed when it was just scheduled). (b) **Seat-sync import grace window** — new `import_dedup_filter(model, *, now, grace_hours=2)` helper in `_seat_sync_helpers.py`, applied across all 5 handlers (zoom / slack / google / jira / feishu). The dedup map now includes recently-revoked rows so a freshly-revoked seat with vendor revoke still inflight isn't re-imported as a duplicate active row — symmetric to the existing 2h fresh-grant grace on the drift-revoke side. (c) **Offboarding cancel auto-restore** — `OffboardingService.cancel_process` now flips back any seats whose `auto_revoke` ProvisioningTask reached COMPLETED, re-queues a vendor grant on the same row, and skips when admin already manually re-granted (migration-119 unique-index respected). The cancel response payload now lists `restored_seat_ids` + `restoration_grant_task_ids` + `skipped_restore_reasons`. (d) **HRIS full-roster reconciliation** — new `hris.absent_person_check` scheduled job (06:00 UTC daily, migration 134) detects Helix-active persons whose `hris_person_id` is missing from the live SF roster (admin hard-deleted SF user, webhook fail-silently, tenant migration). Detection-only with mass-leaver brake at `max(25, 5%)`; admin acts via the manual sync preview/confirm. Closes the gap where `run_hris_incremental_sync` (event-driven on lastModifiedDateTime) couldn't see persons that disappeared without firing an offboard event.
>
> **Earlier (2026-05-08)**: 11th pre-launch audit closeout — P1 cleanup PR follows the 11-P0 PR #206. ALL 13 P1 + ~10 P2 findings resolved. New: per-request `current_actor_user_id` + `current_request_trace_id` contextvars in `app.auth.auth_context` (set by `get_current_user` + `TraceIdMiddleware`); `LeaverBlockGuard.flip_to_left` now cascade-cancels PENDING rows across 5 request domains (SSO / VPN / AI-tool / announcement / domain registration) at flip-time; `_force_transfer_all` + `_apply_transfer` write `AssetHistory` rows on FK rewrite; `cancel_process` requires `confirm_irreversible=true` past T+0; `cancel_task`/`retry_task` row-locked; `bulk_execute` per-task try/except; `apikey_assignments.key_value` AES-GCM AAD bound to row id; Google Workspace `_poll_data_transfer` waits for terminal status before suspending; HRIS `confirm_batch` honors mid-loop cancel; reminder cascade backfill on missed days; `feishu_chat_approval` v1 webhook path enforces timestamp window. Migration head unchanged. Earlier in-day): 11th pre-launch audit — JML / account-toggle hardening; 11 P0 closed in one PR. Migration head = 131; no new migration in this audit pass — fixes are pure code. New `AI_TOOL_STATUS_REVOKED` constant added to `ai_tool_application.models` without DB enum constraint changes since the column is `String(32)`. Affected modules: `app.identity.successfactors_client`, `app.approval.feishu_client` (both gained `trust_env=False, follow_redirects=False`), `app.scheduled_jobs.handlers` (cleanup_stale_running column-name fix), `app.hr_lifecycle.advance_service` (synthetic-transfer guard + VPN/AI-tool elif branches), `app.domains.ai_tool_application.{models,service}` (new revoke method + status), `app.domains.offboarding.service` (revoke_blocker dispatches VPN/AI-tool to source domain), `app.domains.hr.{schemas,router,service}` (deprecated import endpoint deleted; webhook gained body HMAC + ms/s timestamp auto-detect + mass-leaver brake), `app.integrations.drivers.{aliyun,tencent,aws}_cloud` (mass-revoke safety: list_iam_users / list_scope_grants raise instead of swallow). Cross-cutting verifications confirmed: `employment_status='left'` writes converge to `LeaverBlockGuard`; JML domain has zero hard-delete history paths; transfer IDOR closed at service layer.). 10th pre-launch audit (2026-05-08, see ROADMAP.md for the full punch list). Six launch-blocker fixes landed: (a) `_dispatch_grant` durability — commits PENDING before `driver.grant_access`, commits success/failure outcome before notification side effects, closing the last "ghost grant" window after PR #190's wider commit-before-call sweep. Affects `approve_grant` / `owner_add_member` / batch group-approve. (b) `getattr(integration, "enabled", True)` in `scope_request_service` was fail-OPEN if the column gets renamed — replaced with direct `integration.enabled`. (c) 17× same antipattern in `hr/router._serialize_batch` rewritten as direct ORM attribute access. (d) MCP `list_my_cloud_grants` paginated (default 200 / hard cap 500) — was unbounded, a long-tenured employee could pull a multi-MiB blob into the LLM context. (e) MCP tool `force_cloud_grants_drift_sweep` REMOVED — name matched the launch-blocker pattern denylist (`^force_*`); HTTP route retained for admin web access (single carve-out from MCP-sync rule). `_REGISTERED_TOOLS_COUNT_HINT`: 120 → 119. (f) 12 frontend tables migrated from raw `pagination={{ pageSize: N, showSizeChanger: false }}` to `previewPagination(N)` across admin-insights / AI spending / Bailian admin / cloud overview tabs.
>
> **Earlier (2026-05-08)**: cloud-side orphan-principal cleanup + console pivot + leaver cc-strip; migration head = 131. New §13.1.1 documents the AWS Identity Center "User not found" sweep — admin-confirmed delete via `POST /cmdb/cloud-grants/orphan-cleanup`, history rows with `revoke_reason='orphan_principal_swept'` + `person_id IS NULL`, paired with a 历史 tab on the scope detail page that shows every `revoked / expired / disabled` row with revoke-reason filtering. Also adds the cross-cutting "打开云 Console" button on both admin and `/my` scope detail pages, resolving via `Integration.config_public.console_url` → `platform_settings.cloud.console_url.<provider>` → hardcoded baseline (AWS / Azure / GCP / Aliyun). Migration 131 makes `cmdb_cloud_access_grants.person_id` nullable + adds `external_principal_id (varchar 128)` / `external_principal_email (varchar 255)` so swept rows still attribute even when the leaver's Helix person row is gone, plus a second uniqueness key `(scope_id, role_key(191), external_principal_id)` as belt-and-braces idempotency for re-cleanup. Continuous detection in `sync_grants_for_integration` deliberately NOT wired (the first cut doubled grants_sync's AWS API call volume; supported path is admin-on-demand with `scope_id`). Paired offboarding hook: `LeaverBlockedGuard.flip_to_left` calls `strip_person_from_alert_cc` so future leavers don't accumulate as `未知用户` in alert configs. Two new MCP tools (`scan_cloud_orphan_assignments` + `cleanup_cloud_orphan_assignments`) bring the count to 120.
>
> **Earlier (2026-05-06)**: 9th pre-launch 9-pass audit, PR 1 of dual-PR fix; migration head = 122. (after the 9th pre-launch 9-pass audit, PR 1 of dual-PR fix; migration head = 122. **Highlights**: (a) AI/Bailian billing history can no longer be wiped by hard-deleting `integrations` — migration 122 converts every `cmdb_ai_resources` / `cmdb_ai_resource_cost_daily` / `bailian_workspaces` / `bailian_api_keys` / `bailian_usage_daily` parent FK from CASCADE to `RESTRICT` (and `workspace_pk` to `SET NULL`); operators must soft-disable via `Integration.enabled=False`. (b) AES-GCM credential ciphertext is now bound to the row id via AAD — a ciphertext swap from a low-priv Slack-test row onto a high-priv production AWS row is rejected on decrypt. (c) `cancel_process` on a false-leaver cascade-cancels every still-pending `AssetTransferRequest`. (d) Force-transfer / post-leaver-verify / dispose now ALL touch `borrowed_by` (was only `assigned_to` — leavers permanently retained borrowed equipment past every safety net). (e) The four Feishu approval callbacks (SSO/VPN/AI-tool/announcement) re-validate `employment_status` before flipping APPROVED. (f) `me_universe.assert_can_view` rejects `'left'` viewers + breaks the manager-chain walk on `'left'` managers. (g) Mass-leaver brake (5%/25 threshold) now fires on the manual `confirm_batch` path too — admin auto-confirm on a corrupted preview can no longer wipe through. (h) `require_browser_session` is genuinely fail-closed (was `getattr(... "jwt")` → defaulted to "jwt" when unset). (i) `PATCH /users/{id}` no longer accepts `status="left"` — terminal state goes via the offboarding pipeline only. (j) SF webhook timestamp header is mandatory. (k) Permission revoke no longer optimistically flips status — the cloud-side success path drives the flip. (l) `trigger_sync` / `trigger_sync_all` / `bulk_execute` / `scope_request approve` commit before any external side effect (was `flush()` only — orphaned billable sub-accounts on retry). (m) URL-safety port pinned to 443 by default + SMTP host SSRF check + email-header CR/LF refusal close the remaining credential leak vectors. (n) `audit_logs.actor_user_id` indexed.)
>
> **Earlier (2026-05-06, 8th audit)**: migration head = 120. Highlights: SSRF IPv4-mapped-IPv6 bypass closed; CMDB lifecycle/router added missing leaver-runtime gate; `update_scope_config` rejects decommission re-open; DNS callback `_execute_approved` takes `SELECT … FOR UPDATE` against Feishu redelivery; scope-request `manual_provision` four-eyes guard; Feishu chat_approval webhook fail-closed when no secret configured; Tencent `list_scopes` calls `GetUserAppId` to use real Uin (matching AWS/Aliyun fix); GCP `_remove_member_from_project` per-binding tracking (was silently dropping unrelated empty bindings); Azure `_resolve_user_object_id` UPN-priority disambiguation + `list_scope_grants` raises on Graph 4xx/5xx (mass-revoke safety); asset IDOR through approval closed (`_assert_asset_caller_authorized` + TOCTOU revalidation at auto-execute); approval auto-execute now writes its own `audit_logs` row; `trust_env=False` swept across every software + cloud HTTP driver (Slack, GitHub, Zoom, Jira, Aliyun, Azure, UCloud); AI-spending CNY arithmetic switched to Decimal end-to-end (~$0.50 drift on $5M annualised bills); permission `admin_grant` enforces leaver invariant; failed-auth attempts now produce audit rows. Migration 120 adds `cmdb_iam_users.sub_account_id` so AlicloudIntl cross-account RAM users with the same name don't collapse into one row.)
>
> **Earlier (2026-05-05)**: MCP launch-readiness 9-pass audit — third pass on the MCP surface, focused on cross-cutting defenses: per-PAT + per-IP Redis rate limit, 1 MiB body-size cap, `mcp.auth_failure` audit trail, contextvar `try/finally` reset, `serialize()` deny-list aligned to a superset of `_SENSITIVE_KEYS` + real ORM columns `token_hash` / `secrets_encrypted` / `secrets_nonce`, outbound C0 / RTL-override sanitization on every string leaf to defang cross-tool prompt-injection, header-style audit-key normalisation `X-Api-Key` → match, read-only audit `try/except` so audit-DB blip can't 500 a successful read, Aliyun / Tencent / GCP credential-pattern redaction; per-tool: `get_cloud_server` + `get_cloud_domain` per-row owner-or-subordinate scoping (matches HTTP), `update_my_software_catalog_entry` self-lockout fix to check post-patch state, `revoke_my_cloud_grant` parity with HTTP (`reason="user_released"` + `suppress_driver_error=True`), `admin_change_seat_plan` per-call trace_id, `cancel_my_seat_next_month` + `undo_my_seat_cancellation` `SELECT FOR UPDATE`, `submit_approval_request` rejects `template_key="software_seat"` and bounds reason/notes/answers, `request_email_group` caps `additional_owner_ids`, `get_audit_trace` UUID-validates trace_id, `list_my_software_seats` handles pending `allocated_at=NULL`. Earlier: seat-acquisition + billing pre-launch hardening — 7th 9-pass audit + 3 loop-until-clean re-audit rounds; migration 119 adds VIRTUAL-column + UNIQUE INDEX on `software_seat_allocations(plan_id, user_id)` for active+pending rows AND on `software_monthly_bill_history(period, supersedes_seq, original_bill_id)` AND rewrites `software_license_plans.software_id` FK from CASCADE → RESTRICT.
> **Bootstrap docs**: [CLAUDE.md](../../CLAUDE.md) → [ROADMAP.md](../../ROADMAP.md) → this file → [DECISIONS.md](DECISIONS.md)

---

## Architecture at a glance

Three tiers — **people / Helix / external systems**. Solid arrows are user-driven (web request, MCP call); dotted arrows are system-driven (SSO callback, HRIS sync, Feishu webhook, provisioning, cloud sync). Audit captures every write.

The same diagram is rendered in-app at `/help` (Quick start tab) with i18n labels — keep these in lockstep when the topology changes. The `web/src/pages/help/architectureSource.ts` builder is the runtime source for the in-app version.

```mermaid
flowchart TB
    subgraph users["People"]
      direction LR
      EMP["Employee"]
      MGR["Manager"]
      ADM["Admin (IT/Asset/Cloud)"]
      AUD["Auditor"]
      AI["AI assistant (MCP)"]
    end

    subgraph helix["Helix platform"]
      direction TB
      FE["Web (React)"]
      MCP["MCP server"]
      API["Backend API (FastAPI)"]
      WK["Async worker · scheduled jobs"]
      DB[("TiDB · Redis")]
      AUDITLOG[/"Audit log · append-only"/]

      FE --> API
      MCP --> API
      API <--> DB
      API <--> WK
      WK <--> DB
      API --> AUDITLOG
      WK --> AUDITLOG
    end

    subgraph external["External systems"]
      direction LR
      IDENT["Identity (Google · HRIS)"]
      APPR["Approval (Feishu)"]
      SAAS["SaaS (Slack/Zoom/Jira/Atlassian/Google Workspace)"]
      CLOUD["Cloud (AWS/Aliyun/Tencent/Cloudflare)"]
    end

    EMP --> FE
    MGR --> FE
    ADM --> FE
    AUD --> FE
    AI --> MCP

    IDENT <-.-> API
    APPR <-.-> API
    SAAS <-.-> WK
    CLOUD <-.-> WK

    classDef userNode fill:#eef2ff,stroke:#6366F1,color:#1e1b4b
    classDef helixNode fill:#ecfdf5,stroke:#10b981,color:#064e3b
    classDef extNode fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef storeNode fill:#fff7ed,stroke:#ea580c,color:#7c2d12
    classDef auditNode fill:#fce7f3,stroke:#db2777,color:#831843

    class EMP,MGR,ADM,AUD,AI userNode
    class FE,MCP,API,WK helixNode
    class IDENT,APPR,SAAS,CLOUD extNode
    class DB storeNode
    class AUDITLOG auditNode
```

---

## Table of contents

1. [Stack & deployment shape](#1-stack--deployment-shape)
2. [Identity, auth, RBAC](#2-identity-auth-rbac)
3. [API envelope, middleware, tracing](#3-api-envelope-middleware-tracing)
4. [Integration framework + driver registry](#4-integration-framework--driver-registry)
5. [Scheduled jobs framework + worker](#5-scheduled-jobs-framework--worker)
6. [Notification outbox](#6-notification-outbox)
7. [Workflow journal](#7-workflow-journal)
8. [Audit & compliance](#8-audit--compliance)
9. [Approval (Feishu chat-approval)](#9-approval-feishu-chat-approval)
10. [Provisioning framework](#10-provisioning-framework)
10.5. [Identity Sync Center (outbound employee-data hub)](#105-identity-sync-center-outbound-employee-data-hub)
10.6. [Identity Consumers (outbound PULL surface — Directory API / SCIM credentials)](#106-identity-consumers-outbound-pull-surface)
11. [HR lifecycle automation](#11-hr-lifecycle-automation)
12. [Domain map (per domain: tables, routes, invariants)](#12-domain-map)
13. [Cloud lifecycle (CMDB)](#13-cloud-lifecycle-cmdb)
14. [Frontend architecture](#14-frontend-architecture)
15. [MCP server](#15-mcp-server)
16. [Migrations index](#16-migrations-index)
17. [Cross-domain wiring & non-obvious behaviors](#17-cross-domain-wiring--non-obvious-behaviors)
18. [Glossary of "already exists, don't ask"](#18-glossary-of-already-exists-dont-ask)

---

## 1. Stack & deployment shape

| Layer | Choice |
|------|--------|
| **Backend** | Python 3.12 · FastAPI 0.115+ · SQLAlchemy 2.0 async (asyncmy for runtime, pymysql for Alembic) · uv + pyproject.toml |
| **Worker** | Separate `app/worker.py` process running APScheduler AsyncIOScheduler |
| **DB** | MySQL 8.0 local Docker · TiDB Cloud Dedicated (ap-southeast-1, MySQL-compatible, SSL, PrivateLink) production. **Single cluster** — no second DB, broker, or state store. |
| **Cache / Redis** | Redis 7 (local Docker · managed cloud Redis prod) — used for HRIS auth-token cache, idempotency keys, dashboard hot reads |
| **Frontend** | React 19 · TypeScript 5.9 · Vite 7 · Ant Design 6 desktop · antd-mobile 5 mobile · Zustand · Axios · @react-oauth/google · react-i18next · pnpm |
| **Auth** | Google SSO id_token → backend verify → JWT access (memory) + refresh (httpOnly cookie). Personal Access Tokens (PAT) for MCP / CI / scripts. Local-dev only: bcrypt password login behind `ALLOW_PASSWORD_LOGIN`. |
| **HRIS** | SuccessFactors OData; multi-instance support via `hris_instance_id` on `identity_persons` (data-residency split, e.g. China SF + EU SF). |
| **Lint / Format / Type** | ruff · mypy (standard). **ruff is a `go-ci` gate for `server` since 2026-07-30** (job `Backend Lint`, folded into the required `Backend Migration & Test` aggregate) — before that the only `ruff` invocation in the prior workflow was the `cli` job's, and `server/` had never been linted in CI at all. Ruleset + the documented structural exemptions (E402 for cycle-breaking mid-module imports, N8xx for vendor-SDK-mirroring names) live in [`server/pyproject.toml`](../../server/pyproject.toml) `[tool.ruff]`; `alembic/versions` is excluded because applied migrations are immutable history. **mypy is gated too since 2026-07-30, as a RATCHET**: standard mode reports ~1650 pre-existing errors, so [`server/scripts/check_mypy_baseline.py`](../../server/scripts/check_mypy_baseline.py) compares against [`mypy-baseline.json`](../../server/mypy-baseline.json) per `(file, error-code)` and fails only on a NEW or GROWN pair (plus a total-never-rises assertion). Deliberately asymmetric with the i18n ratchet: a shrink is reported as `IMPROVED` and still PASSES, because a refactor that incidentally fixes types must not fail its author's CI. Keyed without line numbers so the baseline stays mergeable across concurrent branches. Refresh: `python scripts/check_mypy_baseline.py --update`. |
| **Tests** | pytest + pytest-asyncio + httpx AsyncClient + real MySQL fixture (no mocks) and frontend Vitest remain available to developers. They are deliberately not a GitHub Actions merge/main gate and do not prove Staging. `pnpm lint` retains eslint and i18n guards for local/targeted use; Staging proof needs a separately authorized, deployed-artifact-bound profile. |
| **CI** | GitHub Actions. DeepSeek Review is the PR admission gate; after exact-SHA squash merge, source-owned `go-ci.yml`, Terraform and CodeQL run on `push` to `main`, never on a PR. `go-ci` has no path filter or workflow-level cancellation, publication, deployment, OIDC or merge authority. It retains only migration/database/type/lint and Actions trust-boundary checks, dependency security audit, one production frontend Docker image build, and a terminal exact-SHA receipt. Historical DeepSeek shadow binds the `/pulls/{number}/files` sequence through `files_sha256`, then reads candidate-size metadata through GraphQL `Blob.byteSize` before any bounded inert blob body read; oversized source remains patch-only, and neither raw blob nor PR patch-media endpoints are size/content prerequisites. A safe unmaterialized candidate prefix yields no source and no evidence, while malformed/unsafe paths still fail closed. The runtime App has no Administration permission. |
| **Prod deploy** | AWS EC2 + Docker Compose (backend, worker, frontend nginx). EKS not yet authored. Boot-time `.env` is rendered from AWS SSM Parameter Store by [`deploy/scripts/render-env.sh`](../../deploy/scripts/render-env.sh) — see §1.1 below for the fail-loud contract. |
| **Secrets** | `CREDENTIAL_ENCRYPTION_KEY` (base64url-encoded 32-byte AES-256 key) encrypts every integration's secrets at-rest. Decrypt failure → `AppException(503, ENCRYPTION_KEY_MISMATCH)`. |
| **Logging** | stdout JSON; cloud log collector in EC2 prod. No self-hosted ELK. |

> **Post-merge CI topology — v17, superseding v16.3 (2026-08-13).**
> `go-ci.yml` is validation-only and has exactly three visible checks:
> `backend-validate`, `frontend`, and fail-closed terminal `go-ci / Main`.
> Each checks out the immutable event SHA without persisted credentials. The
> workflow has no PR trigger, path filter, workflow cancellation, OIDC, image
> publication, deployment or merge authority. It runs no generic backend pytest,
> Vitest, CLI suite, standalone frontend lint/typecheck/build or scheduled broad
> regression. Deployment-bound Staging verification remains a separate future
> workflow and must not be inferred from a green `go-ci` receipt.

[`server/app/main.py`](../../server/app/main.py) currently has 56 `include_router(...)` mounts:

```
auth, auth_tokens, audit, asset, asset_inventory, cmdb, cloud_lifecycle, dashboard,
dashboard_insights, hr, apikey, bailian, ai_spending, email_group, email_alias_me,
email_alias_users, provisioning, integrations, platform_settings, offboarding,
transfer, permission, approval, scheduled_jobs, compliance, notifications,
hr_lifecycle, leaver_blocked, relationships, me_universe, auditor, identity sync,
feed, admin, employee, cloud, network, software, workflow, and domain-specific
self-service/admin routers
```

Plus the MCP app mounted at `/api/v1/mcp` and `/api/v1/health` + `/api/v1/ready` probes.

### 1.1 SSM-to-.env render contract ([`deploy/scripts/render-env.sh`](../../deploy/scripts/render-env.sh))

Production secrets live in AWS SSM Parameter Store under `/helix/prod/<NAME>` (encrypted with the deploy IAM role's KMS key). `render-env.sh` runs on every EC2 deploy before `docker compose up`, fetching each parameter via `aws ssm get-parameter --with-decryption` and writing them as `KEY=value` lines to `/opt/helix/env/.env.production` (chmod 600). Docker Compose then `env_file:`s into the backend / worker containers.

**Fail-loud contract (NON-NEGOTIABLE, 2026-05-19)**: every SSM API failure that is NOT `ParameterNotFoundException` MUST be retried (3× exponential backoff: 1s / 2s / 4s, ≤15s wall-clock) and, on persistent failure, the script MUST exit non-zero BEFORE writing the `.env` file. Reason: 2026-05-19 prod deploy failed because the previous `aws ssm get-parameter ... 2>/dev/null || echo ""` pattern swallowed a `ThrottlingException` and wrote `CORS_ORIGINS=` (empty string) — backend then died inside `pydantic_settings` parsing empty-string as `list[str]` JSON, killing migration mid-deploy. A single empty variable can take down the whole stack; that error class must never be silent.

Three outcomes from the `get_param` helper:

1. **Success** → echoes the value (may legitimately be empty if SSM stores `""`).
2. **`ParameterNotFoundException`** → echoes empty string, exit 0 — the legitimate "soft-optional key not set in SSM" case (e.g. `AUTH_PASSWORD_LOGIN_ENABLED` unset → falls back to `false` in the rendered file's `${VAR:-default}`). NOT retried. Caller decides whether the empty is acceptable via the post-fetch `MISSING[]` validator.
3. **Any other failure** (throttle, network, IAM, decryption) → retries with backoff; if every attempt fails, exits 1 with the real AWS CLI stderr in the deploy log. No empty value ever leaks to the `.env`.

The post-fetch `MISSING[]` validator enforces six critical keys (`DATABASE_URL`, `DATABASE_URL_SYNC`, `REDIS_URL`, `JWT_SECRET_KEY`, `GOOGLE_CLIENT_ID`, `CREDENTIAL_ENCRYPTION_KEY`) — empty for these → deploy abort with "Please set them in SSM" hint. Soft-optional keys (`APP_ENV`, `AUTH_PASSWORD_LOGIN_ENABLED`, `CORS_ORIGINS`, `SUCCESSFACTORS_BASE_URL`, every approval-code) can come back empty without aborting.

Tests live at [`server/tests/test_deploy/test_render_env_helper.py`](../../server/tests/test_deploy/test_render_env_helper.py) — they plant a fake `aws` binary on `PATH` and pin all four outcomes (happy path, ParameterNotFound for soft-optional, ParameterNotFound for critical, ThrottlingException + AccessDeniedException → retry-then-abort with verified retry count). `RENDER_ENV_RETRY_BASE_DELAY=0` collapses backoff for the test suite; `ENV_FILE=/tmp/...` redirects the render target. Test file lives under `core-auth` shard in CI.

### 1.2 On-host deploy pipeline ([`deploy/scripts/deploy.sh`](../../deploy/scripts/deploy.sh))

`image-release.yml` ships images to ECR, fires an async `prepull` SSM warm-up onto the EC2 host, then the `deploy` job sends `deploy.sh <tag>` via SSM Run Command. `deploy.sh` runs 5 steps: pull-images → render-env → run-migrations → `docker compose up -d --force-recreate` + restart nginx → healthcheck (rollback on failure). Each step is wrapped in a `step` timestamp print (wall-clock + cumulative + per-step delta) — **necessary because SSM `get-command-invocation` buffers all stdout and the GHA job only sees it once the script ends; without the timestamps the multi-minute on-host runtime is an opaque blob.**

> **2026-08-13 boundary correction.** `image-release.yml` remains reusable for
> separately authorized image publication/deploy callers, but post-merge
> `go-ci` no longer calls it. CI only validates an immutable main SHA; it does
> not publish, deploy, merge or obtain cloud credentials as a side effect.

**Image-prune ordering invariant (NON-NEGOTIABLE, 2026-06-30)**: `docker image prune -af` MUST run AFTER `docker compose up` (it lives in `deploy.sh`'s healthcheck-success branch), never inside `pull-images.sh` before the pull. `-af` deletes every image not attached to a *running* container — run before `compose up` it deleted both the layers the `prepull` job had just warmed AND the about-to-be-deployed image, forcing a cold pull on every deploy and defeating the prepull entirely. `pull-images.sh` may only prune **dangling** layers (`--filter dangling=true`, safe anytime). The aggressive `-af` reclaim of old `sha-…` releases is safe only post-`compose up`, where Docker's "in use" guard protects the just-deployed image.

---

## 2. Identity, auth, RBAC

### 2.1 The single person table

`identity_persons` is the authoritative person row — every other domain FKs to it. Key columns:

- `id` (UUID), `work_email` (UNIQUE), `display_name`, `preferred_language`
- `employment_status` ∈ {`active`, `on_leave`, `offboarding`, `left`}
- `hris_person_id`, `hris_instance_id` (FK to integrations for multi-HRIS), `source_system` (default `successfactors`), `source_updated_at`, `last_synced_at`
- `manager_person_id` — self-reference; **drives Manager scope at request time** (no DB role)
- Contact PII: `mobile_phone`, `personal_email` — masked by default, revealed via `POST /users/{id}/reveal-contact` (audit-logged)
- Location: `work_location_code`, `country`, `city`, `company_code`, `department_name`, `job_title`
- SSO state: `email_aliases` (JSON, populated by Google sync), `last_login_at`, `force_logout_at`
- Local-dev only: `hashed_password` (bcrypt)

`AuthUser` is an alias for `IdentityPerson` for back-compat. The legacy `hr_employees` table mirrors a subset for back-compat too — new code reads `identity_persons`.

### 2.2 Role model

Canonical roles (in [`server/app/core/rbac.py`](../../server/app/core/rbac.py)):

| Role | Scope |
|------|------|
| `global_admin` | Full read/write everywhere. Frontend sometimes calls it `it_admin` (legacy alias normalized server-side). Its cloud grants are perpetual (`expires_at=NULL`) and are excluded from expiry reminders/recycling, matching `cloud_admin` (policy clarified 2026-08-02; migration 440 repairs existing rows). |
| `employee` | Self scope. Read-only on `cmdb_servers` / `cmdb_domains` (`can_view_cloud_resources`). |
| `auditor` | Read on audit + security; **mutates** for review decisions, finding acknowledgments. Legacy alias `security_auditor` accepted. |
| `asset_admin` | Asset domain admin — **region-scoped writes**, where a region is a managed **location** (migration 535, 2026-08-24; was office-scoped from migration 220). Auto-derived from `asset_location_admins.person_id` — manual grant / remove is rejected by the API (`ROLE_AUTO_DERIVED`) and disabled in `/admin/rbac`; the daily cron `asset.asset_admin_role_reconcile` reconciles drift. Reads all assets (`can_read_all_assets` unchanged); writes go through the shared gates in `app/domains/asset/region_gates.py` (`enforce_asset_region` / `assert_target_person_in_region` / `assert_target_office_in_region` / `enforce_intake_region` / `enforce_asset_region_bulk`), which the MCP tools import rather than re-implement. Resolution: `asset.location` through `ManagedLocationResolver` → assignee's `work_location_name`/`city` through the same resolver. Unresolvable ⇒ global tier only. Excel bulk-update / bulk-intake routed to `require_admin` (global only) — Excel-row → region mapping can't be enforced pre-parse. **Cannot** edit the location master or any location's administrator list: that is `require_global_asset_admin()` (`global_admin` + `asset_global_admin`), since granting yourself a region would otherwise be one click. Cannot manage cloud / HR / finance. |
| `cloud_admin` | **Cloud + network domain write authority** (promoted 2026-05-20). `can_manage_cloud` now admits cloud_admin alongside global_admin — holders mutate cloud servers / LBs / OSS / IAM users / scopes / DNS / CDN / Bailian. Boundaries that still gate to `global_admin` only: `can_manage_cloud_scope_lifecycle` (irreversible scope decommission / cloud-sub-account close), connector credential edits at `/admin/integrations` (route-gated), and every non-cloud domain (HR / asset / finance / approval). Cloud grants are perpetual (`expires_at = NULL`) through the shared `load_perpetual_person_ids` predicate; since 2026-08-02 that same permanence also covers `global_admin`. Was deleted in migration 040, revived in migration 080. |
| `hr` | Read-only directory + HRIS; can run aliases for others; can co-sign Leaver Blocked overrides. |
| `finance` | Read on billing-adjacent screens (procurement / suppliers / seat allocations / business payments / AI spending). No mutations. |
| `management` | **Read-only leadership view** (added 2026-05-02, migration 099). Same read scope as `global_admin` — sees every admin surface (assets / HR / cloud / audit / finance / security / insights). Excluded from every `can_manage_*` helper (100% read-only at backend). Also excluded from the 7 admin-broadcast notification fan-outs hard-coded as `("global_admin","it_admin")` in scope-request, lifecycle-service, provisioning, offboarding, hr-digest, and leaver-blocked SLA — leadership doesn't get paged by routine ops. |
| `github_admin` | **GitHub 组织域读写** (added 2026-08-29, migration 567). The engineering-side org owners themselves (研发侧), not IT: after Helix takes over execution, "who should be in which org", "which departments belong there by default" and "is this leaver still a legitimate community maintainer" are still only answerable by an org owner — leaving the configuration with `global_admin` turns the automation into a different manual queue. Passes `can_view_github_org` + `can_manage_github_org`; grants **nothing** outside the GitHub domain and joins no `global_admin` alert fan-out. Same shape as `security_officer`. Migration seeds the role and grants it to **nobody** — who holds it is a decision for `/admin/rbac`, not for a migration. |
| **Manager** | Derived at request time from `manager_person_id`. Sees subordinates' assets / seats / permissions. **No DB role** — service layer checks `current_user.subordinates`. |

Role canonicalization: `LEGACY_ROLE_ALIASES = {"it_admin": "global_admin", "security_auditor": "auditor"}`.

Cloud scope owner is a **per-resource derivative** — `cmdb_cloud_scopes.owner_person_id`. The async helper `is_cloud_scope_owner_of(session, person_id, scope_id)` is the per-scope gate; `person_owns_any_cloud_scope` powers the "show owner console entry" UX flag.

### 2.2.1 Business Unit (BU) — top-level org node

There is no dedicated `business_unit` column on Person. A BU is the **first segment of `identity_org_memberships.org_path`** (the slash-delimited department chain) after stripping the tenant root (`PingCAP` / `PingCAP-ATI`). For the example path `PingCAP / Engineering Group / TiDB / Cloud SaaS`, the BU is `Engineering Group`.

Centralised in [`server/app/identity/bu_helpers.py`](../../server/app/identity/bu_helpers.py) (added 2026-05-03):

- `list_bus(db)` — returns `[{name, member_count}]`. Two strategies unioned: (1) `IdentityOrgNode WHERE parent_org_node_source_id IS NULL AND node_status='active' AND org_node_type='department'` (canonical seed); (2) distinct top segments of active primary memberships (covers tenants whose org-node sync lags). Member count counts active (non-`left`) persons.
- `resolve_bu_user_ids(db, bu_name)` — set of person ids whose primary BU matches. Empty set on unknown BU.
- `bu_for_user_ids(db, user_ids)` — `{user_id: bu_name}` map, used by analytics rollups so we don't issue one query per user.
- `bu_from_org_path(path, fallback)` — pure function for ad-hoc derivation.

Endpoint: `GET /api/v1/auth/business-units` (any logged-in user). Consumed by the BU dropdown on `/admin/assets/seat-allocations`, `/admin/ai-spending`, and the matching MCP tool `list_business_units`. **2026-05-04 hardening**: `member_count` is **redacted** for callers without `it_admin` / `security_auditor` (else every employee can dump headcount per BU). Frontend type is `BusinessUnitInfo.member_count?: number` — render conditionally.

**Filter semantics** (matches `_compute_cost_stats(bu=...)` in [`server/app/domains/asset/router.py`](../../server/app/domains/asset/router.py) and `_filter_rows_by_bu` in [`server/app/domains/ai_spending/service.py`](../../server/app/domains/ai_spending/service.py)):

- BU filter applies to the admin "all-company" views only — employees and managers don't cross BU per the data invariant.
- Empty / whitespace-only `bu` is treated as no filter.
- Unknown BU returns the canonical empty payload (NOT 404) — keeps the UI dropdown stable across syncs.
- Rows without a `user_id` (cloud dedicated-scope full bill, keyword-match) are dropped when a BU filter is active — they cannot be honestly attributed.

### 2.3 Auth flow

1. **Google SSO**: frontend `@react-oauth/google` → `id_token` → `POST /api/v1/auth/google/verify` → backend verifies via Google OAuth endpoint → match/upsert `identity_external_identities` (provider=google) → issue JWT access token + httpOnly refresh cookie.

**Identity resolution (Google + Feishu, hardened 2026-05-29 after prod incident)** — both `AuthService.login_via_google` and `login_via_feishu` now follow the same shape:

1. **Subject-first**: try `(provider, provider_subject)` against `identity_external_identities` via [`find_user_by_external_subject`](../../server/app/auth/service.py). For Feishu the lookup tries `[union_id, open_id]` **sequentially** (not via `IN(...)+LIMIT 1`) so the union_id-preferred contract holds even when historic rows for two persons happen to collide. The lookup filters `deleted_at IS NULL`, `identity_status='active'`, `is_login_identity=True`, and the Person's own `deleted_at IS NULL` — `is_login_identity=False` is therefore an actual admin kill-switch (not cosmetic).
2. **Email fallback** (first-time binding only): case-insensitive `identity_persons.work_email` match. For Feishu, tries `enterprise_email` first, then `email` — with an **anti-takeover guard**: when `enterprise_email` is present but doesn't resolve, the `email` field is only accepted if its local-part matches `enterprise_email`'s local-part (defeats `enterprise=a@x` + self-edited `email=b@victim` impersonation). `_local_parts_match` also requires `@` in both sides and a non-empty local-part.
3. **Departed gate**: `is_effectively_departed(user)` applied in both subject-first and email paths.
4. **ext_identity upsert**: `_pick_provider_ext` helper picks the live row preferentially (over a tombstone) so a soft-deleted ext_identity doesn't shadow a live one in `next(...)` iteration order. Tombstone revival respects `is_login_identity=False` (admin's double kill-switch survives). Subject swap on existing rows uses `_safe_swap_subject` (pre-check for `(provider, provider_subject)` collision → clean `ValueError` → 401, not `IntegrityError` → 500). Subject "downgrade" is prevented by `_should_replace_subject`: stored union_id-shaped (`on_…`) values are never overwritten by transient `open_id` (`ou_…`) values just because the current login lost the union_id scope. `provider_email` is refreshed for forensic snapshot. `is_login_identity` is NOT re-affirmed on already-live rows (preserves admin disables). Tombstone is cleared only AFTER subject swap pre-check passes — keeps SQLAlchemy session pristine on rejection.

**Information disclosure guard**: all 401-class `LOGIN_FAILED` responses from `login_via_*` paths return a constant opaque message (`_LOGIN_FAILED_OPAQUE` in service.py); router-side `_audit_login_failure` writes the detailed reason into `audit_logs.detail.reason`. SOC visibility preserved, account-state enumeration via response-body diff blocked.

**Why subject-first**: 2026-05-29 prod incident — Feishu tenant admin rotated `enterprise_email` for ~all employees from `@pingcap.com` → `@pingcap.com.cn`. Helix `work_email` (HRIS-sourced) stayed `.com`, so the previous email-only matcher missed every already-bound user. Subject (Feishu `union_id` / Google `sub`) is the durable anchor — it survives every provider-side field rename.

2. **Feishu SSO** (added 2026-05-19): OAuth 2.0 authorization-code flow with full-page redirect. `POST /api/v1/auth/feishu/authorize-url` issues a 256-bit `state` (Redis 10-min TTL, bound to redirect_uri) and returns the `accounts.feishu.cn/open-apis/authen/v1/authorize` URL. After Feishu redirects browser back to `/auth/feishu/callback?code=&state=`, frontend `POST /api/v1/auth/feishu/verify` consumes the state (atomic Redis GETDEL — single-use, redirect_uri-bound), exchanges code via `open.feishu.cn/open-apis/authen/v2/oauth/token`, fetches `/authen/v1/user_info`, and matches `enterprise_email` (preferred) → `email` (fallback) case-insensitively against `IdentityPerson.work_email`. Upserts `identity_external_identities` (provider=`feishu`, `provider_subject=union_id` — stable across the developer's apps, unlike `open_id`). Same JWT issuance + refresh-cookie path as Google. **Credentials reuse the existing `chat_approval` integration row** (one Feishu自建应用 for approval + notification + SSO; one secret-rotation point). `httpx.AsyncClient(trust_env=False, follow_redirects=False)` mirrors the Google SEC-001 hardening. `redirect_uri` is validated against `settings.CORS_ORIGINS` + the callback path as defence-in-depth on top of Feishu's own redirect whitelist — values to whitelist on Feishu open platform: `https://helix.pingcap.net/auth/feishu/callback` (prod) + `http://localhost:3000/auth/feishu/callback` (local dev). Audit action `auth.feishu_login` (success + failure both written, failure rows use an independent session like the Google path so a request rollback doesn't take them out). Rate-limited under the same per-IP `check_login_rate_limit` window as Google verify (the strict 20/15min brute-force cap — Feishu verify is the same threat class as Google verify since both accept upstream-attestable opaque tokens).
2a. **Feishu H5 silent SSO** (added 2026-05-21): a second Feishu code path used by the in-app webview, separate from the redirect-based flow above. Frontend loads `https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.36.js` lazily inside [`web/src/lib/feishuSdk.ts`](../../web/src/lib/feishuSdk.ts), calls `tt.requestAccess({appID})` to obtain a code without any browser redirect, then `POST /api/v1/auth/feishu/h5-login {code}`. The endpoint reuses [`AuthService.login_via_feishu`](../../server/app/auth/service.py) — only the upstream `_exchange_code(redirect_uri=None)` differs from the redirect flow (the OAuth v2 token request omits `redirect_uri` because there was no authorize redirect). **No `state` and no `redirect_uri` validation**: CSRF is structurally impossible because `tt.requestAccess` only yields a code inside the Feishu app's own webview, and the code is bound to `(app_id, user_id)` on the Feishu side. A small companion endpoint `GET /api/v1/auth/feishu/app-id` returns the configured app_id so the SDK can call `requestAccess` without the frontend hardcoding it (same value the redirect flow already discloses in the authorize URL). Audit action `auth.feishu_h5_login`. Same per-IP `check_login_rate_limit` window. Routed only from `/m/*` paths — see the Mobile (Feishu H5) entry under §"Frontend route map".
3. **Refresh**: `POST /auth/refresh` (cookie carries refresh) → new access token. Frontend Axios interceptor handles 401 → refresh → retry; queues concurrent 401s onto a single in-flight refresh. **Per-IP rate-limit lives in its own Redis bucket (`refresh_ratelimit:<ip>`, `REFRESH_RATE_LIMIT_MAX=200 / 15min`) separate from the login bucket (`login_ratelimit:<ip>`, `LOGIN_RATE_LIMIT_MAX=20 / 15min`)** — added 2026-05-19 after a prod incident where a refresh-traffic burst (multi-tab + page reloads on an expired access token) burned the shared bucket and locked the same IP out of `/auth/google/verify` for the rest of the window. See [`check_refresh_rate_limit`](../../server/app/core/dependencies.py) and [`check_login_rate_limit`](../../server/app/core/dependencies.py). The login bucket still gates `/auth/google/verify` + `/auth/feishu/verify` + `/auth/login` so opaque-token brute-force stays bounded at the tight 20/15min cap. **Rotation grace** (added 2026-06-02; made *atomic* 2026-06-02): the JTI consume IS an atomic Redis Lua script (`_CONSUME_AND_GRACE_SCRIPT` in [`auth/service.py`](../../server/app/auth/service.py)) that, in one serialized step, reads the JTI's owner, deletes the JTI (one-time use), and stamps `refresh:grace:{J1}` → `{sub}` (the opaque user id ONLY — never the signed tokens) with a 15s TTL. Because the marker is written in the SAME atomic step as the consume, the instant any concurrent caller observes the JTI as already-consumed the marker is GUARANTEED present — `refresh_tokens()` reads it exactly once (no poll, no TOCTOU window) and, on a same-`sub` hit, re-mints THIS caller its OWN fresh pair (independent session — never replays the winner's tokens, never shares one JTI) instead of tripping the Round-49 reuse-detect / `force_logout_at` flip. This deterministically absorbs React StrictMode's double-effect (`AuthRoute.useEffect → initialize()` fires twice in dev), multi-tab refresh fan-out, and the apiClient-401-retry racing `initialize()`; only a reuse landing AFTER the 15s TTL (real replay) still flips `force_logout_at` (RFC 6819 / OAuth 2.1 BCP). **Why atomic matters**: the earlier version wrote the marker only AFTER minting the new pair, leaving a poll-band window where a genuinely concurrent loser could read the marker before it existed, fall through to reuse-detect, and flip the *sticky* `force_logout_at` — which then **permanently bricked the account** (login still worked, but every subsequent `/auth/refresh` saw the flag and 401'd → "刷新页面就退出登录" on every reload). a single benign race was an unrecoverable kick-out. The atomic stamp closes that window for good. **Recovery escape hatch** (added 2026-06-08): the atomic grace only covers the *concurrent-rotation* race — it does NOT cover *whole-of-Redis JTI loss* (a Redis restart without persistence, `FLUSHDB`, key eviction, or the documented local `docker compose down -v` reset), where a legitimate `/auth/refresh` finds neither the JTI nor a grace marker and so is indistinguishable from a real replay → reuse-detect flips the sticky `force_logout_at` → the same "刷新页面就退出登录, can't escape even by re-logging-in" brick. Because a successful interactive login (Google / Feishu / password) proves possession of the user's real SSO identity / password — strictly stronger evidence than a replayed refresh cookie — all three login paths now call `_clear_stale_force_logout(user)` right after their `is_effectively_departed` gate (a departed user is rejected *before* the clear runs, so an offboarding shutdown can never be self-cleared), releasing the brick on re-login while leaving RFC 6819 reuse-detect fully intact (an attacker cannot interactively log in to undo it). So `force_logout_at` is now cleared by offboarding-reopen OR a successful interactive login. Frontend has a parallel module-level `initializeInFlight` lock in [`stores/auth.ts`](../../web/src/stores/auth.ts) as in-tab defense-in-depth (the atomic grace is the multi-tab / cross-codepath backstop). **One force-logout flip per reused JTI** (added 2026-08-04, `_claim_reuse_flip` / Redis `refresh:reuse_seen:{jti}` SET-NX, TTL = the token's remaining `exp`): the reuse-detect branch used to re-flip `force_logout_at = now()` on EVERY presentation of a dead JTI, so a script replaying ONE leaked/rotated refresh token in a loop (observed 2026-08-04: `python-requests` hitting `/auth/refresh` every ~12s for a real user from an external IP) kept pushing the cutoff forward — and because the recovery escape hatch clears the flag on login, the victim was re-kicked seconds after every re-login → **a single leaked refresh token = perpetual account-lockout DoS**. Now the flip + `auth.refresh_token_reuse_detected` audit are claimed exactly once per distinct reused JTI (atomic SET-NX elects one winner across concurrent replays; fails TOWARD detection on Redis error), so genuinely-distinct stolen tokens each still trip RFC 6819 reuse-detect once, but hammering one already-flagged token is a no-op that no longer defeats a re-login. The per-attempt `auth.refresh_failure` forensic row (Round-49) is intentionally still written on every hit — it is the alarm that surfaced the incident.
4. **PAT**: `helix_pat_<random>` prefix, SHA-256 hashed, stored in `auth_personal_access_tokens` with `expires_at` (NULL = never), `revoked_at`, `last_used_at/ip/ua`. Same bearer scheme as JWT — request resolver detects prefix and dispatches. **PAT minting** (`POST /auth/me/tokens`) is rate-limited by `check_sensitive_endpoint_rate_limit` (10/hour per user) and is hard-blocked from PAT-authenticated callers via `require_browser_session(request)` (in `app/core/dependencies.py`) — only Google SSO sessions can mint or revoke PATs. The same helper guards a small set of catastrophic-if-stolen admin endpoints: `POST /users/{id}/reveal-contact` (PII reveal of *another* user), `PATCH /users/{id}` + `POST/DELETE /users/{id}/roles*` (role mutation), `POST /api-keys/assignments/{id}/reveal` when revealing someone else's assignment. Default fail-closed (`auth_via != "jwt"` rejects, not `== "pat"`) so a future refactor that forgets to set `request.state.auth_via` doesn't silently bypass the gate. Self-targeted reveals on the same endpoints stay PAT-allowed (Claude Desktop can show the user their own cloud key).
5. **Local-dev password login**: `POST /api/v1/auth/password-login` with `{email, password}`. Enabled only when `ALLOW_PASSWORD_LOGIN=true` in `.env`. **Never used in prod.**

**JWT claim contract** (mint + decode are symmetric):
- Mint paths (`create_access_token`, `create_refresh_token` in `server/app/core/security.py`) write `iss="helix"`, `iat`, `exp`, `sub`, `type ∈ {access, refresh}`, `jti`, plus `roles` + `es` (employment_status) on access tokens only.
- `decode_token` requires `["exp", "iat", "sub", "type", "iss"]` and validates `issuer="helix"` (added 2026-05-04 — minted since 2026-05-02 but never enforced at decode). PyJWT raises `MissingRequiredClaimError` / `InvalidIssuerError` (both subclass `InvalidTokenError`) on violation; caller's `except InvalidTokenError → UnauthorizedError(401)` returns the user to login. Pre-2026-05-02 refresh tokens (no `iss`) will silently force re-login during the 7-day TTL window post-deploy.

### 2.3.1 Identity golden record (Identity-Hub spine, added 2026-06-09)

The **identity golden record** is the per-person 360 view that makes "identity is the source of truth" visible — it joins data that otherwise lives in four subsystems into one navigable record. The reusable aggregation lives in [`server/app/identity/profile_service.py`](../../server/app/identity/profile_service.py) (`IdentityProfileService`); later Identity-Hub PRs (data-quality panel, hub landing page) reuse it rather than rolling parallel read models.

- **Endpoint**: `GET /api/v1/identity/persons/{id}/identity-profile` (`require_hr_read` — global_admin / hr / auditor / management, same tier as `GET /users/{id}`). Optional `?drift=false` to skip the per-target live render. MCP twin `get_person_identity_profile` mirrors the gate via `_require_hr_read`.
- **Five blocks**: (1) `source` — upstream HRIS card (`source_system`, `hris_person_id`, `hris_record_state`, `last_synced_at`); (2) `login_identities` — `identity_external_identities` bindings with `is_login_identity` kill-switch + `identity_status`, subject tail-masked; (3) `projections` — **downstream projection matrix**: one row per `identity_sync_state` the person has, joined to its target, annotated with **drift** (re-renders the live payload via `IdentitySyncService.preview_payload` and compares the hash to `last_payload_hash`; only computed for `success`/`skipped` states with a stored hash; render failure → `None`, never crashes the record). The golden record and the §10.5 mapping preview render through the **same engine**. (4) `provenance` — **signal-derived** attribute source tags (`source_system` + `last_synced_at` + a static `_PROVENANCE_SPEC` of which fields are `hris` / `hr_managed` / `lifecycle` / `sso` / `system` and which are HR-overridable). No per-field history table — derive-from-signals was the deliberate scope call (marginal value of a history migration at 500 people is low). (5) `timeline` — `audit_logs` rows for `resource_type='identity_person'`, newest first, actor names resolved in one batched query.
- **Login kill-switch mutation**: `PATCH /api/v1/identity/persons/{id}/external-identities/{ext_id}` (`require_admin` + `require_browser_session`, audited as `identity.external_identity.update_login`) toggles `is_login_identity` (the real auth-path kill-switch — see §2.3 subject-first resolution) and/or `identity_status` ∈ {active, suspended}.
- **Frontend**: the "身份" tab on `UserDetailPage` ([`web/src/pages/admin/IdentityProfileTab.tsx`](../../web/src/pages/admin/IdentityProfileTab.tsx)). Projection rows link to `/admin/identity-sync` (bidirectional navigation); the kill-switch Switch renders only for `global_admin` (backend enforces regardless).

### 2.3.2 Identity data quality (continuous reconciliation, added 2026-06-09, migration 269)

The data-quality layer is the Identity Hub's continuous-reconciliation surface — what Authing calls "数据比对", made explicit. A daily cron sweeps the identity graph and upserts findings; the admin panel reads them; each finding links to the subject's golden record.

- **Scanner** [`IdentityDataQualityService`](../../server/app/identity/data_quality_service.py) runs eight checks: `orphan_login_identity` (a live, login-enabled `identity_external_identities` row for a `left`/deleted person — they can still SSO in; **high**), `stale_tombstone` (`hris_record_state='purged'` person that still has an **`identity_status='active'`** login binding; **high**), `manager_departed` (active person whose `manager_person_id` points at a `left`/deleted person; medium), `missing_manager` (active person, no manager; low), `sync_drift_failed` (`identity_sync_state.last_status='failed'` on an enabled target; medium), and (2026-07-04, P0051 follow-up) `hris_status_divergence` (Helix `active` but the live SF roster reports `status='f'` — a real leaver still counted active; `leaver_pending` is deliberately excluded: offboarding is already in flight there and its SLA escalation owns stuck cases; cross-instance: statuses are aggregated across ALL instances first and any live `t` suppresses the finding; **high**) + `offboarding_session_not_cut` (a leaver whose `OffboardingProcess.session_enforcement.google_session.status='failed'` — session cutoff failed; **high**) + (2026-09-07, P01574) `hris_email_name_malformed` (HR 的邮箱名 SF `PerPersonal.customString4` 不是邮箱名 —— 显示名 / 个人邮箱 / CJK 文本; **low**, 刻意不 page: Helix 已经能兜住这个值,而修 SF 字段是 HR 的活,不是 IT 的。判据是**往返**而非坏字符黑名单 —— 把存值过一遍 provisioner 自己的派生,问它有没有原样活下来:`minted == ""` ⇒ Helix 完全弃用、HR 的意图被忽略(`detail.usable=false`);`minted != raw` ⇒ 能用但被重新解释过(`'Koji Betsuyaku'` → `koji.betsuyaku`),地址对、SF 字段仍脏。离职人员排除:没人会为清一条发现去改离职档案).
  - **SF-outage safety** for the divergence check: it pulls the live SF roster (`list_all_users`) per enabled HRIS instance, so `_detect_all` now returns an `incomplete_types` set — if an instance's SF pull fails, `hris_status_divergence` is marked incomplete and `run_scan` **skips auto-resolving** it that run, so an SF outage can never silently clear open divergence findings (a false "all-clear"). The divergence check is the daily safety net beside the incremental sync (#1626) and weekly full sync; `offboarding_session_not_cut` is a pure-DB read of the enforcement proof from #1636.
- **Tables** ([`data_quality_models.py`](../../server/app/identity/data_quality_models.py), migration 269): `identity_data_quality_findings` — UNIQUE `(finding_type, dedup_key)` so a re-seen problem bumps `last_seen_at` and keeps the human decision; lifecycle `open → acknowledged → accepted_risk → resolved`; a finding that disappears from a scan is **auto-resolved** (`auto_resolved=true`). `identity_data_quality_runs` — per-scan history with per-type open counts for the panel trend. Both use `id_type_for`/`table_kw_for` for TiDB-FK strictness; person FKs `ON DELETE SET NULL` so a hard purge doesn't erase the problem's audit trail.
- **Cron** `identity.data_quality_scan` (daily 05:00 UTC, [`scheduled_jobs/identity_handlers.py`](../../server/app/scheduled_jobs/identity_handlers.py), seeded by migration 269 with a hyphenated `str(uuid.uuid4())` id; import-mirrored into `main.py` + `worker.py::_run()`). The scan does NOT raise on findings (findings are normal output); it raises only on a genuine scan error. New high-severity findings dedup-push IT admins via `NotificationService` + `list_admin_recipients` (best-effort, day-windowed via `NotificationOutbox` template_key).
- **API** (`/api/v1/identity/data-quality`): `GET /summary` (tiles + 30-run trend) + `GET /findings` (paged, filter by status/type/severity) at `require_admin_read`; `PATCH /findings/{id}` (decide) + `POST /scan` (manual) at `require_admin`. MCP `list_identity_data_quality_findings` (`_require_admin_read`, read-only).
- **Frontend**: `/admin/identity/data-quality` ([`IdentityDataQualityPage`](../../web/src/pages/identity/IdentityDataQualityPage.tsx)) under the 身份源数据与认证 nav group — tiles + per-type breakdown + findings table; each row links to `/admin/users/{person_id}` (the golden record).
- **Accuracy fixes (2026-06-09, migration 270)** — the scanner reads *stored* identity state, so it only reports the truth if the lifecycle keeps that state current. Two gaps made it over-report (prod: 15 of 18 findings were false). Both root-caused and fixed; new reconciliation primitives live in [`identity_reconcile.py`](../../server/app/identity/identity_reconcile.py):
  - **`orphan_login_identity` was stale, not a security hole** — auth's `is_effectively_departed` already blocks `left`/deleted on every request, but nothing flipped the leaver's google/feishu `identity_external_identities` rows after account revoke. `deactivate_login_identities(db, person_id)` is now called as a leaver hygiene sweep in [`leaver_blocked.guard.flip_to_left`](../../server/app/domains/leaver_blocked/guard.py) (best-effort + audited + idempotent; SF provenance row untouched).
  - **`missing_manager` was mostly link-failure noise** — incremental sync's `_assign_manager` clears `manager_person_id` on a miss and nothing backfilled it once the manager onboarded. New persisted column **`identity_persons.manager_external_id`** (the raw HRIS `EmpJob.managerId`, written by both sync paths) + `backfill_manager_links(db)` (run at the tail of both `hr.service.confirm_batch` and `successfactors_sync`) re-link late-arriving managers. The detector now flags only a *genuine, actionable* gap: excludes org-apex (has active reports), seeded SF service rows (`job_title IS NULL`), and HRIS-declared-but-unlinked (left to backfill).
  - **`stale_tombstone` requires `identity_status='active'` (2026-08-18)** — the check used to count ANY `is_login_identity` row as "can still log in", unlike its sibling `orphan_login_identity` which always required `active`. So it fired on precisely the states that exist BECAUSE the login surface is already shut: `pending_welcome` (the provisioner **suspends** a mailbox whose welcome mail carrying the first-login password failed), plus offboarding-`inactive` and admin-`suspended`. Prod: the SF admin account `CBadmin` paged five IT admins as high-severity "purged yet can still log in" while its Google mailbox had been `suspended: ADMIN` since creation with `lastLoginTime` never set — and the `is_effectively_departed` purge gate below already refuses it a session. Two closed doors reported as an open one. A high-severity finding that overstates its risk trains admins to discount the channel, so the two detectors now share one definition of a live login. Regression: [`test_stale_tombstone_active_only_2026_08_18.py`](../../server/tests/test_identity/test_stale_tombstone_active_only_2026_08_18.py) (carries the positive control — an `active` binding on a purged person still fires).
  - **`stale_tombstone` login surface now actually closed** — `is_effectively_departed` gained an `hris_record_state == 'purged'` gate (orthogonal to employment status), so a purged person can no longer authenticate / refresh / pass a protected route — the gap the check only *warned* about. Because purge previously had **no inverse**, `_upsert_person` now un-purges on re-appearance (any person SF returns in a sync → `hris_record_state='present'`, clears `hris_purged_at`/`reason`) so a purged-then-rehired person isn't permanently locked out of login.
  - **`identity_org_nodes` stale-active org units now deactivated (2026-07-01)** — the SF org sync is an *active-only upsert*: `list_active_departments()` filters `status eq 'A'`, so a department deactivated in SF just drops out of the pull, and because the upsert only touches the codes that came back (and hard-codes `node_status='active'`) the orphaned org-node row kept `active` forever. `deactivate_absent_org_nodes(db, active_source_ids)` runs at the tail of **both** SF sync paths (`hr.service._upsert_org_nodes_from_departments` + `successfactors_sync._upsert_org_nodes`) and flips every currently-active `successfactors` department node **absent from the run's full active set** to `node_status='inactive'` (row kept, `deleted_at` untouched → round-trips back to `active` if SF re-activates it). No-ops on an empty set + only runs when the upstream pull was non-empty, so a transient fetch failure can never tombstone the whole tree. This removes dead orgs from BU-root detection (§2.2.1) and the Feishu outbound projection, and stops a person mis-assigned to a dead org from syncing it as live.

### 2.3.3 Identity Hub landing cockpit (added 2026-06-09)

`/admin/identity` was a never-routed "Coming soon" shell; it is now the navigation root of the 身份源数据与认证 group ([`IdentityPage`](../../web/src/pages/admin/IdentityPage.tsx), routed + a 「总览」 nav child at the top of the group). [`IdentityHubService`](../../server/app/identity/hub_service.py) (`GET /identity/hub/overview`, `require_hr_read`; MCP `get_identity_hub_overview`) is a **pure composition** over existing models + `IdentityDataQualityService.summary` — no new read model — returning: headcount by lifecycle state, login bindings by provider (+ login-disabled / suspended counts), every sync target's health (enabled / last-run / next-run), the last 10 sync runs, and the data-quality posture. The four cockpit tiles deep-link to `/admin/users`, `/admin/identity-sync`, and `/admin/identity/data-quality` so the hub is the entry point into the spine, not a dead end. Together PR1–4 form the Identity Hub: inbound HRIS → golden record (2.3.1) → outbound projection + config builder (§10.5) → data-quality reconciliation (2.3.2) → this cockpit, navigable both directions via person ↔ target ↔ run ↔ finding.

### 2.3.4 Identity field dictionary (read-only 字段管理, added 2026-06-09)

Authing-style 字段管理 at `/admin/identity/fields` — a directly-viewable inventory of every user + department attribute (key / display name / type / source / console-detail visibility / self-service visibility / user-editability). [`field_catalog.py`](../../server/app/identity/field_catalog.py) `build_field_catalog()` (`GET /identity/field-catalog`, `require_hr_read`; MCP `get_identity_field_catalog`) **iterates the real `IdentityPerson` / `IdentityOrgNode` table columns** so it can never drift from the schema, enriches from a curated `_USER_FIELD_META`/`_DEPT_FIELD_META` (source ∈ hris/hr_managed/lifecycle/sso/system + visibility + i18n label key), and derives `user_editable` **live from `MyProfilePatchRequest.model_fields`** (the exact self-service `PATCH /me/profile` body — today just `preferred_language`). `hashed_password` + `deleted_at` excluded. **Deliberately read-only** (DECISION 2026-06-09): for a stable 500-person single-org, configurable per-field visibility/editability would either be fake toggles or a full render-layer rewrite — the documentation view is the honest, useful scope. To add a field to the dictionary you add the column (it auto-appears) + an i18n label key.

### 2.4 RBAC functions (call these, don't roll your own)

From [`server/app/core/rbac.py`](../../server/app/core/rbac.py):

```
can_manage_assets, can_read_all_assets,
can_access_audit,
can_manage_cloud, can_manage_cloud_scope_lifecycle, can_read_cloud, can_view_cloud_resources,
is_cloud_admin (marker check),
can_view_finance, can_manage_finance,
can_view_ai_spending, can_manage_ai_spending,
can_view_hr_data, can_view_directory,
can_view_security, can_manage_security,
can_view_github_org, can_manage_github_org,
can_manage_leaver_blocked, can_secondary_sign_leaver_override,
can_view_relationship_graph,
can_alias_for_others, can_delete_any_mail_group,
is_active_employee (runtime gate — Leaver Blocked half),
is_cloud_scope_owner_of, person_owns_any_cloud_scope (async)
```

**`can_view_directory` vs `can_view_hr_data`** (split 2026-06-01): the lightweight people picker (`/api/v1/directory/people`) and department list (`/api/v1/auth/departments`) are gated on `can_view_directory` — admitting every admin-tier role (`global_admin + hr + asset_admin + finance + cloud_admin + auditor + management`). The full HR roster `/api/v1/users` (which carries `roles[] + last_login_at + hris_record_state + manager hierarchy`) stays on `can_view_hr_data` (HR + auditor + management + global_admin). Without this split, every admin view's `UserSelect` / `MultiUserSelect` filter dropdown silently 403'd for non-HR admins — the frontend swallowed the error and regional asset admins saw permanently empty pickers ("看不到资产信息"). Background: PR 2026-06-01. **`GET /auth/departments/tree`** (added 2026-06-03) shares this `can_view_directory` gate — the hierarchical feed for the shared `DepartmentTreeSelect` (see the CONVENTION callout at the top of this file). `/auth/departments` (flat, name+count) is retained for any non-picker consumer.

**Self-service lite picker** (added 2026-06-02): `/api/v1/directory/people/lite` — any-authenticated-employee variant of `/directory/people`, search-mandatory (empty `q` → empty list, no full-roster scrape), capped at 30 rows, no pagination, no role filter, response omits `employee_no`. Anchors every self-service modal mounted on `/me/...` / `/my/...` routes where the caller may not hold an admin-tier role: cloud DNS / server owner transfer, API-key transfer, asset-inventory respondent picker, SSO system owner contact. The frontend `UserSelect` component takes a `selfService` prop that switches to this endpoint — without it the admin-tier `/directory/people` 403s and the picker silently renders "暂无数据". Disclosure tier matches `/feed/mention-search` (every employee already sees colleagues' name / email / department via Feishu + the celebration feed). Rate-limited via `check_directory_read_rate_limit`, same bucket as the admin variant.

`is_active_employee(user)` is the **runtime half** of Leaver Blocked: `employment_status=='active' AND deleted_at IS NULL`. Not active → can read but cannot mutate.

**Offboarding carve-outs from the mutate lock** — two allowlists in `dependencies.py`, plus per-flow decisions in the owning service:
- `_LEAVER_MUTATE_ALLOWLIST` (prefix match): `auth/refresh`, `auth/logout`, `auth/me/profile`, `notifications/inbox/mark-read`.
- `_LEAVER_MUTATE_EXACT_ALLOWLIST` (**exact** match, added 2026-08-13): `POST /approval/asset/requests` + `POST /approval/asset/uploads`, so a 离职中 employee can still submit 归还 (`asset_return`) and 回购 (`offboarding_buyback`) — offboarding obligations, not new privileges. Exact rather than prefix on purpose: the `…/requests/on-behalf` sub-path is role-gated on `can_manage_assets`, which a leaver_pending asset_admin still satisfies, so prefix-matching the parent would hand a departing admin the `requester_id` rewrite. **The per-template decision is NOT in the router** — it lives in `ApprovalService.create_asset_approval_request` (`LEAVER_SELF_SERVICE_TEMPLATE_KEYS`), which still 403s `laptop_replacement` / `computer_borrow` / software flows / any on-behalf rewrite. That placement is what keeps the MCP twin (`submit_approval_request`, `leaver_allowed=True`) from drifting: both surfaces call the same service. `/asset/uploads` carries no template_key so it can't be gated per-flow — it is exempt wholesale because it is inert (stages MIME/size-validated bytes to Feishu, returns a token that is worthless without a gated submit), and the return form's 恢复出厂设置证明 upload is required. Regression: `tests/test_approval/test_leaver_self_service_flows_2026_08_13.py`.
- Post-Feishu-freeze the employee can't be a Feishu applicant at all; that's what the admin `…/requests/on-behalf` 代提回购 path is for.

**Adding a new role**: insert via Alembic migration (see 080 / 099 as templates), add the constant to `CANONICAL_ROLE_NAMES` + `ROLE_EQUIVALENTS`, then thread it through whichever read / write helpers it should belong to. The frontend mirrors are **five** files, not three (counted the hard way while adding `github_admin` on 2026-08-29 — `tsc` found the last two): `web/src/lib/rbac.ts` (`AppRole` type + per-helper hasAnyRole sets), `web/src/stores/auth.ts` (`ViewRole` enum — `deriveDefaultViewRole` is intentionally a **one-line predicate** that defaults everyone except `global_admin` to `employee`; do not add branches), `web/src/layouts/MainLayout.tsx` (role switcher chip + `is<Name>View` boolean + admin-menu inclusion + the `menuByViewRole` map, which `satisfies Record<ViewRole, unknown[]>` and therefore fails the build until the new perspective has a menu), `web/src/lib/roleLanding.ts` (`VIEW_ROLE_PRIORITY` + `HOME_BY_VIEW_ROLE` — and its test `roleLanding.test.ts` demands the landing target be listed explicitly, so a perspective cannot silently default onto a page it has no route for), and `web/src/components/onboardingTour.helpers.ts` (`TourRole` — every ViewRole except `global_admin` gets a tour, so a new role needs both the union entry and its `SPECIALIST_CONFIG` steps + `onboardingTour.<role>.*` copy in three bundles). Keep the read scope in lockstep on both sides — every backend `can_read_*` mirror must have a frontend counterpart, otherwise the UI hides pages the backend would happily serve.

**`finance` reads the whole cloud/network surface (2026-09-04)** — and the lesson is where the gate actually was. The user asked for "云这个里面所有的只读的权限"; the backend needed **no change at all**, because `can_read_cloud` and `can_view_cloud_resources` already name `FINANCE_ROLE`. What blocked finance was one frontend line — `RoleRoute roles={['global_admin','cloud_admin']}` in `App.tsx` — so the page 403'd **at the router, before any request went out**. Before widening a backend read gate "so finance can see X", check whether the backend already says yes.

Two structural pieces came out of it, both reusable:

* **Restricted views reuse the admin menu's group OBJECTS, minus keys.** `withoutChildKeys(group, hiddenKeys)` in [`MainLayout`](../../web/src/layouts/MainLayout.tsx) returns a copy of a menu group with named children dropped (copy, not splice — `adminItems` is the same object global_admin renders, and mutating it once cost the admin their API Key entry). `finance` and `cloud_admin` both consume `cloud-mgmt` through it, so a page added to 云资产 reaches every admitted view without a second edit, and the two menus cannot drift.
* **The exclusion criterion is "is the READ endpoint itself `can_manage_cloud`", not "does it feel sensitive".** 回收站 / 角色目录 / 阿里云百炼 are out for exactly that reason (plus `API Key 管理`, which is global_admin + asset_admin): a menu entry would open a page whose FIRST fetch 403s, which is worse than no entry. Widening those three backends is a separate decision.

The boundary is a test, not a comment: [`test_finance_cloud_read_parity_2026_09_04.py`](../../server/tests/test_cmdb/test_finance_cloud_read_parity_2026_09_04.py) sends **`X-View-Role: finance`** (mandatory — without it the fixture user still carries `employee` and several reads pass for the wrong reason) at all 13 reads behind finance-visible cloud pages, asserts 403 on the 2 admin-only reads, and asserts one write is refused. Tighten a read gate while leaving the sidebar entry and it goes red.

**Corollary — a `mode` prop is not an authorization check.** `CloudScopeRequestsPage` rendered 审批 / 驳回 / 手动开通 on `mode === 'admin'`, which says *which queue this is* (everyone's vs mine), not *whether you may act on it*. The moment a read-only role reached the admin queue, that condition handed it a full button row where every click 403s. Same fix in `CloudScopesPage` (立即同步 / 下线), `CloudIamUsersPage` (改绑负责人 / 清理) and `LeaverResiduePanel` (忽略 / 重新扫描) — all four rendered writes off page/mode rather than off `canManageCloud()`. When you open a page to a new read-only role, grep it for write controls that key off anything but a role predicate.

**RoleRoute auto-promotion**: `RoleRoute` admits the `management` role wherever an **admin-tier role** is in the allowlist — `global_admin`, `asset_admin`, `hr`, `finance`, or `cloud_admin` (broadened 2026-05-20 from "global_admin only" to all admin-tier roles, so management gets visibility on every domain-admin surface, not just the global_admin pages). Write controls still gate via `canManage*` mirrors (none of which include `management`), so management lands on the same admin pages a domain admin sees but every mutation backend-403s. This is intentional — the leadership view's brief is "see everything, change nothing". `cloud_admin` is **not** auto-promoted onto global_admin routes because its scope is bounded to the cloud + network domain; each cloud/network route opts cloud_admin in explicitly in `App.tsx`.

### 2.5 Dependencies (FastAPI gates)

[`server/app/core/dependencies.py`](../../server/app/core/dependencies.py) provides:

- `get_current_user` — JWT or PAT, eager-loads roles via `selectinload(AuthUser.user_roles).selectinload(AuthUserRole.role)`. Sets `request.state.auth_via` ∈ {jwt, pat}, `request.state.pat_token_id` for PAT.
- `require_admin()`, `require_active_employee()`, `require_asset_admin()`, `require_auditor()` — common gates.
- `require_directory_read()` (added 2026-06-01) — `global_admin + hr + asset_admin + finance + cloud_admin + auditor + management`. Gates the lightweight people picker (`/api/v1/directory/people`), department list (`/api/v1/auth/departments`), and department tree (`/api/v1/auth/departments/tree`). Mirrors `can_view_directory`. The full HR-only `/api/v1/users` endpoint stays on `require_hr_read`.
- `require_hr_admin()` (added 2026-05-14) — `global_admin + hr`. Used by every endpoint under `/api/v1/hr/*` (HR lifecycle / SF sync) AND by the HR-facing read surface on `/users` / `/auth/users/stats` / `/auth/org-tree` / `POST /users/{id}/reveal-contact` / `GET /users/{id}/roles`. Mutations on `/users` stay admin-only. (`/auth/departments` moved to `require_directory_read` 2026-06-01.) **Frontend mirror is `canManageHR` in [`web/src/lib/rbac.ts`](../../web/src/lib/rbac.ts)** — it only started admitting `hr` on 2026-08-27; before that the mirror was `['global_admin']`, which made the whole 「HRIS 同步」 page (`/admin/hr/sync`) a dead end for HR (route admits `['global_admin','hr']`, every endpoint on the page admits `hr`, and the page still early-returned 「权限不足」). If you add an `hr`-admitting endpoint, check the mirror before assuming the UI follows. The premise is now pinned server-side by [`test_hris_sync_page_hr_authority_2026_08_27.py`](../../server/tests/test_hr/test_hris_sync_page_hr_authority_2026_08_27.py) (13): every read the page issues on mount is 200 for `hr` and 403 for a plain `employee`.
- `require_foundation_admin()` (added 2026-05-14) — `global_admin + asset_admin + hr`. Used by Foundation org-config endpoints: `office-addresses` (POST/PUT/DELETE) and `legal-entities` (full CRUD). HR owns onboarding/offboarding and needs to keep this org config current.
- The Leaver Blocked guard ALSO fires server-side at `before_flush` regardless of which gate was used.

### 2.7 Mobile aggregate + app catalog API surface (added 2026-05-22, M-Mobile-1.1)

The mobile rebuild adds a dedicated `/api/v1/m/*` surface so the Feishu H5 client can collapse first-screen fan-out into one round-trip per page. Lives in [`server/app/mobile/`](../../server/app/mobile/) — separate from `dashboard/` because the audience (workspace home, app grid, role-aware KPI cards) and shape (minimal bytes, mobile-flat schema) differ from the desktop dashboard summary.

- `GET /api/v1/m/aggregate/home` ([aggregate.py](../../server/app/mobile/aggregate.py)) — bundles user identity + role set (including the synthetic `"manager"` derived from having ≥1 subordinate) + cross-domain counts (`pending_todos = AssetTransferRequest.pending + ProvisioningTask.pending_for_user`, `assigned_assets`, `unread_inbox`, `team_subordinates`, `team_leavers_pending`, `admin_approvals_pending`, `admin_provisioning_queue`, `admin_offboarding_blocked`) + 3 most-recent inbox rows. Counts the caller has no role to see are always `0` (privacy default); the client uses `available_roles` to decide which cards to mount, not whether a specific count is non-zero — so a global_admin with zero pending approvals still gets the admin card mounted with an empty state. **2026-07-10 (PR5)**: also returns `pinned_apps` — the caller's role-default pinned app entries (`pinned_apps_for(role_names)` in [`apps_catalog.py`](../../server/app/mobile/apps_catalog.py), gate+pin filtered), so the home "我的东西" shortcut row renders real quick-access chips (bills / permissions / inventory / my-cloud …) instead of an empty placeholder — no separate `/m/apps/catalog` round-trip. The apps catalog also gained an `admin_cloud_approvals` entry (`/m/admin/cloud/approvals`, gate cloud_admin/global_admin) so the PR3 cloud-approval queue is discoverable in the apps grid, not only via the inbox.
- `GET /api/v1/m/apps/catalog` ([catalog_router.py](../../server/app/mobile/catalog_router.py)) — drives the mobile App Grid. Returns `categories` (stable 8-bucket ordering: my / team / asset / approval / cloud / software / audit / report) + `apps` filtered server-side by the caller's role gates + `pinned_default` keys to seed the workspace home shortcut row. The catalog data structure lives in [`apps_catalog.py`](../../server/app/mobile/apps_catalog.py) (`APPS: list[_AppEntry]`, each entry: `key` / `category` / `title_key` / `subtitle_key?` / `icon` / `path` / `gate(role_names) -> bool` / `pinned_default`). M1.1 ships `APPS = []`; each subsequent M-Mobile milestone appends entries as it ports the matching desktop page. **PR1 (2026-05-25)** adds an optional `?as_role=<role>` query parameter so the mobile RoleSwitcher can narrow the catalog to one perspective — `employee` / `manager` collapse to the `_any_user` subset; other values pass through to `visible_apps_for([as_role])` only if the caller actually holds that role. `as_role` is always a narrowing, never an escalation: unknown values or roles the caller doesn't hold fall back to the full role set.
- `GET /api/v1/m/aggregate/inbox` ([action_inbox.py](../../server/app/mobile/action_inbox.py)::`build_action_inbox`, **mobile 生产级重做 W0-PR1, 2026-06-05**) — the **unified cross-domain action inbox**: the *list* counterpart to `/home`'s counts, and the data spine of the role-workspace action centre (W0-PR3). A registry of role-gated providers emits a type-agnostic `MobileActionItem` (`id="{type}:{entity}"` / type / title / subtitle / severity `urgent·warning·info` / `deep_link` to the page that mutates it / optional inline `actions`). **Read-only — never the source of truth.** Sources this revision: self-scope `asset_transfer_confirm` (recipient pending, inline confirm/reject) + `provisioning_ack` (LEFT JOIN `asset_software_catalog` for the title); `compliance_finding` (open `AuditorFinding`, severity-mapped; deep-links to the decidable worklist `/m/auditor/findings`, W4-PR14) gated `auditor` (**2026-06-14 fix**: findings are an auditor-perspective concern — narrowed from `can_manage_security`, which also matched global_admin and flooded the admin action centre with 安全发现); `approval` (pending `ApprovalRequest`) gated `global_admin`; `leaver_blocker` (pending `OffboardingBlocker`) gated `can_manage_leaver_blocked`. Role gating is off the role set **narrowed to the mobile RoleSwitcher `?as_role=` perspective** ([`perspective.py`](../../server/app/mobile/perspective.py)::`resolve_perspective_role_names`, single source of truth shared with the apps catalog — **2026-06-14 fix**: the mobile webview never sends `X-View-Role`, so before this the action centre ignored the active perspective and leaked admin/auditor items into the employee view). A global_admin "viewing as employee" now sees only self-scope items; findings surface only in the auditor perspective. Stable two-pass sort (severity, then newest-first); `counts` (per-type, pre-pagination, for badges) + `total`; per-source cap 200 (triage view — dedicated per-domain pages remain the full queue); naive-UTC normalisation for cross-source comparison. New types are a new provider, no schema change. **MCP**: `mobile_action_inbox` (read-only, 1:1 with the HTTP route, reuses `build_action_inbox`) — `_REGISTERED_TOOLS_COUNT_HINT` 246 → 247. Later W0 PRs add the write primitives (`useMobileMutation`/`MActionBar`/`MDecisionSheet`) and the action-centre home; new write surfaces (inventory response, access-cert review, etc.) extend the inbox as new providers. **2026-07-10 action-plane rebuild (mobile能力整改 PR1)**: the bar is now strictly "**waiting for ME to act, and I can act on it inside Helix**". **Removed** the company-wide `approval` provider — it listed every pending `ApprovalRequest`, but those are Feishu-mirrored (`ApprovalStep.approver_id` is never populated in Helix; the approver decides in the Feishu app), so it was noise the admin could neither own nor clear (the "why can I see everyone's cloud approvals?" report). The company-level pending-approval *board* lives on `GET /approval/admin/overview` (below), NOT the personal inbox. **Added** 7 in-Helix-decidable providers, each deep-linking to a working mobile page (never a dead-end): self-scope `access_attestation` (open self-attestation with undecided items → `/m/access-review`), `support_reply` (my ticket in `waiting_requester` → `/m/support/:id`), `attendance_confirm` (intern/leader report pending my confirm → `/m/attendance/:id`), `inventory_response` (pending 盘点 items in active cycles → `/m/inventory`), `onboarding_ack` (unacked 入职须知, gated on an OPEN `OnboardingProcess` so a tenured employee never gets a perpetual to-do → `/m/lifecycle`); role-gated `support_assigned` (`SupportTicket.assignee_id == me`, gated `global_admin`, excludes `waiting_requester` → `/m/admin/support/tickets/:id`) and `leaver_override_signoff` (`LeaverDualSignOverride` pending_secondary, gated `can_secondary_sign_leaver_override` AND scoped to the NAMED `secondary_signer_user_id == me` → `/m/admin/leaver-override/:id`). Aggregate count types (attestation/inventory/onboarding) send a bare count; `MActionItemCard` localises it (`mobile.inbox.count.<type>`). **2026-07-10 cloud-approval closure (PR3)**: added `cloud_grant_approval` (pending `CmdbCloudAccessGrant` — mirrors `GET /cmdb/my-pending-grant-approvals`: cloud_admin sees org-wide, a scope owner sees owned-scope grants minus self; runs every perspective because owning a scope is a data-driven duty → `/m/cloud/grant-approval/:id`) and `cloud_scope_approval` (pending `CmdbCloudScopeRequest`, gated `can_manage_cloud` → `/m/cloud/scope-requests/:id`). Both are now decidable IN the mobile UI: grant approve/reject carry no step-up, and the scope-request `require_browser_session` gate distinguishes PAT from JWT — the Feishu webview's OAuth JWT passes — so `MScopeRequestDetailPage` hosts approve/reject inline (the prior desktop hand-off was based on a mistaken "webview can't step-up" assumption) and the new `MCloudGrantApprovalDetailPage` + consolidated `MCloudApprovalsPage` (`/m/admin/cloud/approvals`, `MActionInbox` filtered to the two cloud types) complete the loop. **MCP +3** (hint 407 → 410): `approve_cloud_grant` / `reject_cloud_grant` (scope owner OR cloud_admin, mirrors `_require_scope_owner_or_admin` + prod-write-lock; grant decisions are not step-up-gated so a PAT may act) + `reject_cloud_scope_request` (cloud_admin). NO `approve_cloud_scope_request` MCP tool — scope approval triggers AWS CreateAccount and is step-up-gated, which rejects PAT/MCP by design.

Frontend client lives at [`web/src/api/mobile.ts`](../../web/src/api/mobile.ts) — schemas mirror [`server/app/mobile/schemas.py`](../../server/app/mobile/schemas.py) 1:1 (no codegen; keep both sides in sync when either changes).

- `GET /api/v1/approval/admin/overview` ([admin_overview.py](../../server/app/approval/admin_overview.py)::`build_admin_approval_overview`, **unified admin pending-approval board, 2026-06-10**) — the **admin-scoped sibling** of `build_action_inbox` / `build_my_requests`: every approval-type item *awaiting a decision* across the whole platform, in one oldest-pending-first list. Closes the fragmentation where an admin context-switched across ≥4 pages (`/admin/approvals` = employee_requests only, `/admin/cloud/scopes?tab=requests`, the network pages, the deep-link-only leaver-override review). 9 providers, each reading its source's PENDING rows + outer-joining `IdentityPerson` for the requester name (no N+1): `employee_requests`, `cmdb_cloud_scope_requests` (pending+feishu_sent), `leaver_dual_sign_overrides` (pending_secondary), `cmdb_dns_record_requests`, `domain_registrations`, `vpn_applications`, `ai_tool_applications`, `announcement_requests`, `api_key_release_requests`. Each item tagged with **`decided_in`** (`helix` = a Helix page decides it / `feishu` = the approver acts in Feishu and Helix only mirrors status) + a `deep_link` to that surface when one exists (VPN/AI-tool have none — Feishu-only) + `age_days`. **Read-only — never the source of truth; decisions still happen on each item's owning page**; this unifies *discovery*. `counts_by_source` / `counts_by_decided_in` off the unfiltered set. HTTP gated `require_admin_read` (global_admin / auditor / management). **Frontend**: the new default **待办总览** hero tab on `/admin/approvals` ([`AdminApprovalOverviewTab`](../../web/src/pages/admin/AdminApprovalOverviewTab.tsx)); the existing employee_requests rich table + analytics stay as the other tabs. **Mobile (2026-07-10, PR4)**: `MAdminApprovalsPage` (`/m/admin/approvals`) now consumes this board too (was employee_requests-only) — a decided_in filter (all/helix/feishu), age-coloured rows, tap routes to the item's mobile decidable/detail page (employee→`/m/approval/:id`, cloud_scope→`/m/cloud/scope-requests/:id`, leaver_override→`/m/admin/leaver-override/:id`), Feishu-only sources marked non-navigable. Observation surface — not in the personal action inbox. **MCP**: `admin_approval_overview` (1:1, reuses the builder, same `_require_admin_read` gate) — `_REGISTERED_TOOLS_COUNT_HINT` 264 → 265.
- `GET /api/v1/me/requests` ([my_requests.py](../../server/app/domains/me_universe/my_requests.py)::`build_my_requests`, **unified 我的申请 tracker, 2026-06-10**) — the **outgoing complement to the action inbox**: every request the caller *submitted*, across all domains, in one list with a normalised lifecycle status. Where `build_action_inbox` answers "what must I act on?", this answers "where is my laptop request / did my cloud-scope request get approved?". Same provider-registry shape (one provider per source table, all **self-scoped** to the caller — no role gate), normalising each source's status vocabulary (`feishu_sent`→pending, `provisioned`/`granted`→completed, `withdrawn`→cancelled, …) into one of pending/in_progress/completed/rejected/cancelled/revoked/failed; `is_open` = pending+in_progress. Sources: `employee_requests` (asset/software/api-key/Google), `cmdb_cloud_scope_requests`, `cmdb_dns_record_requests`, `vpn_applications`, `ai_tool_applications`, `announcement_requests`, `domain_registrations`, `api_key_release_requests`. Each item carries `source` / `category` (hardware/software/email/network/cloud/ai/general) / `deep_link` to the owning page / `feishu_instance_code` / and — for still-cancellable **pending** items where a uniform request-id cancel endpoint exists — a `cancel_path` (the existing per-domain cancel route; server stays authoritative on ownership+pending). `counts_by_status` / `counts_by_category` are computed off the **unfiltered** set so the FE filter chips stay stable. `str()`-bound for the `String(36)` `person_id`/`requester_person_id` columns, UUID-bound for the `Uuid` ones. Read-only. **Frontend**: the `/my/requests` records tab is now [`MyRequestsTracker`](../../web/src/pages/asset/MyRequestsTracker.tsx) (replaces the old `employee_requests`-only list; admin view keeps the admin records table). **MCP**: `list_my_requests` (1:1 with the HTTP route, reuses `build_my_requests`) — `_REGISTERED_TOOLS_COUNT_HINT` 263 → 264. **Outcome layer (结果闭环 PR1, 2026-06-18, 0 migration / 0 cron / 0 new MCP)**: every terminal item now also carries `outcome_reason` (WHY it ended — resolved from the source row's own columns: `rejection_reason`/`failure_reason` for cloud-scope, `detail.reject_reason`/`revoke_reason` for cloud-grant via `cloud_grant_outcome_reason`, `error_message` for DNS, `revoke_reason` for VPN/domain/SSO), `outcome_reason_code` (machine code the FE localises when the reason is an enum), `needs_attention` (rejected/failed = a failure the requester should act on; cancelled/revoked excluded), and `recourse[]` (deterministic action codes `reapply`/`contact_it`/`goto_source` from `resolve_recourse`). `_mk` + `_finish` gate reason/code to terminal rows only; `_timeline` attaches the reason as the terminal event's `note` so the synthesised timeline shows WHY inline. For Feishu-routed sources whose reject note lives only in the approval instance, `build_my_request_detail(with_live_approval=True)` recovers the approver's rejection comment from the live flow (`_reject_comment_from_timeline`) into `outcome_reason` + the timeline note. Same fields flow through `build_team_requests` (manager view) for free. `list_my_requests` MCP twin emits them via `model_dump()` (no new tool — hint unchanged). **Frontend (结果闭环 PR2, 2026-06-18)**: shared [`RequestRecourseActions`](../../web/src/components/RequestRecourseActions.tsx) (`reapply`/`goto_source` → owning page, `contact_it` → `/me/support` with a prefilled ticket draft via `location.state`) + [`utils/requestOutcome.ts`](../../web/src/utils/requestOutcome.ts) `localizeOutcomeReason`; `RequestDetailModal` shows a failure `Alert` (reason + recourse) on `needs_attention`; `MyRequestsTracker` rows show the reason as red subtext + inline recourse; `MySupportPage` consumes the draft state; `/dashboard` 我的申请 KPI surfaces the `rejected+failed` count as a warn. **PR3 (provisioning owner queue, 2026-06-18)**: `GET /provisioning/tasks/my` gained a `statuses` CSV multi-filter (mirrors `/tasks`; `list_tasks` already took `statuses: list[str]`) so the software-owner 我的待办 queue pulls `notified,failed` in one round-trip — a FAILED job (executor couldn't grant/revoke) surfaces as an action item with its `error_message` shown red inline + an inline 重试 (`retryTask`) / 已处理 button, instead of hiding under the unfiltered toggle. **PR4 (lifecycle blockers, 2026-06-18, theme close)**: `MyLifecyclePage` offboarding blockers with no self-serve action (sso / email_group / cloud_scope / domain / google_orphan, or hardware/api_key missing `resource_id`) and onboarding-readiness dims with no CTA gained a 求助 IT button → `/me/support` with a prefilled ticket draft (reusing the PR2 `location.state.draft` channel); mobile `MMyLifecyclePage` routes a leaver-with-blockers to `/m/help`. The 4-PR theme is complete — every request/blocker, on every surface, answers outcome / why / what-now. **Review fixes (2026-06-18)**: `POST /provisioning/tasks/{id}/retry` now allows the task assignee (not just admin) so the owner-queue 重试 button works for non-admin owners (mirrors `complete_task`; `reset_assignee` stays admin-only); the `domain` source's `deep_link` moved `/my/cloud/domains` → `/my/requests` (re-registration form lives there, the cloud list is read-only); `outcome_reason_code` dropped for vpn/sso/domain (their `revoke_reason` is free text, only cloud_grant's is a real enum).
  - **Manager axis (2026-06-10, PR3)**: `GET /api/v1/me/team/requests` (`build_team_requests`) is the third visibility axis (self → admin → manager). The providers were refactored to take a **list** of person ids (`.in_(...)`), so the same registry backs both the self view (`[caller]`) and the team view (the caller's subordinate ids from `app/core/person_scope.subordinate_person_ids` at `SubordinateScope.MANAGED`). Each item gained `requester_id` (always) + `requester_display` (filled only by the team view via one batched name lookup). Manager-derived scope, **read-only** (managers see their reports' requests, they don't decide them); no reports → empty (not 403), mirroring the other `/me/team/*` endpoints. Frontend [`TeamRequestsPage`](../../web/src/pages/team/TeamRequestsPage.tsx) at `/my/team/requests` under the 我的团队 menu. MCP `list_team_requests` — hint → 266.

**Shared mobile write primitives (W0-PR2, 2026-06-05)** — every `web/src/mobile/*` mutation goes through these instead of calling `apiClient` directly; reuse them, don't reinvent. **[`useMobileMutation`](../../web/src/mobile/hooks/useMobileMutation.ts)**: mints a scope-prefixed `Idempotency-Key` per `run()` (reused across an in-flight retry), synchronous re-entry guard (kills double-tap → double-submit), unified loading/success toast + `getApiErrorMessage`-unwrapped error toast, **auto-retry OFF by default** (`retryOnNetworkError` — blind retry of a non-idempotent write can double-execute; only enable once the endpoint honours the key), `throwOnError` to keep a sheet open on failure. **[`MActionBar`](../../web/src/mobile/components/MActionBar.tsx)**: bottom action bar via `position:sticky;bottom:0` (pins above the persistent TabBar, NOT `fixed` overlaying it) + safe-area inset; presentation-only (caller passes localised labels). **[`openDecisionSheet`](../../web/src/mobile/components/MDecisionSheet.tsx)**: decision+note dialog with the note buffered in a **ref not state** (state re-render steals iOS keyboard focus), required-validation, throw-to-stay-open. **Mobile design tokens** [`styles/tokens.css`](../../web/src/mobile/styles/tokens.css) (`--m-*` spacing/radius/severity-colour, imported once by MobileLayout) + [`tokens.ts`](../../web/src/mobile/styles/tokens.ts) (`severityColor()` for the inbox). **[`MStepForm`](../../web/src/mobile/components/MStepForm.tsx)** (W2-PR10) — lightweight multi-step form scaffold: owns step nav + per-step `validate()` + prev/next/submit footer; caller supplies each step's content + an `onFinish` (typically `useMobileMutation.run`). First consumer: `MApiKeyRequestSheet` (mobile-native 申请 API Key on `MMyApiKeysPage`); template-driven asset/software requests (dynamic answers + buyback + payment + uploads) stay a desktop flow. First write-primitive consumer (proof): `MProvisioningTaskDetailPage` complete/fail; `provisioningApi.completeTask/failTask` gained an optional `idempotencyKey` arg.

**Dignified desktop handoff (W0-PR4, 2026-06-05)** — **[`MHandoff`](../../web/src/mobile/components/MHandoff.tsx)** replaces `DesktopOnlyBanner`'s passive "action-on-desktop" dead-end for ops that are genuinely impractical on a phone (Excel bulk import, complex IAM policy edit, connector creds, multi-step procurement): a card stating WHY + one-tap **copy deep link** (`navigator.clipboard` w/ `execCommand` fallback) + **open on desktop**. Caller passes its desktop twin path explicitly (`desktopPath`) — never guessed; URL = `origin + desktopPath` so the copy works outside the Feishu webview. First consumer: `MMailGroupsPage`. The remaining ~25 `DesktopOnlyBanner` pages migrate in W5-PR16. **Deep-link coverage** ([`lib/mobileDeepLink.ts`](../../web/src/lib/mobileDeepLink.ts)`::mapDesktopPathToMobile`) expanded so cards/notifications carrying a desktop URL land on the right `/m/*` page instead of `/m` boot: offboarding-process detail, leaver-override detail, user detail, cloud-scope detail (admin + read-only paths), employee cloud-resource detail (`/my/cloud/<kind>/:id`), and the admin approval/provisioning/offboarding/user queue lists.

**Infinite-scroll list primitive (W5-PR17, 2026-06-05)** — **[`useInfiniteList`](../../web/src/mobile/hooks/useInfiniteList.ts)**: accumulate-paginated state (a `fetchPage(page,size)→{items,total}` adapter + pull-to-refresh + `loadMore` + `resetKey` to re-fetch from page 1 on search change + monotonic request-id guard + `fetchWithRetry`). Pairs with antd-mobile `InfiniteScroll`. Replaced the page-1-only silent 50-row cap on the three `useListUrlState` admin browse lists (`MAdminCloudServersPage`/`MAdminUsersListPage`/`MAdminAssetsListPage`). The action inbox already had its own infinite scroll (`useActionInbox`, W0-PR3); employee self-service lists are per-user-small and don't need it. **W0–W5 mobile rebuild complete** (17 PRs): unified action inbox + action-centre home, write primitives (`useMobileMutation`/`MActionBar`/`MDecisionSheet`/`MStepForm`) with the 4 write categories standardised on the idempotent path, full deep-link coverage, dignified handoff (zero `action-on-desktop` dead-ends), mobile auditor finding decisions, infinite scroll.

Test suite: [`server/tests/test_mobile/`](../../server/tests/test_mobile/) — 12 integration tests covering home counts, role filtering, privacy invariants on admin counts for non-admins, manager derivation from `IdentityPerson.manager_person_id`, and catalog role-gate semantics.

Mobile RBAC posture — **current** (DECISIONS.md 2026-06-14; this SUPERSEDES the 2026-05-22 "READ + single-record actions only / batch+destructive+config never on mobile" rule, which was still quoted verbatim in this paragraph until 2026-07-28 and had misled at least two follow-up reviews): mobile is a **first-class operating surface**. Only two guardrails remain, and both are platform-level rather than mobile-specific — they apply identically on desktop: (1) irreversible / high-blast-radius operations (cloud scope decommission, connector credentials) stay `global_admin`-only behind `ensure_prod_scope_writable`; (2) every write goes through `useMobileMutation` (Idempotency-Key + synchronous double-tap guard). Pages that remain desktop-only are a *judgement* per surface (Excel batch import, IAM policy editors, budget/alert-rule builders, evidence-pack downloads the Feishu webview handles inconsistently), not a category ban — and each one is reached through [`MHandoff`](../../web/src/mobile/components/MHandoff.tsx) so there are no dead ends.

**安全 FAQ on mobile (2026-08-27)** — `/m/security-faq` + `/m/security-faq/:entryId`, plus a `security_faq` tile in the apps grid (all-staff, unpinned). Three decisions worth not re-litigating. (1) **One search box, and it always asks.** Desktop splits ask (a question) from filter (a keyword) because they are different intents and it has the room; two search bars stacked on 390 px read as a mistake. Costless because `POST /me/security-faq/ask` runs keyword matching first — typing a keyword into it behaves exactly like a filter. (2) **List → detail, not an accordion.** A full answer is four or five sentences of policy prose. List rows clamp to two lines via CSS `line-clamp` rather than a character slice — a hard slice lands mid-word and reads as damage. (3) **The deep link is an exception to the strip-query rule** in [`mobileDeepLink.ts`](../../web/src/lib/mobileDeepLink.ts): the desktop 复制链接 button hands out `/me/security-faq?entry=<id>` specifically to be pasted into a Feishu thread, which is where it gets tapped on a phone, so `?entry=` is promoted to a path segment (`/m/security-faq/<id>`). Both forms are registered in `contracts/mobile-deep-links.json`. The 「没找到」 recourse hands off to `/m/support/new` with a `location.state.draft`, reusing the ticket intake rather than opening a second inbox.

**Employee knowledge surfaces on mobile + the shared-renderer injection seam (2026-07-28)** — five pages: `/m/handbook` + `/m/handbook/:docId`, `/m/announcements`, `/m/policy-center`, `/m/company/offices`. The handbook reuses the SAME content pipeline as desktop rather than forking it: [`MarkdownDoc`](../../web/src/pages/handbook/MarkdownDoc.tsx) grew `renderMermaid` / `renderLiveTable` / `bodyClassName` props whose defaults are the existing desktop behaviour, because the two default block renderers cannot cross to mobile — `LiveTable` is built on antd's `Table`/`Input`/`Alert` (importing it drags the desktop bundle over the `manualChunks` boundary), and `MermaidDiagram` pulls the **2.3 MB** mermaid chunk to render a diagram that is unreadable at 375 px. Mobile injects [`MLiveTable`](../../web/src/mobile/components/MLiveTable.tsx) (antd-mobile `List`/`Tag`) and a mermaid placeholder that lazy-loads the real renderer into a full-screen popup only when tapped (verified via resource timing: the mermaid library is absent until the tap). `/m/announcements` and `/m/policy-center` are one component behind two routes so each keeps its own deep link and back stack. Policy notices deliberately expose **no acknowledgement action** — `policyNoticesApi` has no ack endpoint, and a UI that implies one would be a compliance lie. The office directory filters out inactive offices (in a "where do I go" directory a closed office is a wrong answer, not a grey badge).

**Personal-domain surfaces on mobile (2026-07-28)** — five pages plus a bills extension: `/m/leave`, `/m/notification-preferences`, `/m/done`, `/m/api-tokens`, `/m/me/settings` (`?tab=aliases|groups`), and `/m/bills` gaining personal budgets + add-on charges. Four contract facts worth not re-deriving:

- **PATs work inside the Feishu webview.** `require_browser_session` (in [`core/dependencies.py`](../../server/app/core/dependencies.py)) branches only on `request.state.auth_via == "jwt"` — it exists to stop a PAT minting another PAT, and does not sniff user-agents or distinguish browser from webview. The mobile OAuth round-trip yields an ordinary JWT, so `POST /auth/me/tokens` passes. The PAT plaintext is returned **exactly once** (server keeps only a SHA-256 hash), so the reveal is a `closeOnMaskClick={false}` popup whose only exit is an acknowledge button, and the copy affordance branches on `copyToClipboard`'s real boolean — a webview clipboard denial is silent, and toasting "copied" over an empty clipboard costs the user the credential. `tokensApi.create` forwards an `Idempotency-Key` so a weak-network retry replays the original response instead of leaving a second live credential nobody knows about.
- **`GET /me/todos/done` clamps `page_size` by hand** (`min(…, 100)`), not via `Query(le=100)` — an over-limit value is **silently squashed to 100** rather than rejected with a 422. Callers must self-limit; the mobile pager uses 40.
- **`POST /me/email-groups/leave` can answer 200 with `already_absent: true`.** That means Google reported the caller is not a DIRECT member: they are in the group through a nested group, the delete was a no-op, and **they keep receiving the mail**. Mobile surfaces it through `useMobileMutation`'s `partialWarning` (which suppresses the success toast) AND keeps the row in the list tagged "indirect member" — the desktop tab drops the row in both cases, which tells the user they left a group they are still in.
- **`GET /finance/fiscal-year-config` 403s for a plain employee.** It only resolves WHICH fiscal year to query (≠ calendar year for Jan–Mar under an April fiscal start), so it is a helper, not a dependency: both `MyBudgetPage` and `MMyBillsPage` catch it to `null` and fall back to the calendar year. Letting that one 403 escape would blank a whole page over an auxiliary lookup.

Deliberate non-features, so they are not "missing": there is **no leave-application flow** (Helix has no such endpoint at all — SF EC Time-Off is the system of record), the done-history **report tab** stays desktop (a 12-month multi-series stacked trend is unreadable at 375 px), and `SoftwareCostPage` is **not** ported — it is dual-mounted at `/my/software-costs` and `/admin/assets/seat-allocations` as a company-wide console (month-end close/reopen, seat approve/revoke, per-vendor reconciliation), so only `global_admin` / `finance` get an `MHandoff` to it. `/m/me/settings` is the mail-group surface for groups the caller is a MEMBER of (`GET /me/email-groups`); `/m/mail-groups` is the one for groups they OWN (`only_mine=true`) — two endpoints, two questions, deliberately not merged.

**One mobile list contract + real search on the auditor lists (2026-07-28)** — [`MAdminListShell`](../../web/src/mobile/components/MAdminListShell.tsx) now runs on `useInfiniteList`, the same hook the other 14 mobile list pages already used. Its eleven consumers (5 cloud-admin inventories + 6 auditor rosters) each hardcoded `{page: 1, page_size: 50}` and the shell rendered exactly that with no load-more and no total — so 1,200 DNS records or a 300-person MFA roster showed 50 rows with **no indication the rest existed**. A silent cap reads as "that's all of them", which is worse than an error because you act on it. The shell's contract is now `fetchPage(page, pageSize, keyword) → {items, total}` with the SERVER's total displayed as "showing X of N". Six auditor endpoints (`mfa-status` / `pat-audit` / `public-exposure` / `secret-rotation` / `signin-events` / `cert-expiries`) had only enum filters while the shell always rendered a SearchBar — a search box that does nothing is worse than none, since an auditor concludes the row is absent and stops looking. They now take a `search` param via the shared `_apply_text_search` helper, which applies the same predicate to the row query **and its COUNT twin** (a filtered list with an unfiltered total would render "showing 3 of 1200" and promise matches that do not exist — the test suite pins exactly that). `DesktopOnlyBanner` is deleted: all 17 usages moved to [`MHandoff`](../../web/src/mobile/components/MHandoff.tsx), which carries the desktop link and the reason. Design tokens gained the missing semantic slots (`--m-icon-empty` — that grey was hardcoded in **51 places**; five domain accents kept separate from the severity palette; two neutral text tiers), retiring 118 hardcoded hex values across 49 files.

**`@idempotent` route decorator + mobile write coverage (2026-07-28)** — [`app/core/idempotent_route.py`](../../server/app/core/idempotent_route.py) reduces `Idempotency-Key` wiring from six lines to one, because that boilerplate cost was why the solid `core/idempotency.py` primitive reached only **5 endpoints** while `useMobileMutation` was minting a key for *every* mobile write and sending it to ~30 endpoints that silently ignored it (the hook's documented "a retry does not double-execute" guarantee was therefore false for most writes; the client-side re-entry guard only covers a double tap in one page instance). Now on **14 endpoints**, chosen as the set where a replay causes a second EXTERNAL side effect: cloud grant approve/reject/renew/revoke, provisioning complete/fail/cancel/retry, role grant, cloud-scope decommission, software-seat revoke, Google mail-group + alias creation. `key_params` supports dotted attribute paths (`"current_user.id"` — using the raw object would embed a memory address in the scope and silently make every request a fresh reservation) and raises rather than degrading to a shared `family:None` scope. Also fixed a latent bug in the shared primitive: `record()` serialised with `json.dumps(default=str)`, so a cached replay returned space-separated datetimes while the live response returned ISO-8601 — and `new Date("2026-07-27 20:02:24+00:00")` is **Invalid Date** on WebKit, i.e. every timestamp in a replayed response rendered as garbage inside the Feishu iOS webview. Now `jsonable_encoder`, the same encoder the live path uses.

**Mobile error states must be distinguishable from empty ones (2026-07-28)** — the manager hub `/m/team` used `.catch(() => setSummary(null))`, making "the risk-summary request failed" render identically to "your team has no at-risk joiners or leavers" on the tab whose entire job is telling a manager whether something needs attention. Silent failure there reads as reassurance. Now three states (loading / failed+retry / loaded) with an explicit "unknown" badge. The reports tab separates **403 "not yours"** (keep hiding the card — honest) from **failure "could not read"** (show it with a retry) via the new `getApiErrorStatus` helper; a missing KPI must never read as a zero.

**BE↔FE deep-link contract (2026-07-28)** — [`contracts/mobile-deep-links.json`](../../contracts/mobile-deep-links.json) is now the single source of truth for "where does a notification land on a phone?", enforced from both sides: [`server/tests/test_mobile/test_deep_link_contract.py`](../../server/tests/test_mobile/test_deep_link_contract.py) AST-scans `server/app/` for the four URL-sink shapes (`url=` kwarg, dict entry, `url = f"{portal_url}/…"` local, `def _x_url(): return …` helper) and fails on any path that is not classified; [`web/src/lib/mobileDeepLink.contract.test.ts`](../../web/src/lib/mobileDeepLink.contract.test.ts) asserts the mapper produces exactly the declared target (and `null` for `desktopOnly`). Third-party API paths are excluded by requiring the first segment to appear in the frontend's own route tables. Three further assertions close the classes of bug that motivated it: **every contract path must resolve against the route table** (this found SEVEN paths being emitted to users with no route on either shell — `/my-cloud-scopes`, `/email-groups`(+`?review=1`), `/admin/cmdb/grants`, `/admin/software/seat-allocations`, `/cloud/dns-requests/:id` desktop-side, a Zoom seed email's `/my/seat-requests`, and `/my/requests/:id`); **every `apps_catalog` tile must have a route** (`auditor_privileged_review` had advertised one for months without it existing); **every `action_inbox` `deep_link` must be reachable**. Also fixed: `FeishuMobileGuard` now passes `path + search` to the mapper — passing pathname alone silently disabled every query-promotion rule (`?renew=` / `?report=` / `?id=` / `?tab=`) on the FIRST tap of a card, the only tap that matters. New mobile decision surfaces added rather than de-listing their tiles: quarterly privileged-access review (list + keep/revoke worklist + complete-cycle) and mail-group join approvals.

**Mobile feedback layer + the React 19 render bridge (2026-07-28)** — every antd-mobile *imperative* surface (`Toast.show`, `Dialog.confirm`, `ActionSheet.show`) renders through `rc-util`'s `React/render`, which selects its renderer by reading `createRoot` off the **`react-dom` main entry**. React 19 removed it (verified: `'createRoot' in ReactDOM === false`, `typeof ReactDOM.render === 'undefined'` on 19.2.4), so the helper fell through to `ReactDOM.render?.(…)` — an optional call on `undefined`, i.e. a **silent no-op with no DOM and no error**. Net effect from #448 (2026-05-21) to 2026-07-27: *every* mobile confirm dialog was a dead button and *every* success/error toast invisible. Reported from production as "云权限审批点了没有反应". Fixed by [`web/src/mobile/lib/antdMobileReact19.ts`](../../web/src/mobile/lib/antdMobileReact19.ts) (antd-mobile's official `unstableSetRender` escape hatch → React 19 `createRoot`, one cached root per container). Structural guarantees so it cannot regress: the bridge's **only** importer is [`utils/feedback.tsx`](../../web/src/mobile/utils/feedback.tsx) (importing it from `routes.tsx` instead hoists all 211 kB of antd-mobile into the desktop `index` chunk — measured, don't); an ESLint selector bans bare `Toast.*` / `Dialog.*` / `ActionSheet.*` calls anywhere under `web/src/mobile/` except that file; and DOM-level tests assert the dialog/toast actually reach `document` (they fail 12/12 with the bridge removed). Both `mDialog.confirm` variants now share one implementation with one set of semantics — the plain variant used to run on antd-mobile's own `Dialog.confirm` (`closeOnAction: true`), where a throwing `onConfirm` left the caller's promise unsettled forever plus an unhandled rejection.

### 2.6 Mobile (Feishu H5) route tree (added 2026-05-21; rebuild M-Mobile-1.2 + 1.3 landed 2026-05-22)

`/m/*` is a parallel route tree to the desktop SPA, optimised for the Feishu in-app webview. Layout chrome (top bar + 5-tab bottom nav + ConfigProvider) lives at [`web/src/mobile/layouts/MobileLayout.tsx`](../../web/src/mobile/layouts/MobileLayout.tsx) and is built on `antd-mobile@5` (separate chunk; desktop bundle does not download it). The route declarations live at [`web/src/mobile/routes.tsx`](../../web/src/mobile/routes.tsx) and are inlined into the root `<Routes>` from `App.tsx`.

Bottom tabs are **role-driven** (M-Mobile-IA-PR1, 2026-05-25 — replaces the pre-PR1 fixed `工作台 / 待办 / 应用 / 报表 / 我` strip that ignored the RoleSwitcher choice). Strip composition lives in [`web/src/mobile/config/mobileMenu.tsx`](../../web/src/mobile/config/mobileMenu.tsx) as `buildTabsForRole(activeRole, counts, t)` and switches on the active perspective: **employee** → home/todos/apps/me (4 tabs — reports dropped because the employee has no reports surface); **manager** → home/team/todos/apps/me (team → `/m/team-assets`, badge = `team_leavers_pending`); **auditor** → home/audit/apps/reports/me (audit → `/m/auditor`); **admin-tier** (global_admin / it_admin / asset_admin / cloud_admin / hr / finance / management) → home/todos/apps/reports/me. Active-tab highlight uses longest-prefix match so deeper sub-routes (e.g. `/m/auditor/mfa` under the audit tab key `/m/auditor`) keep the right tab highlighted. The apps grid (`/m/apps`) subscribes to the same `activeRole` and forwards it to the catalog endpoint as `?as_role=<role>` (see §2.7) so the grid collapses to that single perspective in lock-step with the bottom bar. Top bar carries the RoleSwitcher chip (left) + page title (centre) + notification bell with unread badge (right). The notification entry was a bottom tab in M-Mobile-1.0; moved to the bell so the 5 bottom slots can carry workspace-level destinations.

- `/m` — public boot page ([`MobileBootPage`](../../web/src/pages/mobile/MobileBootPage.tsx)). Silent SSO via Feishu H5 SDK; OAuth-redirect fallback.
- `/m/home` — workspace home ([`MHomePage`](../../web/src/mobile/pages/MHomePage.tsx)). Leads with the **action-centre hero** ([`MHomeActionCentre`](../../web/src/mobile/components/MHomeActionCentre.tsx), W1-PR5): top-5 highest-severity items from the unified inbox (`useActionInbox`), inline-actionable, "view all (N)" → `/m/todos`; hidden when empty. Below it, role-aware KPI cards driven by `/api/v1/m/aggregate/home`: Employee card (always), Manager card (when `available_roles` includes synthetic `"manager"`), Admin card (when role set includes any admin-tier role), plus recent inbox + apps shortcut row. Pull-to-refresh forces the layout aggregate.
- `/m/todos` — **unified cross-domain action inbox** (rebuilt W0-PR3, 2026-06-05; was the legacy hand-rolled transfers+tasks list). Renders [`MActionInbox`](../../web/src/mobile/components/MActionInbox.tsx) over `GET /m/aggregate/inbox`: every "waiting for me" item across domains, severity-prioritised, PullToRefresh + InfiniteScroll. Asset-transfer confirm/reject stay inline (no UX regression); other types tap through to their detail page. Hooks: [`useActionInbox`](../../web/src/mobile/hooks/useActionInbox.ts) (data/pagination) + [`useInboxActions`](../../web/src/mobile/hooks/useInboxActions.ts) ((type,action)→inline-mutation-or-deep-link). The workspace home preview (W1-PR5) reuses the same hook/components.
- `/m/apps` — apps grid ([`MAppsPage`](../../web/src/mobile/pages/MAppsPage.tsx)). 8 stable category buckets, SearchBar, 4-column Grid. Entries pulled from `/api/v1/m/apps/catalog` (role-filtered server-side). Empty in M1.1–1.3; each subsequent milestone (M2/M3/M4/M5) appends entries to `server/app/mobile/apps_catalog.py::APPS`.
- `/m/reports` — Reports tab ([`MReportsPage`](../../web/src/mobile/pages/MReportsPage.tsx), PR12 landed 2026-05-24). Role-aware KPI cards backed by existing platform APIs — `aiSpendingApi.getOverview` (AI monthly cost + top-3 buckets + MoM delta, tap → `/admin/ai-spending`), `cloudApi.getBillingSummary` (cloud monthly cost + top-3 accounts, tap → `/m/cloud-cost`), and `mobileApi.home()` counts (`admin_approvals_pending` → `/m/admin/approvals`, `admin_offboarding_blocked` → `/m/admin/offboarding`). Each loader catches its own 403 so a single missing role-grant renders an empty card instead of crashing the tab; all-403 falls back to the `unauthorisedTitle` ErrorBlock. Pull-to-refresh re-fetches all four KPIs in parallel.
- `/m/me` — profile + language switcher + logout. **antd-mobile `List` + `ActionSheet` (W1-PR6 rewrite)** — the legacy hand-rolled CSS module is gone.
- `/m/assets`, `/m/notifications` — reachable from home cards and the top-bar bell, not from the bottom tabs. **Both rewritten to antd-mobile in W1-PR6** (assets: `Card`/`Tag` + expandable detail, desktop-return hard-link → `MHandoff`; notifications: date-grouped `List` + type-icon buckets). All four formerly-legacy mobile pages (todos/me/assets/notifications) are now on the shared primitives + `--m-*` tokens with no page-specific CSS.
- `/m/approval/:id`, `/m/asset-transfer/:id`, `/m/provisioning-task/:id`, `/m/notification/:id` — IM deep-link detail pages (M1.5, landed 2026-05-22). Each fetches via the relevant existing API (`approvalApi.getMyRequestDetail`, `transferApi.listIncoming/Outgoing`, `provisioningApi.getTask`, `notificationsApi.inbox`) and renders a compact detail card with the primary action inline (accept/reject for transfers, mark-complete for tasks). Power-user actions surface an "Open on desktop" link. **M-Mobile-PR1 (2026-05-24)** extends `MProvisioningTaskDetailPage` with an IT-Admin-only "Mark as failed" button (red, opens an antd Modal with a required-reason `Input.TextArea`, calls `provisioningApi.failTask(id, reason.trim())`) — mirrors the desktop `ProvisioningQueuePage` fail flow on phone, so admins can clear failed-in-the-field tasks from IM without bouncing to desktop. Same PR fixed a silent-403 bug: the existing "Mark complete" button was visibility-gated on `task.user_id === user.id` (target user) instead of `task.assignee_id === user.id` (assignee), so the button appeared for the wrong person and would 403 on click. Visibility now matches backend RBAC: `canComplete = isActive && (isAssignee || isAdmin)`, `canFail = isActive && isAdmin`. Cancel / retry intentionally remain desktop-only — both depend on attempt history + connector logs that don't fit a phone screen.

Employee bills surface (M2 first wave, 2026-05-22 PR4): `/m/bills` (MMyBillsPage) reuses the existing `/api/v1/dashboard/summary` employee section (cost_snapshot + expiring keys + expiring warranty); `/m/api-keys` (MMyApiKeysPage) calls `apikeyApi.getMyApiKeys()` for the list view (per-key MTD spend deferred to detail page); `/m/cloud` (MMyCloudOverviewPage) calls `cloudApi.getMyAnalytics` for owner-scoped totals. All three are registered in `server/app/mobile/apps_catalog.py::APPS` with `pinned_default=True` so the workspace home shortcut row surfaces them immediately for every user.

Employee cloud resource lists (M-Mobile-PR13, 2026-05-24): `/m/cloud/{servers,domains,dns,load-balancers,cdn,object-storage,iam,grants}` — 8 owner-scoped read-only inventory pages. Backend `GET /cmdb/{servers,domains,dns-records,load-balancers,cdn-domains,object-storage,iam-users}` auto-demote non-admin callers server-side, so the mobile pages call without `owner_person_id`; `cloudLifecycleApi.listMyGrants()` powers the grants page (tap → `/my/cloud/scopes/<scope_id>` for renew / release decisions). Tap-through on the other 7 pages goes to the desktop `/my/cloud/<type>` page for full filters / actions — mobile is read-only inventory. `/m/lifecycle` (MMyLifecyclePage, also PR13) reuses `GET /api/v1/lifecycle/me` and renders the employee's own onboarding / offboarding state + provisioning tasks.

Admin high-frequency pages (M-Mobile-PR14, 2026-05-24): `/m/admin/{users, users/:id, hr-sync, asset-operations, google-orphans, leaver-overrides, leaver-override/:id, offboarding/:processId, insights, api-keys}` — 10 mobile admin entries reusing `userApi.list/get`, `hrApi.listSyncBatches`, `assetApi.listOperations`, `googleWorkspaceApi.listOrphans`, `listOverrides` / `getOverride` / `approveOverride` / `rejectOverride`, `offboardingApi.getProcess`, `/dashboard/insights/overview`, `apikeyApi.listAssignments`. `MAdminLeaverOverrideDetailPage` is the only one that crosses the antd boundary (uses `antd.Input.TextArea` + `antd.App.modal` for the required-reason confirm dialog — antd-mobile has no equivalent confirm-with-input primitive; all other UI stays antd-mobile to preserve mobile chunk isolation). Bulk operations (revoke / transfer / SSO suspend / role assign / sync confirm) stay on desktop — each detail page surfaces "Open on desktop" as the escape hatch.

Cloud admin + Auditor pages (M-Mobile-PR15, 2026-05-24): `/m/admin/cloud/{overview, iam-users, scopes, scopes/:id, bailian, role-catalog}` + `/m/auditor/{<dashboard>, privileged-review/:cycleId, risk-score, evidence-packs}` — 10 pages reusing `cloudApi.getAdminOverview/listIamUsers`, `cloudLifecycleApi.listScopes/getScope/listRoleCatalogByIntegration`, `bailianApi.listWorkspaces`, `auditorApi.fetchCoverage/fetchControlStatus`, `listReviewHolders`, `listRiskScores`, `listEvidencePacks`. Role-catalog is per-integration so the page boots `/cmdb/cloud-tenants`, defaults to the first tenant, and lets the admin switch via an antd-mobile `Selector`. Pack downloads (zip / pdf) stay on desktop because Feishu webview file-download handling is inconsistent — every download CTA on mobile routes to `/auditor/evidence-packs` (desktop list). Privileged-review and role-catalog rows are intentionally non-tappable on mobile (decide / promote / demote actions stay on desktop) — no fake `onClick` affordance.

Mobile fetch retry primitives (M-Mobile-PR6, 2026-05-25): Feishu webview network blips (DNS hiccups / MITM proxy reconnects / carrier-edge timeouts) used to surface as the generic ErrorBlock + manual `<a>retry</a>` link; ~99% of those failures self-heal in 1-3 seconds, so the manual-retry UX both confused users (they thought the feature was broken) and wasted the auto-heal window. New `fetchWithRetry<T>(fetcher)` in [`web/src/mobile/hooks/useFetchWithRetry.ts`](../../web/src/mobile/hooks/useFetchWithRetry.ts) centralises the policy: 3 auto-retries with exponential backoff (500ms / 1.5s / 3s), classify via `isRetryableError` — retry no-response (DNS / timeout / abort), 5xx server errors, 408 (Request Timeout), 429 (Too Many Requests — the backoff schedule honors the server's signal without needing inconsistent `Retry-After` parsing); fail-fast on 4xx auth / permission / validation so retries don't burn 4 round-trips to surface a permanent error. `MAdminListShell` wraps its `loader(kw)` call in `fetchWithRetry` so all 11 shell-based list pages (6 auditor + 5 admin cloud) get the retry behaviour automatically. The shell gains a `slow` state + `tokenRef` monotonic guard (matching the audit-fixed `reqIdRef` pattern in MAdminCloudIamUsersPage): after 4s without data, an amber "网络较慢, 正在重试中..." hint surfaces below the Skeleton so users don't pull-to-refresh mid-retry. Handrolled list pages (the ~13 that don't use MAdminListShell) remain untouched — deferred to a future polish PR to keep PR6 verifiable on the shell path. 0 backend / 0 migration / 0 cron / 0 RBAC change.

Mobile inventory response Modal (M-Mobile-PR5, 2026-05-25): the employee `/m/inventory` page gains an in-place response Modal so the monthly / quarterly inventory confirmation can happen on mobile when the Feishu IM reminder lands — no more "tap → desktop" detour. Pending item rows re-enabled `onClick` (PR2's `DesktopOnlyBanner` removed; cycle rows stay info-only because there's no per-cycle employee decision separate from item-level responses). The Modal supports the 4 response types whose backend payload has no companion-field dependency (`in_use`, `damaged`, `lost`, `other` — all take just optional `response_notes`); `transferred` (needs `transferred_to_id` via desktop's PersonSelect / PickablePeopleSelect) and `not_mine` (needs `evidence_photos[]` via S3 presign-put + Antd Upload sync-push) surface as an explicit "在桌面端处理" link inside the Modal — user reads the label before navigating, never silently bounced. Submit calls `assetInventoryApi.respond(itemId, body)` against the existing `/asset-inventory/items/:item_id/respond` endpoint; a defensive `isMobileResponseType` type guard rejects unsupported values pre-POST so a future i18n / refactor can't smuggle `transferred` / `not_mine` through without their required companion fields. 0 backend / 0 migration / 0 cron / 0 RBAC change.

Mobile request detail + honest "open on desktop" entry points (M-Mobile-PR4, 2026-05-25): the employee `/m/requests/:id` detail page renders read-only fields (`request_number`, `status`, `type`, linked asset tag + name, target user, reason, notes, created/approved/rejected timestamps, reject reason) via `assetApi.listEmployeeRequests` + client-side find — backend has no per-id GET for employee requests so the list (`page_size=100`, auto-scoped to `requester_id == caller`) plus client filter is the cleanest path (mirrors `MAssetTransferDetailPage`). [`MMyRequestsPage`](../../web/src/mobile/pages/MMyRequestsPage.tsx) restored row `onClick` + `arrow` → `/m/requests/:id` and dropped the PR2 `<DesktopOnlyBanner variant="action-on-desktop" />` (banner redundant once the row lands on a useful mobile detail). For asset return + AI tools application — both depend on the full Feishu approval form (`approvalApi.createAssetRequest({template_key, asset_id, reason, answers})` with dynamic widgets, display_condition evaluation, fileList upload sync-push, radioV2 default backstop, 4-round-reviewed confirmLoading re-entry guard) — porting them is out of single-PR scope. PR4 uses PR2's honest text-link pattern: [`MAssetsPage`](../../web/src/pages/mobile/MAssetsPage.tsx) renders an explicit `<a className="actionLink">{t('mobile.assets.returnOnDesktop')}</a>` inside the expanded card body for `RETURNABLE_STATUSES = {in_use, claimed, borrowed, assigned}` rows; AI tools page retains the PR2 banner ("operations require desktop") which already conveys the same message. 0 backend / 0 migration / 0 cron / 0 RBAC change.

Mobile dynamic create-request engine (M-Mobile-Upgrade-PR1, 2026-06-14): **supersedes the "asset/AI request forms stay desktop" handoff** documented in M-Mobile-PR4 above (and the 2026-05-22 "mobile = read + single-record only" ADR — see [DECISIONS.md 2026-06-14](DECISIONS.md)). The Feishu-approval-template widget logic that was inlined in the desktop [`EmployeeRequestPage`](../../web/src/pages/asset/EmployeeRequestPage.tsx) (radioV2 option-set guard, NFKC widget-name normalisation, `dateInterval`/`amount`/upload value shaping, `display_condition` evaluation, primary-reason alias resolution) is now the framework-agnostic single source of truth [`web/src/components/approvalWidgetEngine.ts`](../../web/src/components/approvalWidgetEngine.ts); both desktop and the new mobile [`MRequestForm`](../../web/src/mobile/components/MRequestForm.tsx) import it — **never duplicate widget logic on mobile** (that's the historical "Feishu rename → silent value drop" bug class). `MRequestForm` renders every widget type as antd-mobile controls in a bottom Popup (input/textarea/radioV2 Selector/date DatePicker/dateInterval as start+end+duration for computer_borrow/amount+currency/image+attachmentV2 via a generic file uploader/text static NoticeBar/Payment-Information entity-prefill); picking an asset runs `buildAssetPrefill` + fetches payment info + (offboarding_buyback) the server buyback quote with client-side currency switch. Launcher [`MNewRequestPage`](../../web/src/mobile/pages/MNewRequestPage.tsx) at `/m/requests/new` (FloatingBubble "+" on `/m/requests`) lists the asset templates. Writes go through `useMobileMutation` (Idempotency-Key forwarded to `approvalApi.createAssetRequest`). Full on-phone loop for `laptop_replacement` / `computer_borrow` / `asset_return` / `offboarding_buyback` incl. photo upload + buyback quote; `software_seat` (needs catalog plan pick) and non-template flows (API key / DNS / VPN / AI tool / announcement / SSO grant / cloud scope) land in later upgrade PRs.

Mobile non-template request flows + unified request lifecycle (M-Mobile-Upgrade-PR2, 2026-06-14): closes the "file ANY request type on the phone" gap. Declarative bottom-sheet primitive [`MSimpleRequestSheet`](../../web/src/mobile/components/MSimpleRequestSheet.tsx) (field schema + real API submit + required/custom validation + idempotent `useMobileMutation` write) drives 9 fixed-field flows from [`config/requestFlows.tsx`](../../web/src/mobile/config/requestFlows.tsx): domain registration / VPN / cloud-scope / AI tool / announcement / SSO grant / mail group / email alias / mailbox password reset. DNS self-service create has its own [`MDnsRequestSheet`](../../web/src/mobile/components/MDnsRequestSheet.tsx) (zone fetch + per-type value validation); the DNS validators (`stripZoneSuffix` / `SUBDOMAIN_RE` / value regexes) are now the single source of truth [`web/src/utils/dnsValidators.ts`](../../web/src/utils/dnsValidators.ts) shared with the desktop `DnsRequestFormModal` (kills the recurring FE-stricter-than-BE drift). Software seat → [`MSoftwareCatalogPage`](../../web/src/mobile/pages/MSoftwareCatalogPage.tsx) at `/m/software-catalog` (catalog → plan pick → `POST /assets/seat-requests`). The launcher `/m/requests/new` is now a categorised full surface (asset / software / network / email / general / API). **Lifecycle**: `/m/requests` switched from the asset-only `listEmployeeRequests` to the cross-domain aggregator `GET /me/requests` (build_my_requests) so every new request type is visible, with open/all filter + inline 撤回 (per-domain `cancel_path`); the detail page renders the full Feishu **approval progress (Steps) + timeline** via `approvalApi.getMyRequestDetail` (no more "open on desktop for timeline" dead-end). 0 new endpoints — reuses existing flow endpoints + their MCP tools.

Mobile cloud-grant employee loops + provisioning cancel (M-Mobile-Upgrade-PR3, 2026-06-14): closes the remaining employee action-loop gaps an audit surfaced. [`MMyCloudGrantsPage`](../../web/src/mobile/pages/MMyCloudGrantsPage.tsx) (`/m/cloud/grants`) went from read-only + `MHandoff` to two inline loops — **my grants** (renew `renewGrant` / release `revokeGrant('user_released')`) and **pending my approval** (owner / global_admin: approve `approveGrant` / reject `rejectGrant`, via `listMyPendingGrantApprovals`), reason capture through the shared `openDecisionSheet`; `write_locked` prod-scope rows disable all actions (backend 403 `PROD_CLOUD_LOCKED`). [`MProvisioningTaskDetailPage`](../../web/src/mobile/pages/detail/MProvisioningTaskDetailPage.tsx) gained admin **cancel** (`cancelTask`, required reason) alongside the existing complete/fail/retry. Note: the originally-planned "rebuild legacy 4 pages + close todo loops" was already shipped in W0-PR3 / W1-PR6 — PR3 was re-scoped to the genuine residual gaps. 0 new endpoints.

Mobile manager team surface (M-Mobile-Upgrade-PR4, 2026-06-14): closes the "managers can't do their day job from a phone" gap (audit: only `/m/team-assets` existed). The manager bottom-tab now lands on a hub [`MTeamHubPage`](../../web/src/mobile/pages/MTeamHubPage.tsx) (`/m/team`, tab key changed from `/m/team-assets` to `/m/team`; longest-prefix highlight keeps the tab active across `/m/team*`) with at-risk badges from the lifecycle summary. Six surfaces under it: `/m/team/requests` (`fetchTeamRequests` → `/me/team/requests`), `/m/team/leavers` (`offboardingApi.listMyTeamLeavers`), `/m/team/joiners` (`/my/team-joiners`, read-only joiners list), `/m/team/footprint` (`teamApi.footprint`), `/m/team/cloud` (`teamApi.cloud`, grants/scopes/dns/domains tabs), `/m/team/lifecycle` (`teamApi.lifecycle`, joiners/leavers tabs + countdown + blocker labels). Read-only (manager approvals are Feishu-routed per the no-third-party-approval ADR; per-asset device-inheritance preference stays the desktop structured form). 0 new endpoints.

Mobile admin offboarding operations + reusable person picker (M-Mobile-Upgrade-PR5, 2026-06-14): [`MAdminOffboardingDetailPage`](../../web/src/mobile/pages/MAdminOffboardingDetailPage.tsx) went from read-only + `MHandoff` to a working admin console. Per-blocker (only `triage_bucket==='needs_it'`) ActionSheet: revoke (`revokeBlockerPreview` → if `requires_successor`, [`MPersonPicker`](../../web/src/mobile/components/MPersonPicker.tsx) prefilled with the suggested successor → `revokeBlocker(id, notes, successorUserId)`), mark-resolved (`markBlockerResolved`), skip (`skipBlocker`, required reason). Process action bar: advance-now (`advanceNow`), complete (`completeProcess`), and — gated by `canForceBypassLeaverGrace` (global_admin) — force-bypass-grace (`forceBypassGrace`, reason ≥30 chars). New shared [`MPersonPicker`](../../web/src/mobile/components/MPersonPicker.tsx) wraps `directoryApi.searchPeopleLite` (`GET /directory/people/lite`) as a debounced search-and-pick Popup — reusable for any mobile handover/successor flow. Leaver-override approve/reject and provisioning task complete/fail/retry/cancel were already inline (earlier W-series + Upgrade-PR3). 0 new endpoints.

Mobile asset + software-seat admin ops (M-Mobile-Upgrade-PR6, 2026-06-14): the admin asset list was browse-only + `MHandoff`; now rows tap into [`MAdminAssetDetailPage`](../../web/src/mobile/pages/MAdminAssetDetailPage.tsx) (`/m/admin/assets/:id`, `assetApi.get`) with inline **assign** (`assetApi.assign({user_id})` via `MPersonPicker`), **transfer** (`assetApi.changeAssignee({to_user_id})`), **return-to-stock** (`assetApi.return({return_target:'idle'})`). New [`MAdminSoftwareSeatsPage`](../../web/src/mobile/pages/MAdminSoftwareSeatsPage.tsx) (`/m/admin/software-seats`, linked from the assets list) lists seat allocations with inline **revoke** (preview → successor pick when required). [`api/asset.ts`](../../web/src/api/asset.ts) gained `listSeatAllocations` / `getSeatRevokePreview` / `revokeSeat` thin wrappers over the existing `/assets/seat-allocations*` endpoints. Gated by `canManageAssets`; backend re-checks region scope. Excel bulk import + recycle-to-office (office-picker structured flow) stay desktop. 0 new endpoints.

Mobile cloud admin ops (M-Mobile-Upgrade-PR7, 2026-06-14): admin cloud pages were all read-only + desktop handoff. Now [`MAdminCloudServerDetailPage`](../../web/src/mobile/pages/MAdminCloudServerDetailPage.tsx) (`/m/admin/cloud-servers/:id`, `cloudApi.getServer`) does power-on/off/restart/destroy (`cloudApi.powerOnServer|powerOffServer|restartServer|destroyServer`, `canManageCloud`, 2-step confirm, destroy 3-step; server power is resource-level, not prod-write-lock-gated). [`MAdminCloudScopeDetailPage`](../../web/src/mobile/pages/MAdminCloudScopeDetailPage.tsx) gained trigger-sync (`cloudApi.triggerSync`, discovery-only), owner-transfer (`MPersonPicker` → `cloudLifecycleApi.updateScope({owner_person_id})`, Helix-only rebind, lock-exempt for admins), and decommission (`cloudLifecycleApi.decommissionScope`, reason required, button disabled when `scope.write_locked`, backend 403 `PROD_CLOUD_LOCKED` backstop). Admin pending-grant approve/reject is already covered by the cloud grants page (`listMyPendingGrantApprovals` returns org-wide for global_admin). Budget/alert rule builder + recycle-bin (no mobile discovery UI) stay desktop. Gated by `canManageCloud`; all prod-scope writes respect the write lock. 0 new endpoints.

Mobile HR admin ops (M-Mobile-Upgrade-PR8, 2026-06-14): [`MAdminHrSyncPage`](../../web/src/mobile/pages/MAdminHrSyncPage.tsx) gained a **trigger-incremental-sync** button (`hrApi.previewIncrementalSync` — stages a batch; confirming the staged-events diff stays desktop, banner kept). [`MAdminUserDetailPage`](../../web/src/mobile/pages/MAdminUserDetailPage.tsx) (was read-only) now offers **mark-as-leaver** (DatePicker + reason → `hrApi.markPersonLeaverManual`) and **role-change grant review** (per `pending_review` grant from `roleChangeApi.listPersonGrants`: keep/revoke → `roleChangeApi.resolveGrant`). Mark-as-leaver is gated by `canManageHR` (= backend `require_hr_admin`, global_admin + hr since 2026-08-27); the grant-review block is gated on an explicit `global_admin` check, because `/role-changes` rides `require_admin` / `require_admin_read` and does NOT admit `hr`. **Finance**: cost-allocation rules are a multi-field match/priority/target builder — kept desktop; cost dashboards already render on mobile (`MCloudCostPage`), so no new finance mobile writes (documented, not crammed). 0 new endpoints.

Mobile native cloud-resource detail pages (M-Mobile-PR3, 2026-05-25): 7 employee-self-service `/m/cloud/<type>/:id` detail pages (servers / domains / dns / cdn / object-storage / load-balancers / iam) replacing the previous "tap row → desktop" silent jump. Backend already exposed per-id GETs for 5 of the 7 types (`/cmdb/servers/:id`, `/cmdb/domains/:id`, `/cmdb/dns-records/:id`, `/cmdb/load-balancers/:id`, `/cmdb/cdn-domains/:id`) — all auto-demote non-admin callers to owner-scope. OSS + IAM have no per-id GET yet, so those two pages list-and-find with `page_size=100` (owner-scope filter applied server-side via `mine=true` for IAM and the standard owner demotion for OSS). New shared component [`MMyCloudDetailShell`](../../web/src/mobile/components/MMyCloudDetailShell.tsx) handles the fetch / Skeleton / ErrorBlock + "Open on desktop" fallback / Card title / footer-link chrome; per-resource page wrappers only supply the fetcher + field rows (~50 lines per page instead of ~120). [`cloudApi`](../../web/src/api/cloud.ts) gained `getServer` / `getDomain` / `getDnsRecord` / `getLoadBalancer` / `getCdnDomain` thin client methods. The 7 list pages re-enabled row `onClick` + `arrow` to `/m/cloud/<type>/:id` and dropped the PR2-added `<DesktopOnlyBanner variant="action-on-desktop" />` — now that the row reliably lands on a useful mobile detail page (and the detail page itself has an explicit "Open on desktop" footer link for write ops), the banner's "operations require desktop" wording would be redundant noise. 0 backend / 0 migration / 0 cron / 0 RBAC change.

Mobile "jump to desktop" cleanup (M-Mobile-PR2, 2026-05-25): 70 navigate-to-non-`/m/*` calls in the mobile tree were audited; the worst pattern was **silent row-level onClick** (`<div onClick={() => navigate('/admin/...')}>` wrapping every list row, or `<List.Item onClick={...} arrow>`) which bounced the user mid-tap to a desktop UI that's unusable inside Feishu webview. PR2 cleans 36 such silent-jump sites + fixes 8 path bugs where the mobile route already existed but the code navigated to the desktop equivalent. New shared primitive [`DesktopOnlyBanner`](../../web/src/mobile/components/DesktopOnlyBanner.tsx) — single-row antd-mobile-flavoured info chip with two variants (`readonly` / `action-on-desktop`) — mounts at the top of every list/dashboard that's read-only on mobile, declaring the policy up front so users know what to expect before they tap. [`MAdminListShell`](../../web/src/mobile/components/MAdminListShell.tsx) gained an optional `headerSlot` prop so the 11 shell-based pages (6 auditor + 5 admin cloud) get the banner via prop without re-implementing the mount per file. Path bugs fixed in the same PR (fix-on-sight): `MHomePage` 4 admin/team rows that pointed at `/admin/*` instead of the existing `/m/admin/*` routes; `MAdminOffboardingPage` (`/admin/offboarding/process/:id` → `/m/admin/offboarding/:id`); `MAuditorPrivilegedReviewPage` (`/auditor/privileged-review/:id` → `/m/auditor/privileged-review/:id`); `MReportsPage` AI card (`onClick` on the entire Card replaced with an explicit "在桌面端打开" link inside the card so the KPI stays readable without auto-navigation on tap). The ~12 pages that already used the explicit `<a onClick>{t('mobile.detail.openOnDesktop')}</a>` text-link pattern were left alone — they're the honest pattern (user reads the label, then clicks knowingly), not the "tap a row and get teleported" anti-pattern. 0 backend / 0 migration / 0 cron / 0 RBAC change.

Mobile 动态广场 + 庆祝详情 (M-Mobile-PR17, 2026-06-02): `/feed` shipped to desktop 2026-06-01 with **no mobile route** — Feishu H5 users had no way to read the feed or send blessings from their phones. PR17 closes that gap by mounting two new pages under the mobile tree: [`MFeedPage`](../../web/src/mobile/pages/MFeedPage.tsx) at `/m/feed` (5-tab cursor-paginated feed with InfiniteScroll + PullToRefresh + SearchBar + a floating FAB compose sheet — body + visibility + category only; @mention picker and image upload deliberately stay desktop-only per the mobile RBAC posture) and [`MCelebrationDetailPage`](../../web/src/mobile/pages/MCelebrationDetailPage.tsx) at `/m/feed/celebrate/:id` (hero card + blessing wall + sticker grid modal + template shuffle). Both pages reuse the desktop [`celebrationTheme.ts`](../../web/src/pages/feed/celebrationTheme.ts) tokens (rose/amber gradients) and [`feedAssetsData`](../../web/src/pages/feed/feedAssetsData.ts) helpers (`pickStickerSet`, `pickBlessingTemplate`, `findSticker`) so brand language is identical across surfaces. URL rewriter [`mapDesktopPathToMobile`](../../web/src/lib/mobileDeepLink.ts) gained `/feed[/p/<id>]` → `/m/feed` and `/feed/celebrate/<id>` → `/m/feed/celebrate/<id>` mappings so Feishu IM celebration cards (which carry the desktop URL) deep-link directly into the mobile detail page. Apps catalog ([`apps_catalog.py`](../../server/app/mobile/apps_catalog.py)) gained a `feed` entry with `pinned_default=True`. `/help` employee tab gained a `feed` topic (`bodyEmployeeFeed`) documenting tabs, @mention, visibility, like/comment, celebration aggregation, blessing flow, birthday privacy, and the mobile entry point + limits. All new i18n keys ship in all three locales (zh-CN / en-US / ja-JP) under `mobile.apps.feed.*`, `mobile.feed.composer.*`, and `helpCenter.employee.feed.*`. 0 backend route / 0 migration / 0 cron / 0 schema / 0 RBAC role / 0 MCP-tool change.

Mobile shell caching + throttling (M-Mobile-PR16, 2026-05-24): the layout's `/m/aggregate/home` payload is already cached for 30s (`HOME_REFRESH_MIN_INTERVAL_MS`) and exposed via `<Outlet context={{ home, loading, refresh }}>`. PR16 wires `MReportsPage` (and going forward, any other tab that consumes admin counts) into that context via `useOutletContext<MobileHomeContextValue>()`, eliminating the duplicate `mobileApi.home()` round-trip per Reports-tab visit. `notificationsApi.summary()` (bell badge) is now throttled to a 15s sliding window with a forced refresh on the leave-`/m/notifications` transition, so the badge can never lag more than 15s behind reality and the deep PR8/9/10/14/15 drill-in pages don't burn one `/summary` call per pathname change. PullToRefresh was already uniform across every list page via the shared [`MAdminListShell`](../../web/src/mobile/components/MAdminListShell.tsx) component (which internally wraps content in `<PullToRefresh onRefresh={() => load()}>`) — PR8/9/10 list pages all consume the shell rather than rolling their own, so the earlier survey claim that "11 pages lack PullToRefresh" was a false positive (it grepped the individual page files instead of following the shell import).

`/m/*` error boundary (M-Mobile-PR13, 2026-05-24): [`MobileErrorBoundary`](../../web/src/mobile/components/MobileErrorBoundary.tsx) wraps the page `<Outlet />` inside `MobileLayout`. Uses antd-mobile `ErrorBlock` + `Button` so the fallback ships in the same chunk as the rest of the mobile tree (the desktop `components/ErrorBoundary.tsx` pulls antd + would defeat the bundle-isolation policy). When a page throws, the top-bar bell + RoleSwitcher + bottom-tab navigation stay mounted and the user can recover without a hard reload. `useAuthStore.logout()` also calls `useMobileWorkspaceStore.getState().reset()` (defense-in-depth fallback: direct `localStorage.removeItem('helix:mobile:workspace:v1')`) so multi-account / shared-device flows don't leave activeRole + pinnedAppKeys behind.

Mobile feedback layer (M-Mobile-IA-PR2, 2026-05-25): [`web/src/mobile/utils/feedback.tsx`](../../web/src/mobile/utils/feedback.tsx) is the single seam mobile code uses for toasts + confirm dialogs. `mToast` wraps antd-mobile `Toast` (success / error / info / loading / clear); `mDialog.confirm` wraps `Dialog.confirm` for plain confirms and falls through to `Dialog.show` with explicit `danger: true` action descriptors when the call site needs a destructive style (antd-mobile's built-in `confirm` doesn't expose per-action styling). Throw inside `onConfirm` keeps the dialog open — same semantics as antd-mobile upstream — so in-dialog validation can re-prompt without remounting the body. Three form-style Modals were rewritten with the same pattern: `MProvisioningTaskDetailPage.fail` and `MAdminLeaverOverrideDetailPage.decide` use `mDialog.confirm({ danger, content: <TextArea/> })` with a `useRef` buffer for the input value (avoids the focus-loss-on-rerender failure mode that a useState-in-content would trigger); `MMyInventoryPage` swaps the antd `Form` + `Radio.Group` for an antd-mobile `Popup` + `Selector` + `TextArea` with local useState. ESLint rule `no-restricted-imports` ([eslint.config.js](../../web/eslint.config.js), scoped to `src/mobile/**` + `src/pages/mobile/**`) blocks future `Modal` / `message` / `notification` / `App` imports from `'antd'` so the mobile chunk stays clean of desktop primitives. Desktop pages (`src/pages/<non-mobile>/*`) are unaffected. 9 pre-PR offender files were migrated in the same PR: `MAdminLeaverOverrideDetailPage`, `MMyInventoryPage`, `MAssetTransferDetailPage`, `MProvisioningTaskDetailPage`, `MAssetsPage`, `MMePage`, `MNotificationsPage`, `MTodosPage`, `MobileBootPage`. 0 backend / 0 migration / 0 cron / 0 RBAC change.

IM card URL rewriting ([`web/src/lib/mobileDeepLink.ts`](../../web/src/lib/mobileDeepLink.ts)): `FeishuMobileGuard` calls `mapDesktopPathToMobile()` before falling back to `/m` boot. This lets pre-existing notification cards whose `url` field points at the desktop route (`/admin/approvals/:id`, `/my/requests/:id`, `/admin/provisioning/tasks/:id`, `/me/inventory/:cycle_id`, `/notifications/...`) land on the mobile detail page without backend changes. Unmapped paths still fall back to `/m`. New mappings can be added as M-Mobile milestones port more pages. Because `FeishuMobileGuard` runs the redirect **only on initial entry** (one-time bounce policy), in-shell notification taps (`MHomePage` recent-inbox card + `MNotificationsPage` row click) ALSO route their `it.url` through `mapDesktopPathToMobile` before `navigate()` — otherwise a tap after the user has already entered `/m/*` would still dump them on the desktop layout because the guard treats the session as "already mobile" and lets the desktop path through (2026-05-28 — inventory notifications were the regression that surfaced this gap; the `/me/inventory/:id` mapping collapses to the list page `/m/inventory` since the mobile list filters by caller and the cycle name appears in the row).

UX state lives at [`useMobileWorkspaceStore`](../../web/src/mobile/stores/mobileWorkspace.ts): `activeRole` (perspective selected by RoleSwitcher, persisted to localStorage `helix:mobile:workspace:v1`) + `pinnedAppKeys` (user pin list, seeded from catalog `pinned_default`). `pickDefaultActiveRole()` chooses the highest-authority role (global_admin > management > domain admins > manager > employee) when the user hasn't picked one yet. The RoleSwitcher action handler (2026-05-28) calls `setActiveRole(role)` AND `navigate('/m/home')` to mirror the desktop `handleSwitchRole` contract — without the navigate, a user on `/m/admin/<page>` who picks "员工" would stay on the same admin page (the activeRole flips and the chip label updates, but the page body — already mounted under an admin route — doesn't); landing on `/m/home` lets the role-aware home cards + TabBar (both subscribed to `activeRole`) re-render in the new perspective.

List page URL state + history-aware back (M-Mobile-IA-PR3, 2026-05-25): mobile list pages used to hardcode `page=1, page_size=50` and keep SearchBar input in local `useState`, so tapping a row → detail → back reset the list to page 1 with the search field cleared. [`useListUrlState`](../../web/src/mobile/hooks/useListUrlState.ts) round-trips `page` / `page_size` / `q` (search) / declared filter keys through the URL search params (defaults are stripped so the URL stays clean; `setParams({ replace: true })` so typing in the SearchBar doesn't pollute history). [`MAdminUsersListPage`](../../web/src/mobile/pages/MAdminUsersListPage.tsx), [`MAdminCloudServersPage`](../../web/src/mobile/pages/MAdminCloudServersPage.tsx), and [`MAdminAssetsListPage`](../../web/src/mobile/pages/MAdminAssetsListPage.tsx) consume the hook; the other 10 list pages can pick it up one-line at a time. MobileLayout's top-bar back button now reads `window.history.state.idx` (React Router 6's history-stack pointer); non-zero → `navigate(-1)` so the browser restores the previous URL (and therefore the list page's `page` / `search` / filter state for free); zero → falls back to the hardcoded `backTo` path for deep links (IM card → detail page directly, no in-app history exists). Same PR: `unauthRedirectTarget()` ([feishuEnv.ts](../../web/src/lib/feishuEnv.ts)) now prefers path over UA — any pathname starting with `/m` redirects 401 failures to `/m` so browser-based H5 testers (no Lark UA) don't get bounced to desktop `/login` mid-session.

Vite chunk policy: `antd-mobile` + its exclusive transitive graph (`@react-spring/*` / `@use-gesture/{core,react}` / `staged-components`) land in a dedicated chunk (`antd-mobile-*.js`, ~143KB / 49KB gzipped after M-Mobile-1.2). Desktop visitors never download it.

Routing guards:
- [`MobileAuthRoute`](../../web/src/components/MobileAuthRoute.tsx) — sibling to `AuthRoute` but redirects unauth → `/m` (re-attempt silent SSO) instead of `/login`.
- [`FeishuMobileGuard`](../../web/src/components/FeishuMobileGuard.tsx) — top-level wrap around `<Routes>`; any in-Feishu-MOBILE UA visiting a non-`/m`, non-`/auth/feishu/callback` path gets `<Navigate to="/m">`. **Mobile-only by design** (iPhone / iPad / Android): the desktop Feishu client (macOS / Windows) also carries `Lark` in its webview UA, but its webview is 1280px+ wide and renders the desktop SPA fine — funneling desktop-client users into the bottom-tab mobile layout would make the experience visibly broken. The UA check is `isInFeishuMobile()` = Feishu marker AND mobile OS marker. Operator-side, Feishu open-platform 网页应用 should set 桌面端主页 = `https://helix.pingcap.net/` (desktop SPA) and 移动端主页 = `https://helix.pingcap.net/m` so the platform itself doesn't cross-wire entry URLs.

Mobile-aware post-auth navigation ([`web/src/lib/feishuEnv.ts`](../../web/src/lib/feishuEnv.ts), 2026-05-24):

`FeishuMobileGuard` uses a **one-time bounce policy** — it only redirects on the *first* entry; once the user has visited any `/m/*` route, the `helix:m:entered` sessionStorage flag is set and subsequent navigations are allowed (so users can deep-link into desktop admin pages we haven't ported to mobile). That policy is correct for user-initiated navigation but **wrong for system-initiated post-auth redirects**: after the OAuth round-trip the flag is already set, so a naïve `navigate('/dashboard')` from the Feishu callback page lands the user on the desktop UI inside the mobile webview.

Solution: `postLoginTarget()` and `unauthRedirectTarget()` helpers + `helix:m:logged-out` sessionStorage sentinel.

- `postLoginTarget()` — destination after a successful sign-in (returns `/m/home` on Feishu mobile, `/dashboard` elsewhere). Used by `FeishuCallbackPage` (success), `LoginPage` (password / Google success), `App.tsx` `<Route path="/" → Navigate>` + `/reports` redirect, `MainLayout.handleSwitchRole`, `ErrorBoundary` / `ForbiddenPage` / `NotFoundPage` / `ServerErrorPage` "back home" buttons, `PageHeader` breadcrumb home link.
- `unauthRedirectTarget()` — destination when auth is required but missing (returns `/m` for Feishu mobile so the boot page can retry silent SSO, `/login` elsewhere). Used by `AuthRoute`, `RoleRoute`, `MainLayout.handleLogout`, `apiClient` 401-refresh-fail redirect, `FeishuCallbackPage` error branches.
- `helix:m:logged-out` sentinel — `markLoggedOut()` written by `auth.logout()` (user-initiated only — `logout({ silent: true })` from the 401 handler skips this), `clearLoggedOut()` cleared by `auth.setAuth()` and `auth.initialize()` success. `MobileBootPage` reads `isJustLoggedOut()` to skip auto-`startOAuth` after explicit logout, otherwise Feishu silent SSO would re-auth the user in <1s and the logout button would feel broken.
  - When the sentinel is set, `MobileBootPage` renders a dedicated `'loggedOut'` state with no spinner + a "sign in again" button (versus the legacy loading state that left a perpetually-spinning indicator above a secondary login button — visually deceptive).
  - The OAuth callback's `?error=` / missing-code / verify-failure branches ALSO call `markLoggedOut()` before navigating so a user who cancels Feishu's consent screen lands on the loggedOut card instead of immediately re-firing OAuth (which would loop indefinitely if they cancel twice).

Backend deltas (see §2.3 entry 2a):
- `POST /api/v1/auth/feishu/h5-login {code}` — H5 SDK code → JWT, no `state`/no `redirect_uri`.
- `GET /api/v1/auth/feishu/app-id` — returns the configured Feishu `app_id` so the SDK can call `tt.requestAccess({appID})` without the frontend hardcoding it.

Feishu open-platform configuration (operator):
- Enable "网页应用" capability on the existing 自建应用 (the same one used for chat_approval + Feishu SSO).
- Set 主页 / 移动端主页 = `https://helix.pingcap.net/m`.
- The H5 SDK does not use OAuth redirect, so no new `redirect_uri` whitelist entries needed beyond the existing redirect-flow ones.

i18n: every new key under `mobile.*` is added in all three bundles (`zh-CN`, `en-US`, `ja-JP`) per the i18n parity invariant. Mobile pages are Feishu-only by design — non-Feishu mobile browsers fall through to `/login`.

Mobile-experience overhaul (W1, 2026-07-10; PRs land incrementally):
- **Notification deep-link survives login** (PR1): the iOS Feishu webview drops the refresh cookie, so every entry re-runs OAuth. That round-trip used to drop the target route (everything landed on `/m/home`). [`feishuEnv.ts`](../../web/src/lib/feishuEnv.ts) now exposes `stashPostLoginRedirect` / `consumePostLoginRedirect` (same-origin-rooted guard; refuses `//` + transient auth routes); `MobileAuthRoute` stashes `pathname+search` before bouncing to `/m`, `FeishuMobileGuard` stashes the raw desktop URL when a path has no mobile twin, and `postLoginTarget()` / `MobileBootPage` drain it after auth. Shares the `helix.postLoginRedirect` key with the desktop MCP-consent flow (one login round-trip per tab, single drain point).
- **`/m/*` 404** (PR1): [`MNotFoundPage`](../../web/src/mobile/pages/MNotFoundPage.tsx) as the last `path="/m/*"` child inside `MobileLayout` — unknown mobile paths render the antd-mobile shell, not the desktop `NotFoundPage` inside the webview. Path constraint keeps it from hijacking the desktop `path="*"`.
- **Deep-link map full coverage + unit test** (PR2): [`mapDesktopPathToMobile`](../../web/src/lib/mobileDeepLink.ts) now covers every backend notification emitter that has a mobile twin (access-review, hr-sync, admin asset detail, software-seats, cloud scope list, scope-requests, `/my/{on,off}boarding`→`/m/lifecycle`, `/my/todos`, `/admin/attendance?report=<id>`→`/m/attendance/<id>` query→path promotion). Emitters with no mobile page stay unmapped (desktop render post-login). **Vitest** was introduced here ([`web/vitest.config.ts`](../../web/vitest.config.ts) + `pnpm test` CI step); [`mobileDeepLink.test.ts`](../../web/src/lib/mobileDeepLink.test.ts) enumerates every emitter URL as a coverage guard + a `DESKTOP_FALLBACK` worklist for later PRs. Backend fix: apikey release-decision URL `/my/apikeys`→`/me/api-keys`.
- **Help-desk / 求助工单 H5** (PR3): net-new mobile surface for the support domain (previously zero `/m/*` routes — the "处理工单" Feishu card fell to home). Employee `/m/support` (list) + `/m/support/new` (create w/ deflection) + `/m/support/ai` (copilot) + shared `/m/support/:id`; IT workbench `/m/admin/support` (queue) + `/m/admin/support/tickets/:id` (same shared detail, agent mode by path prefix). All reuse `supportApi` — no backend change; KB authoring + analytics stay desktop via `MHandoff`. **Workbench polish (2026-07-10):** mobile queue tabs now match backend semantics (`open` / `resolved` / `all`) and the desktop-to-mobile deep link `/admin/support/tickets/:id` is preserved all the way to `/m/admin/support/tickets/:id`, so global admins can open, claim, reply, resolve, and reopen tickets on phone without falling onto a dead route. Status vocab in [`web/src/mobile/lib/supportStatus.ts`](../../web/src/mobile/lib/supportStatus.ts).
- **Lifecycle / cloud-request landing pages** (PR4): fills the remaining notification-deep-link gaps. Onboarding cockpit `/m/admin/onboarding` + process detail `/m/admin/onboarding/:processId` (mirrors the offboarding twin; onboarding per-blocker actions are skip + mark-resolved only, process actions reconcile / advance-now〔ready→live〕/ cancel; reads admit HR+admin, writes gated to global_admin). Employee `/m/permissions` (software permissions + quick links to cloud grants / API keys / assets). Cloud scope-request detail `/m/cloud/scope-requests/:id` and DNS-request detail `/m/cloud/dns-requests/:id` — read + requester-cancel/withdraw + admin recovery (DNS retry/resume); approve/reject + 4-eyes-bypass were originally desktop hand-offs on the belief that `require_browser_session` demanded a step-up the webview could not satisfy — **that premise was wrong and #1850 corrected the code**: the gate reads `request.state.auth_via == "jwt"` and nothing else (no UA / Origin / Referer / cookie check), so it distinguishes PAT from JWT, not browser from webview, and the OAuth-issued webview JWT passes it. Grant + scope approvals are decidable in-app today. (Sentence corrected 2026-07-28 — the stale claim had survived here and in the #1841 changelog entry long after the code changed, and was still being cited in reviews.) Deep-link map promotes the scope-request `?id=` query into the detail path; all reuse `onboardingApi` / `cloudLifecycleApi` / `cloudApi` / `permissionApi` — no backend change.

---

## 3. API envelope, middleware, tracing

### Standard response envelope ([`server/app/core/response.py`](../../server/app/core/response.py))

```json
{
  "success": true,
  "data": { ... },
  "meta": { "trace_id": "<uuid>", "timestamp": "<utc-iso8601>" },
  "errors": null
}
```

On error:

```json
{
  "success": false,
  "data": null,
  "meta": { "trace_id": "..." },
  "errors": [{ "code": "VALIDATION_ERROR", "message": "...", "field": "..." }]
}
```

### Trace-ID middleware ([`server/app/core/middleware.py`](../../server/app/core/middleware.py))

Reads `X-Trace-ID` header or generates a UUID. Stored in `request.state.trace_id`, echoed in response header. Every audit row, every workflow journal event, every Leaver Blocked rejection records this so end-to-end correlation works.

### Security headers / CSP ([`server/app/core/middleware.py`](../../server/app/core/middleware.py))

`SecurityHeadersMiddleware` stamps `X-Content-Type-Options` / `X-Frame-Options` / `Referrer-Policy` / HSTS on every response, plus a **default** `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` — right for JSON, but it makes browsers drop the inline `<style>` of any backend-served HTML page. Since 2026-06-10 the middleware only fills the CSP **when the endpoint didn't set one**: an HTML-emitting endpoint must ship its own page-appropriate CSP (see `_qc_page` in [`provisioning/router.py`](../../server/app/domains/provisioning/router.py) — `style-src 'unsafe-inline'; form-action 'self'`). If you add a new backend-rendered HTML page and it shows up unstyled, this is why.

### Exception model ([`server/app/core/exceptions.py`](../../server/app/core/exceptions.py))

`AppException(code, message, status_code, field)` is the base. Subclasses: `NotFoundError`, `ForbiddenError`, `UnauthorizedError`, `ConflictError`, `ValidationError`, `LeaverBlockedError` (409 with blocker IDs).

Two catch-alls in `main.py`:
- `RequestValidationError` → 422 with structured `errors[]`.
- Bare `Exception` → 500 with generic `INTERNAL_ERROR` (full stack logged server-side, never leaked).

### Idempotency ([`server/app/core/idempotency.py`](../../server/app/core/idempotency.py))

Redis-backed key store. Mutation endpoints accepting an `Idempotency-Key` header check + record; replays return the original response.

> **Key vs scope sizing (column limits — easy to trip)**: `IdempotencyRecord.key=String(128)`, `scope=String(64)`. Concatenating two UUIDs into the scope (`endpoint:{resource_id}:{actor_id}` = 92 chars) silently 500s on every call with "Data too long for column 'scope'". Pattern to fold the actor into the cache key for per-actor isolation:
>
> ```python
> namespaced_key = (
>     f"{idempotency_key}:{current_user.id}"
>     if idempotency_key is not None else None
> )
> idem = IdempotencyService(db, scope=f"offboarding.cancel:{process_id}", key=namespaced_key)
> ```
>
> Where `idempotency_key` is the raw client header. None-passthrough preserves the "no Idempotency-Key supplied → no caching" semantic. Used today on `POST /offboarding/processes/{id}/cancel` (added 2026-05-23) to keep two admins' identical client keys from sharing cached envelopes that embed leaver PII.

### Distributed advisory lock ([`server/app/core/distributed_lock.py`](../../server/app/core/distributed_lock.py))

Redis-backed `async with distributed_lock(key, ttl=...)` context manager. Use when an operation needs single-flight semantics across admin double-clicks / cron+manual overlap / multi-worker prod, but a MySQL row `FOR UPDATE` lock would be held across a multi-second cloud HTTP call (innodb_lock_wait_timeout 50s → batch sync deadlock).

Pattern: SET NX EX `<key>` `<random-token>`; release via Lua `if get == token then del`. Auto-expires via TTL on holder crash. Raises `LockBusy` from `__aenter__` (i.e. surfaces from `async with`, NOT from the bare call). Wrapping pattern is **always**:

```python
try:
    async with distributed_lock(f"some_key:{id}", ttl=600):
        ...
except LockBusy:
    ...handle 409 / no-op / skip...
```

Used today: `decommission_scope`, `approve_grant` / `reject_grant`, `revoke_grant` / `renew_grant`, `/scopes/{id}/sync-now` background dispatch (per-integration). Documented in detail in `cloud-lifecycle-ops.md` §locking.

**`auto_renew=True` + `extend_lock(key, token, ttl=…)`** (2026-07-08, #1754): for LONG critical sections that can exceed the TTL (e.g. a multi-minute identity-sync full sweep — a mapping change nulls every `last_payload_hash` → full re-push; SCIM worst case POST→409→GET→PUT ×N). A background watchdog CAS-re-extends the lock every `ttl // 3` s so it never lapses (which would let the next cron tick start a SECOND concurrent sweep on the same target → interleaved `identity_sync_state` writes / lost updates). If the lock is ever LOST despite renewal (Redis eviction / manual delete), the watchdog cancels the holder and the `async with` re-raises `LockBusy`, so the caller's busy path runs instead of writing under a lock someone else now holds. `extend_lock` is the standalone CAS-extend (Lua `if get==token then pexpire`). Wired at all four identity-sync sweep-path acquisitions (`run_due_targets`, `sync_person_to_target`, router manual trigger, MCP `identity_sync_run_target`) — all keyed on `target.slug` (2026-07-17: the background manual "Sync now" was fixed from a UUID key that put it in a different namespace and defeated single-flight vs the cron tick). Default is `False` — every existing caller is unchanged.

**Acquire-side reachability is the caller's problem, not the lock's** (2026-08-13). `distributed_lock` propagates a Redis error out of `__aenter__` unchanged — it deliberately does NOT swallow or retry, because "couldn't reach Redis" and "someone else holds this" must stay distinguishable (`LockBusy` is a correct outcome; a connect timeout is not). Resilience lives in two layers around it: the shared client retries the `SET NX` itself (see `core/redis_client.py`), and callers that can afford to wait add a bounded acquire retry of their own — `scheduled_jobs.service.execute_job` does exactly that (`_LOCK_ACQUIRE_ATTEMPTS`).

**`is_lock_held(key) -> bool`** (2026-07-17): non-blocking `EXISTS helix:lock:<key>` probe. A live `auto_renew` holder keeps the key alive its whole critical section (only a hard-kill lets the TTL lapse), so "key present" reliably means "a worker is actively inside the section now." Used by reapers that must distinguish a genuinely-orphaned in-progress row from a legitimately long-running one — `identity_sync.cleanup_stale_runs` skips finalising a stale `running` row whose target lock is still held.

---

## 4. Integration framework + driver registry

### Shape

A single `integrations` table holds every external system. Each row is keyed by `(kind, slug)`:

| Column | Purpose |
|--------|---------|
| `id`, `slug`, `display_name`, `kind`, `provider` | identity |
| `enabled` | hard switch |
| `config_public` (JSON) | non-secret fields |
| `secrets_encrypted`, `secrets_nonce` | AES-GCM with `CREDENTIAL_ENCRYPTION_KEY` |
| `capabilities` (JSON) | admin-selected subset of driver's `supported_capabilities` |
| `last_tested_at`, `last_test_status`, `last_test_message` | health |
| `binding_config` (JSON) | per-instance driver knobs (e.g. cloud account ID, default region) |

The migration that unified everything into this table is [`027_unified_integrations.py`](../../server/alembic/versions/027_unified_integrations.py); cloud providers were merged in by [`046_merge_cloud_provider_into_integrations.py`](../../server/alembic/versions/046_merge_cloud_provider_into_integrations.py); the legacy `cmdb_cloud_accounts` was dropped in [`053_drop_cmdb_cloud_accounts.py`](../../server/alembic/versions/053_drop_cmdb_cloud_accounts.py).

### Driver kinds

Defined in [`server/app/integrations/driver_base.py`](../../server/app/integrations/driver_base.py):

- `IntegrationDriver` (abstract): `kind`, `provider`, `display_name`, `config_schema`, `binding_schema`, `test_connection() → IntegrationTestResult`. Constructor accepts an optional `enabled_capabilities: list[str]` kwarg — the per-instance set the admin selected on the integration row (read from `integrations.capabilities`, threaded in by `_build_driver_from_resolved`). When omitted, falls back to the driver-class default `capabilities` list. Diagnostic paths (notably `_CloudDriverBase.test_connection`) consult `self.enabled_capabilities` to probe exactly what the admin opted into — not the driver-class superset. Class flag **`archival: bool = False`** (2026-07-14) marks a decommissioned tenant that exists only to render migrated historical rows under a real name (no credentials / no sync / no capabilities); `registry.archival_providers()` returns the set of such provider strings, and active-management surfaces (the half-yearly cloud-server owner confirmation in [`server_owner_confirmation_service.list_due_owners`](../../server/app/domains/cmdb/server_owner_confirmation_service.py)) exclude servers under these tenants so owners aren't nagged to confirm machines the company no longer runs. Any future archival tenant is excluded automatically by setting the flag — no per-surface provider list.
- `HrisDriver`: `list_active_users`, `list_hires_in_range`, `list_terminations_in_range`, optional `get_users(ids)`, `get_jobs_for_users(ids)`.
- `ChatApprovalDriver`: `submit_approval(ApprovalSubmission) → ApprovalSubmissionResult`, `verify_webhook(body, headers)`, optional `cancel_approval`, `resolve_user_id_by_email`.
- `CloudDriver`: see §13.
- `SoftwareProvisioningDriver`: `grant_seat`, `revoke_seat`, `grant_permission`, `revoke_permission`. Flags: `supports_seat`, `supports_permission`, `requires_successor_on_revoke`.
- `EmailDriver`: `send(EmailMessage) → EmailSendResult`.
- `MdmDriver` (Apple device MDM, added 2026-06-26): read surface `list_devices() → list[MdmDevice]`, `get_seat_usage() → MdmSeatUsage` (seat == managed device); action surface `lock_device` / `wipe_device` / `clear_device_assignment` (offboarding lock/erase/unbind, each raises `NotImplementedError` unless the driver ships it so a missing command never silently no-ops).

### Cloud driver health-check (`_CloudDriverBase.test_connection`)

Per-capability dry-run (2026-05-17, [`_cloud_shared.py`](../../server/app/integrations/drivers/_cloud_shared.py)). Pre-fix the test only ran `list_servers`, so an integration whose `object_storage` capability returned 100% `auth_error` could still pass with `OK · enumerated 692 servers`. Now:

- `_CAPABILITY_PROBE_METHOD` maps capability strings → `CloudProvider.list_X` attribute names (`servers` → `list_servers`, `object_storage` → `list_object_storage`, `iam` → `list_iam_users`, etc.).
- `_CAPABILITY_PROBE_EXCLUDE` keeps money-cost (billing / billing_daily / cost_facts) and write-side (`user_revoke`) capabilities OUT of the probe loop — clicking 测试 must not burn BigQuery dollars or trigger an offboarding call.
- Each probe is bounded by `_CAPABILITY_PROBE_TIMEOUT_S=60` and snapshots `provider.partial_failures` between calls so one cap's legs don't leak into the next.
- Result classifier: `failed` when zero rows + any leg failed (the structural-impossibility case the old single-probe path used to hide), `partial_failure` when some rows + some legs failed, `ok` clean, `skipped` for not-implemented / not-supported caps.
- Aggregated `IntegrationTestResult.detail.capability_probes: {cap: {status, sample_count, leg_count, partial_failures, reason}}`; legacy `sample_server_count` / `leg_failures` keys preserved for callers / tests that haven't been updated. Top-level `ok = not any(failed)`.
- Frontend ([`IntegrationsPage.tsx` `TestResultModal`](../../web/src/pages/admin/IntegrationsPage.tsx)) opens a Modal with a per-capability table when `capability_probes` is present (cloud drivers); other driver kinds keep the toast UX.

### Driver registry ([`server/app/integrations/registry.py`](../../server/app/integrations/registry.py))

`@register_driver` fires at import time. Every driver module is imported by [`server/app/integrations/drivers/__init__.py`](../../server/app/integrations/drivers/__init__.py) — adding a new provider is "create a module + add the import line", no enum / migration / config-center edit.

**Both entrypoints must touch the package.** API process imports it from [`app/main.py:12`](../../server/app/main.py:12) at module top. Worker process [`app/worker.py::_run()`](../../server/app/worker.py) imports it right after `_load_all_models()` — same idempotent-side-effect pattern as model preload. Prior to PR #507 (2026-05-26) the worker reached the package by accident: coordinator modules (`zoom_seat_coordinator` / `feishu_seat_coordinator` / …) declare `from app.integrations.drivers.X import Y` at module level, so the FIRST time a handler lazily imported a coordinator the chain pulled `drivers/__init__.py` and every driver registered. Worked on long-lived workers (something always triggered a coordinator first); broke on fresh boots after image-bump deploys — the `cmdb.resource_creator_sweep` cron (02:00 / 14:00 UTC) firing before any coordinator-importing handler tripped `INTEGRATION_DRIVER_UNKNOWN` for every candidate. Pinned by [test_worker_run_imports_integration_drivers_explicitly](../../server/tests/test_worker_import_order_2026_05_13.py).

### Shipped drivers (30 registered)

> **The count is derivable — derive it, don't trust this line.** On
> 2026-08-29 this heading said 26, the table below listed 27 rows, and
> `len(list_drivers())` answered 29. Three numbers, no two of them equal,
> and a reader trying to say "the platform ships N connectors" had no way
> to know which one was true. One command settles it:
>
> ```
> cd server && python -c "import app.integrations.drivers; \
>   from app.integrations.registry import list_drivers; print(len(list_drivers()))"
> ```
>
> Note the `import app.integrations.drivers` — importing only the registry
> yields **0**, because registration happens as an import side effect.

| Driver | Kind | Vendor | Capabilities |
|--------|------|--------|-------------|
| `aliyun_cloud` | cloud | Alibaba Cloud | servers, domains, dns, cdn, object_storage, user_revoke, billing, **cost_facts** (BSS DescribeInstanceBill, line-item; CNY→USD via [`app.core.fx_rate.resolve_cny_per_usd`](../../server/app/core/fx_rate.py) — platform `ai_spending.cny_to_usd_rate` is the single source of truth, optional per-integration `config_public.cny_per_usd` override; declares `requires_cny_fx=True` so the cost-ingestion service resolves the rate once and passes it in via the `cny_per_usd` kwarg), scopes, roles, grants, account_close |
| `alicloud_intl_cloud` | cloud | Alibaba Cloud Intl | (subclass of aliyun) + IAM capability (added in 067). **CloudSSO `revoke_access` override (2026-05-14)**: SSO-discovered grants carry `external_ref="sso::{access_config_id}::{user_id}::{target_id}"` (or `…::group::{group_id}` for group-expanded synthetic rows). The inherited RAM-based parent revoke parsed `sso` as `UserName` + `:<rest>` as PolicyName → Aliyun `InvalidParameter.PolicyName.InvalidChars` (cycling 58× on P0092 offboarding). The override routes to CloudSSO `DeleteAccessAssignment` for direct User refs with `DeprovisionStrategy=DeprovisionForLastAccessAssignmentOnAccount`, and to `RemoveUserFromGroup` for `::group::` synthetic rows (so per-person offboarding doesn't revoke the assignment for every other group member). Missing SSO directory + `AccessAssignmentNotExist` → idempotent `already_absent`. Non-`sso::` refs fall through to parent. Parent gained a defensive guard that surfaces a clear routing error if an `sso::` ref reaches it (driver mis-registration). Pinned by 6 unit tests in [test_alicloud_intl_sso_grants.py](../../server/tests/test_cmdb/test_alicloud_intl_sso_grants.py). |
| `aws_cloud` | cloud | AWS | servers, domains, load_balancers, cdn, object_storage, user_revoke, billing, billing_by_dimension, **cost_facts** (**dual-source via `config_public.billing_source`** — `"bigquery"` for the PingCAP main AWS Org, `"cost_explorer"` default for AWS SG / single-account IAM-mode integrations. **BigQuery CUR path (2026-05-15, all three billing surfaces — cost_facts / billing_daily / billing_by_dimension)**. **CUR 1.0 vs 2.0 dual layout (2026-05-19)**: the driver supports both per-account-table EXTERNAL CUR 1.0 (legacy `gcp-digital-bi.aws_nonprod_billing`) and per-month-table native CUR 2.0 / Data Exports (current `gcp-digital-bi.aws_billing_no_payer`) via two independent config switches — `billing_bq_table_layout` (`per_account` | `per_month`) selects table-resolution / partition-pruning / scope-filter strategy, and `billing_bq_tag_schema` (`flat` | `nested`) selects how user-defined tags are projected. **A third switch `billing_bq_fact_grain` (`per_product` default | `per_resource`) controls cost_facts aggregation grain** — `per_product` NULLs `line_item_resource_id` in SELECT regardless of layout / tag_schema / column-presence, collapsing all rows of the same product into one fact with synthetic `aws-account:<acc>:<product>` id (~hundreds of facts per scope per day); `per_resource` (opt-in) keeps real `line_item_resource_id` in GROUP BY for per-resource chargeback (~thousands of facts per scope per day). Default was flipped to `per_product` after the 2026-05-19 prod incident where CUR 2.0 cutover with implicit per_resource produced 66k facts for a single 3-day window on one scope (272× CUR 1.0), exhausting TiDB upsert throughput and wedging `cmdb.cost_facts_sync` after 41/101 scopes. **Robustness layer**: `cost_service.sync_cost_facts_for_integration` wraps each scope's `driver.list_cost_facts` call in `asyncio.wait_for(..., timeout=_COST_FACTS_PER_SCOPE_TIMEOUT_SEC=300s)` so a single hung scope records `status='timeout'` + an errors[] line and the cron continues to the next scope rather than wedging the whole run in `running` state. Bounded by the BQ-side `job_timeout_ms = 5min` cap in `_bq_query_job_config` so orphan-thread lifetime stays inside the same 5-minute envelope (the asyncio cancel doesn't reach the `asyncio.to_thread` worker directly). `per_month` enumerates `aws_billing_{yyyymmdd}` tables in the requested window, injects `WHERE line_item_usage_account_id = @scope_acct` for scoped queries, prunes via native DAY partition on `line_item_usage_end_date`. `nested` translates each tag key into a correlated subquery against `resource_tags.key_value` ARRAY<STRUCT<key,value>> (element key carries the `user_` prefix, e.g. `user_owner`); CUR 2.0 also nests `product_*` fields (`product_name` / `region` / `instance_type_family`) inside `product.key_value`. CUR 2.0 schema is uniform across every per-month table so the per-pick schema-drop logic the flat path needs (PR #311 follow-up) is a noop. Originally reads from CUR-in-BQ external Parquet tables `<dataset>.<account>_aws_billing_data` (legacy `aws_nonprod_billing.*`, ~60 tables, one per linked account); current prod points at CUR 2.0 dataset `gcp-digital-bi.aws_billing_no_payer` (~8 per-month tables since 2025-10). Triggered by prod `cmdb.cost_facts_sync` failing with `AccessDeniedException ce:GetCostAndUsageWithResources` — the IAM user `arn:aws:iam::317766989874:user/PingCAP` never had CE permissions, while the CUR has been landing in BQ for the same accounts since pchub built its FinOps pipeline. Three new config fields (`billing_bq_dataset` = `project.dataset`, `billing_bq_credentials_integration_id` = UUID of the Helix GCP cloud integration whose SA holds `bigquery.dataViewer` on the dataset, `billing_bq_table_pattern` = `{account_id}_aws_billing_data`) — SA credentials live exclusively on the GCP integration (single rotation point); [`lifecycle_service._resolve_aws_bq_billing`](../../server/app/domains/cmdb/lifecycle_service.py) decrypts the SA JSON and threads a `bq_billing` kwarg through to the driver, keeping AwsCloudDriver session-free (same pattern as `cny_per_usd`/`principal_tag_keys`). Partition pruning via the Hive `year`+`month` int columns enumerated by `_bq_month_predicate`; tag-allocation surface is `resource_tags_user_*` (e.g. `_owner`, `_application`, `_environment`, `_project`, `_department` materialise as `CostFact.tags`); per-scope queries hit one table, tenant-wide queries discover tables via `INFORMATION_SCHEMA.TABLES`. Missing-table accounts (CUR export hasn't onboarded the account yet, Org master itself) gracefully skip with `[]` rather than failing the run. `principal_tag_keys` mode + BQ mode are mutually exclusive (CUR has no `iamPrincipal_*` column) — set one or the other; configure mismatch raises `RuntimeError`. **Per-account schema heterogeneity (2026-05-15 follow-up, post-PR #311)** — the ~60 per-account CUR Parquet tables do NOT share a uniform schema: some accounts' CUR was created without "Include resource IDs" (no `line_item_resource_id`), and user-defined `resource_tags_user_*` columns are present only when a resource in that account was ever tagged with the key. Generalising a single-table INFORMATION_SCHEMA probe (as PR #311 originally did) hard-fails on the other tables with `400 Unrecognized name: <col>`. Two driver helpers handle this: `_bq_table_columns(table_obj)` reads `Table.schema` from the existing `client.get_table(table)` round trip (free, per-scope path), `_bq_resolve_table_columns(client, dataset, table_names, columns_of_interest)` runs one bulk `INFORMATION_SCHEMA.COLUMNS` lookup for multi-table UNION paths. `_list_cost_facts_bq_sync` projects `line_item_resource_id` only when present (falls back to the `aws-account:<acc>:<product>` synthetic id used for NULL-resource_id rows) and filters the tag-column harvest list to columns actually in the table. `_list_billing_daily_bq_sync` skips the owner_breakdown UNION leg when the scoped account's CUR lacks `resource_tags_user_owner`. `_list_billing_by_dimension_bq_sync` drops tables that lack ANY requested tag column (DIM or FILTER) from the UNION — the NULL-substitution alternative would silently merge "tag unobservable because column missing" rows with "tag legitimately empty" rows, which is unacceptable for cost-attribution honesty. **Totals divergence caveat** (R2 review surface): tenant-wide tag-DIM queries (`/cmdb/cost/breakdown?group_by=tag:Owner`) will SUM strictly less than the corresponding daily totals for any tenant whose CUR tables have heterogeneous tag schemas — a linked account whose CUR lacks the requested tag column contributes nothing to the breakdown (including its non-tag-related cost). Daily totals from `_list_billing_daily_bq_sync` are NOT affected (it only filters owner_breakdown by column presence; the day-totals SELECT references only core CUR columns). Pinned by [test_aws_cloud_bq_billing.py](../../server/tests/test_integrations/test_aws_cloud_bq_billing.py) (61 tests including degradation-path coverage for absent resource_id / absent tags / absent owner / tag-dim drop-table / unsatisfiable tag-filter / combined-axes worst case / validator charset rejection / `_bq_resolve_table_columns` invariants). **Cost Explorer path (default, still required for SG)**: `GetCostAndUsageWithResources`, ≤14d window; scope=None raises NotImplementedError — no STS fallback; **non-USD `Unit` zeros `amount_usd` (2026-05-10)** — AWS China partition (Sinnet/NWCD) returns `Unit=CNY`, pre-fix the driver dropped raw amount into `amount_usd` regardless and silently inflated USD totals; AWS is a USD-native driver — non-USD rows now persist `amount_native` for native-currency analytics with `amount_usd=0`, mirroring GCP's NULL `cost_in_usd` fallback; **IAM principal-tag mode (2026-05-10)** — when `platform_settings.cost_facts.aws_principal_tag.<integration_slug>` is set (string OR JSON list), the driver switches from per-resource to per-(date × service × tag-value) granularity via `GetCostAndUsage` with `GroupBy=[SERVICE, TAG:iamPrincipal/<key>]` and emits one fact per cell with `tags[<key>]=<owner>` so the cost-allocation rule ladder can attribute each cell to a specific person; used for AWS SG / Bedrock-only accounts where on-demand `InvokeModel` has no taggable billable resource — the foundation model itself is shared across customers, so resource-level tags don't reach Cost Explorer, but **IAM principal tags** (once activated in Billing Console → Cost allocation tags) DO surface under `iamPrincipal/<Key>` (the `aws:PrincipalTag/<Key>` namespace is the IAM-policy evaluator's dimension — silently rejected by CE GroupBy, returning empty owner values and collapsing every row into `(unallocated)`; that confusion was the 2026-05-12 production audit's root finding — fixed in follow-up to PR #265); synthetic `resource_id="aws-principal:<key>=<owner>|service=<svc>"` keeps the natural-key hash unique per cell so re-ingestion converges; untagged spend folds onto `(unallocated)` so principals without an `Owner` tag still surface as a chargeback-ladder unallocated row instead of disappearing; migration 142 seeds `cost_facts.aws_principal_tag.aws-sg = "Owner"` for the PingCAP AWS SG account), sso_admin, scopes (Organizations), roles (Identity Center), grants, account_close |
| `azure_cloud` | cloud | Azure | servers, dns, cdn, object_storage, user_revoke, billing, billing_by_dimension, **cost_facts** (Cost Management Query w/ ResourceId grouping; non-200 raises; **429 / 5xx now retried w/ Retry-After (2026-05-11)** — pre-fix the driver raised on the first 429, and Azure Cost Management's per-scope + per-clienttype + per-tenant throttles fire freely across sequential subscriptions in the same tenant; one 429 cascaded into a `partial_failure` covering every sub in the run (observed 2026-05-11 03:35 / 08:15 / 09:17 on the Azure Foundry tenant — 3 of 4 subs throttled in a single sweep). Now honours `Retry-After` + `x-ms-ratelimit-microsoft.costmanagement-{entity,clienttype,tenant}-retry-after` headers (picks the LARGEST cooldown — undercutting the server-stated wait guarantees another 429), retries up to 5 times with 90s ceiling per wait; falls back to exponential `2^attempt` capped at 60s when no header present. After 5 attempts the original raise-RuntimeError path surfaces the per-scope failure to the cron's `scheduled_job_runs.errors` so a permanently-throttled scope can't wedge the run forever. Pinned by 3 new tests in [test_azure_cloud_driver.py](../../server/tests/test_integrations/test_azure_cloud_driver.py): honour Retry-After, prefer largest cooldown, give up after 5 retries. **Transport-layer retry + module-wide `_post_with_retry` helper (2026-05-23)** — the 429/5xx retry was only at the HTTP-status layer; pre-fix the loop never reached on `httpx.ConnectError` / `ReadTimeout` / `RemoteProtocolError` / `PoolTimeout` / `WriteError` / `ConnectTimeout` / `ReadError` (the request was never sent or socket cut before a status was returned), and one transport flake dropped one scope's daily ingest. Hit prod 2026-05-21 + 2026-05-22 on the same Azure subscription (PingCAP Azure OpenAI, `7bc8ab17-…`) — 2 consecutive nights, `ConnectError: All connection attempts failed`, while `resource_sync` on the same sub succeeded (transport-only, not auth/quota). Fix: extracted a module-level `_post_with_retry(client, url, *, json|data, headers, endpoint_label, max_attempts, parse_retry_after)` that catches the umbrella `_TRANSPORT_RETRY_EXCEPTIONS` tuple alongside the existing 429/5xx logic in one loop, exp backoff `min(2^attempt, 60)` for transport (no Retry-After yet), Retry-After-honouring for status; both share the 5-attempt budget. **`_MAX_HONOURED_RETRY_AFTER_SEC = 90` ceiling**: when the server states Retry-After > 90s, the helper short-circuits and surfaces the response to the caller (caller raises with the real body) — burning 5×90s on guaranteed-throttled retries was strictly worse than letting the cron try tomorrow with one clean "Retry-After=600s" line in the failure-notifier. Helper applied to all 4 Azure outbound POST sites in this driver: `_token` (AAD), `list_cost_facts`, `list_billing_daily`, `list_billing_by_dimension`. **Client-side pacing on Cost Management (2026-09-05)** — the retry loop above made throttling *survivable* but never stopped it happening: prod worker logs for 2026-09-04 show **83 consecutive `429 … sleeping 26-58s` lines, one per subscription**, i.e. EVERY sub 429s on its first CM query and only succeeds after sleeping out the server-stated cooldown (71 that day; 12 of them in the ordinary 20:36 daily cron, the rest in the `cost_facts_monthclose` fan-out — ~80% of a 36-scope chunk's 12-25min wall clock is throttle sleep). Two defects: (a) a 429 with **no** Retry-After header restarted the exponential at 1s, so sub `7f070020` slept 56s → 2s → 57s → 8s and spent 2 of its 5 attempts on requests that could not clear a ~60s window — that is how it exhausted its budget and failed the run; (b) nothing carried a learned cooldown across scopes even though `cost_service` reuses ONE driver instance for all of them, so scope N+1 always fired into a limiter already known to be closed. Fix: `_AzureThrottlePacer` (one per driver instance, shared by all three CM call sites via `pacer=self._cm_pacer`) learns the server-stated cooldown, arms a consuming gate before the next request, and applies **AIMD** — halve the spacing after every retry-free success (`_CM_PACE_DECAY = 0.5`), so one hostile minute can't pin the remaining 35 subs at 45s each and make the run slower than the 429-per-scope behaviour it replaces. Header-less 429s now fall back to the last learned cooldown (floor `_CM_MIN_THROTTLE_BACKOFF_SEC = 20`; observed band is 26-58s) instead of the exponential. New `throttle_budget_s` caps CUMULATIVE retry sleep per call at `_CM_THROTTLE_BUDGET_SEC = 200` — the old per-sleep-only 90s ceiling let 4 × 90s overrun `cost_service`'s 300s `_COST_FACTS_PER_SCOPE_TIMEOUT_SEC` and get cancelled mid-sleep, recording the useless `status='timeout'` instead of an honest 429. Pacing is **scoped to Cost Management**: `_token` passes no pacer and keeps the plain 1s → 2s ramp (pinned by a test); a throwaway non-pacing instance keeps the single code path honest for every other caller. The raised 429 now carries `throttle_headers=…` (`_throttle_headers_diag`, `none` when absent) because Azure's 429 body is content-free — answering "did the server even state a cooldown?" for the 2026-09-04 row required a container-log dig. 🔴 **Proactive pacing ships OFF, behind the `cost_pacing_enabled` integration flag — measured, not assumed.** The post-deploy verification tick on 2026-09-05 (04:20 UTC, manual) **hit the job's 1800s timeout after only 27 of 36 subscriptions** against a 587-775s baseline: 88 CM queries, 34 × 429, 0 give-ups, and `pacing budget spent` never logged (⇒ pacing added <600s; the 1224s of ordinary reactive 429 backoff dominated), with server-stated cooldowns flat at 34-38s. Under that limiter pacing did not prevent the 429s, it only spent headroom. The limiter was partly hot from an earlier attempt in the same hour, so this is not proof pacing is harmful — it is proof the wall-clock-neutral model can be wrong, on a cron that cannot test that nightly. What ships ON is the part the incident needed and that cannot cost wall clock (header-less-429 learned fallback, which works with pacing off because `observe_throttle` always learns; the budgets; the diagnostics). Evaluate pacing by enabling it on ONE integration and comparing a week of run durations + `grep -c 'Azure HTTP 429'` on a quiet limiter. Two budgets keep the cure from becoming the disease: `throttle_budget_s` (200s cumulative RETRY sleep per call, sized to stay inside the 300s per-scope timeout) and `_CM_PACE_RUN_BUDGET_SEC` (600s of PROACTIVE pacing per driver instance = per cron run — the daily job inherits the executor's 1800s default and runs 587-775s today, so a wrong model of Azure's limiter must not be able to time it out; past the budget the driver reverts to the pre-pacing reactive behaviour and logs once). A give-up on 429 still calls `arm_learned()` — the limiter is closed at that moment, so holding the shared gate is what stops one throttled scope cascading into the rest of the run. The learned cooldown is used as a fallback for **429 only**: a 5xx is not a throttle and keeps the fast exponential. Pinned by 7 mutation-verified tests in [test_azure_cloud_driver.py](../../server/tests/test_integrations/test_azure_cloud_driver.py). **Gotcha for anyone tuning these**: the pacer's ceilings are default arguments, bound at def time — `monkeypatch.setattr(module, "_CM_PACE_RUN_BUDGET_SEC", …)` silently does nothing; pass `pace_run_budget_s=` explicitly. `_token` is the highest-leverage site — a transport flake there cascaded every cost call in the cron tick (38/38 fail instead of 1/38). Other Azure driver paths (IAM grant/revoke, Foundry provisioning) live outside `_post_with_retry` by design — they have provisioning-queue / offboarding-transfer / Temporal orchestrators that retry the whole flow with richer context; wrapping inside the driver would double-retry. **Other cloud drivers**: confirmed not affected by audit — AWS / GCP / Tencent / Aliyun / UCloud all delegate to vendor SDKs (boto3 / google-cloud-bigquery / TencentCloud / Aliyun / UCloud) which have built-in transport-error retry; Azure was the only driver using raw httpx for cost paths. Pinned by 10 new tests (transport retry on `list_cost_facts` for `ConnectError` / `ReadTimeout` / `PoolTimeout` / exhaustion-after-5, `list_billing_daily` ConnectError retry, `list_billing_by_dimension` `RemoteProtocolError` retry, `_token` ConnectError retry incl. cache-write verification, defensive fallback when `parse_retry_after` raises, transport-then-429-then-success mixed path, Retry-After > 90s ceiling skip)), **billing_daily** (Cost Management ActualCost query, daily granularity, ServiceName grouping; **non-200 raises (2026-05-10)** — was `return []`, advancing `last_seen_billing_daily_at` and feeding stale 7-day averages into spike-detection while `scheduled_job_runs.errors` stayed silent; symmetric with `cost_facts` posture; **transport retry parity (2026-05-23)** via shared `_post_with_retry`), scopes (subscriptions), roles, grants, subscription cancel |
| `gcp_cloud` | cloud | GCP | servers, dns, cdn, object_storage, user_revoke, billing, **cost_facts** (BigQuery export with resource.name + sku + labels; **USD via `SAFE_DIVIDE(cost, currency_conversion_rate)` (2026-05-10)** — earlier driver versions referenced `SUM(cost_in_usd)` as if GCP's export carried that column, but `cost_in_usd` is an AWS CUR field; GCP's standard / detailed export only exposes `cost` + `currency` + `currency_conversion_rate` (USD → local rate at billing time, so USD = local / rate, equals `cost` for USD-billed accounts); pre-fix every per-scope cost-facts sync hard-failed with `400 Unrecognized name: cost_in_usd at [25:19]`, now SAFE_DIVIDE returns NULL on a 0/NULL rate so a single bad export row contributes nothing to the USD sum instead of crashing the whole batch; non-USD billing → amount_usd=0 fallback retained; **net-of-credits (2026-06-06)** — GCP's `cost` column is the pre-credit list amount; SUD/CUD discounts + committed-use credits + promotions ride inline as negative `credits[].amount` (not separate rows like AWS SavingsPlanNegation), so both `list_cost_facts` and `list_billing_daily` now `UNNEST(credits)` and emit `amount_native`/`amount_usd` = gross + credits (true net); pre-credit gross + the netted credit amounts are preserved in `CostFact.raw` (`gross_native`/`gross_usd`/`credit_native`/`credit_usd`) for invoice reconciliation, no schema change; the AI-spending dashboard's Vertex/GCP figure was previously gross-mislabelled-as-net; **multi-billing-account orgs** ship one export table per billing account — driver splits comma/whitespace-separated `billing_bq_table` config and `UNION ALL`s with an outer `GROUP BY` to merge any project-overlap during a billing-account migration; same shape-validator (`_validate_bq_table_shapes`) as the daily-billing path so an admin-config compromise can't inject backticks/SQL through either query template; **standard-vs-detailed export auto-detect (2026-05-10)** — GCP exports come in two flavours, *standard* (`gcp_billing_export_v1_<BA>`, no `resource` STRUCT) and *detailed* (`gcp_billing_export_resource_v1_<BA>`, adds `resource.name` + `resource.global_name`), and the driver was unconditionally referencing `resource.*`, so any standard-export tenant hit `400 Unrecognized name: resource at [21:22]` and every per-scope sync hard-failed; `GcpProvider._is_detailed_export` now detects flavour by table name and `_list_cost_facts_sync` substitutes `'' AS resource_name, '' AS resource_global` on standard tables so the UNION ALL stays type-aligned and standard tenants still get day×project×service×sku×region granularity; **`list_billing_daily` multi-billing-account split (2026-05-10)** — direct parallel of the PR #232 cost-facts fix: pre-audit the daily path interpolated comma-separated `billing_bq_table` raw into `` FROM `{table}` ``, hard-failing every multi-account org with `400 Invalid table name`; now splits + shape-validates + UNION ALLs identically, completing the parity between the two paths that share the same config field), scopes (projects), roles, grants, projects.delete |
| `tencent_cloud` | cloud | Tencent | servers, user_revoke, IAM mode (single-tenant `standalone_iam`), billing, **cost_facts** (DescribeBillDetail line items via `_tencent_throttle.py` token bucket; mid-pagination errors raise; CNY→USD via the same shared [`app.core.fx_rate`](../../server/app/core/fx_rate.py) helper as Aliyun — `requires_cny_fx=True`). **CAM phone identity (2026-08-13)**: new CAM sub-users receive the HRIS mobile in `CloudAccessContext.extra`, strictly normalized with shared `core.phone` and sent to CAM as national `PhoneNum` plus `CountryCode`; an empty, unmappable, or country-mismatched number is omitted rather than guessing a verification factor. This is required for CAM's API-key identity-verification flow, while existing users are intentionally not modified by a repeat grant. **Calendar-month window split (2026-06-03)**: `DescribeBillDetail` is the ONLY billing API that rejects a cross-month `BeginTime`/`EndTime` (`InvalidParameterValue: 不支持跨月查询`, returns 整月数据 per call); the daily cron's default `today-3..today-1` window straddles the month boundary on the 2nd/3rd of each month and failed at `offset=0` for the whole Tencent scope. Module-level `_split_by_calendar_month(start, end)` slices any window into per-natural-month segments; both `_list_billing_daily_sync` + `_list_cost_facts_sync` page each segment independently and merge, with the existing per-day filter trimming each month's over-fetched rows back to the requested window (page-cap now per-month). Pinned by [test_tencent_billing_cross_month.py](../../server/tests/test_integrations/test_tencent_billing_cross_month.py) (5 tests: helper cross-month/cross-year/in-month/single-day invariants + driver billing_daily & cost_facts split-and-merge asserting no request spans two months). **SDK throttle (2026-05-09)** + **revoke_account Page/Rp gap fix (2026-05-09)**: `_revoke_account_sync`'s policy-detach + group-leave loops dropped `Page=N; Rp=200` parameters that Tencent's CAM API rejects with `UnknownParameter` (same field-name-mangling bug as `ListUsers`); pre-fix the outer `except TencentCloudSDKException: logger.warning(...)` swallowed the failure, leaving every Tencent CAM leaver with their access intact and step-5 `DeleteUser(Force=1)` blocked by residual attachments. Now single-call (200 max — covers any tenant), with an explicit warning logged if `TotalNum > returned`. The same outer except blocks now re-raise on `RequestLimitExceeded.*` so a throttle-exhaustion in any of the 5 revoke steps surfaces as a real `ProvisioningResult(ok=False, error_message="throttle exhausted...")` instead of falling through to a misleading "DeleteUser failed: residual attachments" symptom. The throttle covers every Tencent v3 SDK call (CAM / billing / CVM / CLB / CDN / DNSPod / 域名注册); the COS S3-compatible client (`cos-python-sdk-v5`, used in the IAM-side `list_buckets()` enumeration) is a separate SDK family with its own rate-limit codes (`SlowDown`, `TooManyRequests`) and is not on the v3 token bucket — but it's a single one-shot call per sweep, not a per-user loop, so it doesn't trigger the same hazard. every Tencent SDK call (CAM / billing / CVM / CLB / CDN / DNSPod / 域名注册) funnels through [`server/app/integrations/drivers/_tencent_throttle.py`](../../server/app/integrations/drivers/_tencent_throttle.py) — per-`(secret_id, service)` token bucket (4.5 RPS default, under the documented 5 RPS CAM ceiling) + bounded retry-on-`RequestLimitExceeded` (exponential backoff + jitter, 5-attempt cap, then re-raise). The grants_sync per-user `ListAttachedUserAllPolicies` loop trivially hit the 5 RPS cap for any tenant with >5 active CAM users, aborting the snapshot mid-sweep — and a partial snapshot is a mass-revoke hazard since `lifecycle_service:_apply_discovered_grants` mark-and-sweep treats missing grants on a successfully-synced scope as "cloud removed → close Helix row". The throttle is process-local (worker is single-process for `cmdb.grants_sync`); single-flight at the *job* level is enforced upstream by the `cloud_sync:<integration_id>` distributed lock. Sync flavour `tencent_call_sync` exists for code paths already inside `asyncio.to_thread`. |
| `ucloud_cloud` | cloud | UCloud | servers, user_revoke, IAM mode, billing, **cost_facts (2026-05-18)** — `ListUBillDetail` shared with `list_billing_daily` (UCloud has no separate line-item endpoint) but emits one `CostFact` per (day × resource_id × ChargeType) bucket instead of aggregating to (day, BusinessName). **`requires_cny_fx=True`** — CNY-native, FX from shared [`app.core.fx_rate`](../../server/app/core/fx_rate.py); driver raises if rate is None / ≤0 (mirrors Aliyun / Tencent). **Driver-side pre-aggregation by `(day × resource_id × usage_type)`** is mandatory because the ingestor upserts `INSERT ... ON DUPLICATE KEY UPDATE` on `fact_key` and would overwrite (not sum) on duplicate-key collision. `usage_type=ChargeType` keeps prepaid vs pay-as-you-go on the same resource separate. **AmountReal priority identical to `list_billing_daily`** (`AmountReal > Amount > TotalPayPrice`) — daily-rollup CNY total and cost_facts CNY sum reconcile on the same window (pinned by `test_cost_facts_reconcile_with_list_billing_daily`). NotOpen / UnknownAction fail-open to `[]`; mid-pagination errors raise (no partial sediment); **page-cap raises** (stricter than billing_daily which only warns — truncated cost_facts would silently understate chargeback). Zero-amount rows skipped. AWS-only kwargs (`principal_tag_keys` / `bq_billing`) accept-and-ignore. **NaN / Infinity rows dropped via `math.isfinite()` at parse time** so a poisoned upstream can't propagate NaN into `amount_usd` and corrupt every downstream `SUM()`. **`RuntimeError` re-raise wraps `{exc}` via `redact_provider_error()`** so UCloud AK / signature / proxy-WAF echoes can't leak into `cmdb_sync_jobs.capability_results['error']` (DB-readable by cloud_admin) / failure-notifier card / 30-day log retention. 21 tests in [test_ucloud_cost_facts.py](../../server/tests/test_integrations/test_ucloud_cost_facts.py). (**ListUBillDetail mid-pagination errors raise (2026-05-10)**. **`AttachPoliciesToUser` / `DetachPoliciesFromUser` Scope-value correction (2026-05-14 round 3, P0092)**: confirmed against [UCloud docs](https://docs.ucloud.cn/api/iam-api/detach_policies_from_user) — valid `Scope` values are `"All"` (tenant-wide) and `"Specified"` (project-scoped, requires `ProjectID`). Legacy code used `"Account"` on both Attach and Detach, which UCloud's current API rejects with `RetCode=220 / 230`. Helix's single standalone_iam scope model maps to `"All"`. Same value applied to both sides: `grant_access` (Attach), `revoke_access` (Detach), and the `revoke_account` policy-detach chain. **`external_ref` URN parse fix (same PR window)**: `grant_access` writes `{user_name}::{policy_urn}::{policy_name}` but `policy_urn` itself contains `::` (e.g. `ucs:iam::ucs:policy/UBillFullAccess`); the previous parser took `parts[1]` and sent the truncated `ucs:iam` as the URN, hitting UCloud's URN validator before the Scope check could surface. Rewrote as `parts[0]=user`, `parts[-1]=policy_name`, `"::".join(parts[1:-1])=URN`. Legacy 2-part refs (`user::name`) fall back to `_resolve_policy_urn(role_key)`. **Investigation log of three iterations (PR #295 → #296 → #297)**: Round 1 added `Scope="Account"` (wrong value) → RetCode=230 Invalid Scope. Round 2 removed Scope entirely → regressed to RetCode=220. Round 3 pins `Scope="All"` per docs. 6 regression tests in [test_ucloud_revoke_access.py](../../server/tests/test_integrations/test_ucloud_revoke_access.py).) |
| `ksyun_cloud` | cloud | Kingsoft (金山云) | **archival** (`archival=True`, 2026-07-14) — read-only historical tenant, **no capabilities / no credentials / no sync**. PingCAP decommissioned 金山云 (DECISIONS 2026-04-23). The 541 servers + 13 LBs migrated from the legacy CMDB were soft-deleted then **hard-deleted 2026-07-14** (with the 金山云 历史 integration row + 150 owner-history rows) per user direction "彻底不管" — **no ksyun data remains in the DB**. The driver + `archival` flag are kept as generic defense-in-depth (any future archival tenant is auto-excluded from active-management surfaces via `registry.archival_providers()`); with zero ksyun rows the exclusion currently matches nothing. |
| `successfactors` | hris | SAP SuccessFactors | full / incremental / window sync |
| `feishu_chat_approval` | chat_approval | Feishu | submit/cancel approval, webhook verify |
| `feishu_helpdesk` (added 2026-06-22) | helpdesk | Feishu | Read-only 服务台 (Lark Helpdesk) ticket pull for analytics — see §12.x / `app/domains/feishu_helpdesk`. Multi-instance (one row per 服务台; self-contained `app_id`/`app_secret`/`helpdesk_id`/`helpdesk_token`). Subclasses `IntegrationDriver` directly (no kind mixin) + exposes `make_client() → FeishuHelpdeskClient`. Helpdesk API needs DUAL auth headers (`Bearer tenant_access_token` + `X-Lark-Helpdesk-Authorization: base64(helpdesk_id:helpdesk_token)`) and the app must hold `helpdesk:all:readonly` (published). `test_connection` does a 1-row tickets read. Synced by `feishu.helpdesk_sync` (12h). |
| `feishu_contact` (added 2026-05-28; Multi-Geo create 2026-08-19) | identity_sync_feishu | Feishu | Outbound HRIS-of-truth → Feishu Contact projection. Catalog + `test_connection` only; runners (`FeishuContactRunner` / `FeishuDepartmentRunner`) live in `app.domains.identity_sync.runners.*` and read `app_id` / `app_secret` from this integration row (typically the same self-built app that backs `feishu_chat_approval`). A missing employee is created through Directory v1 Create Employee (not Contact v3) so its one-time data-residency option is explicit: HRIS `residence_country` maps `CN → cn`, every other valid country → `jp`; missing/invalid country blocks creation rather than falling back to the tenant default. The normal Contact PATCH then projects departments and richer profile/custom fields. **Create-path resilience (2026-08-27):** a mobile Feishu won't validate (41004) is dropped and the create retried once — the account is built and the number recorded as a `dropped` slot outcome (deliberate degraded success, #1956); the Contact projection does not re-send that number, so "dropped" on the ledger is literally true, and the next sweep's PATCH retries it on the now-existing user. The PATCH path's "user vanished → recreate" hand-off preserves the residency hint, and **the projection PATCH that follows a create runs with `allow_recreate=False`** so a read-after-write window can never mint a SECOND account. A dept the app may not assign into (41041) degrades on the projection rather than retrying the create into root `"0"` — strictly better, since a lingering `"0"` pins the hire at the org root even after the real dept resolves. Directory v1 requires no `employee_type` (that create-only default was a Contact v3 workaround for 99992402) and whether it returns Contact's 41010 "no mobile" code is unverified — do not port that branch on the strength of the old code numbers (#3274). Existing users are never re-homed by a sync; corrections use the separately audited Feishu User Migration API. `test_connection` is 3-stage: `tenant_access_token` → `/contact/v3/scopes` read probe → `/contact/v3/custom_attrs` enumeration (surfaces in `detail.custom_attrs` so admin can verify Feishu side has the schemas referenced by mapping_rules before enabling a target). `default_department_id` config slot drives the user-runner UNASSIGNED fallback for hire-day creates where HRIS dept isn't pushed yet. |
| `huilianyi` (added 2026-06-10) | identity_sync_huilianyi | 汇联易 | Outbound HRIS-of-truth → 汇联易 open-API push (employee + dept master data, replacing the legacy 汇联易→Authing daily pull). Catalog + `test_connection` only; runners (`HuilianyiRunner` / `HuilianyiDepartmentRunner`) live in `app.domains.identity_sync.runners.huilianyi`. Credentials = 权限ID/权限密钥 minted per-permission in 汇联易后台 → 系统对接 → 开放接口权限 (the permission row also gates every endpoint individually — enable 登录 + 员工 + 部门 modules). Token contract verified 2026-06-10: `POST {base}/oauth/token`, Basic base64(权限ID:权限密钥), form `grant_type=client_credentials&scope=write`; SaaS gateway base `https://api.huilianyi.com/gateway`. `test_connection` = token probe (hard) + `GET /api/open/user` read probe (soft). |
| `netsuite` (added 2026-06-23; inbound GL read 2026-06-28) | identity_sync_netsuite | Oracle NetSuite | Outbound HRIS-of-truth → NetSuite REST record API push (employee master data) **+ inbound GL read** (finance PR1: `finance_netsuite_coordinator` calls the driver's public `build_suiteql_client()` to pull summarized GL actuals + financial masters via SuiteQL — same TBA creds). Catalog + `test_connection` + `build_suiteql_client`; runner (`NetSuiteRunner`) lives in `app.domains.identity_sync.runners.netsuite`. Credentials = Account ID + TBA Consumer Key/Secret + Token ID/Secret (5 fields; Account ID is `config_public`, the other 4 are `secrets_encrypted`). **Auth = Token-Based Authentication (OAuth 1.0a, HMAC-SHA256)** — no token exchange; the 4 static credentials sign every request, realm = Account ID, host derived from Account ID (`{id_lower_dashed}.suitetalk.api.netsuite.com`). The bound NetSuite access token MUST sit on a dedicated role with "REST Web Services" + "Log in using Access Tokens" + employee-record permission — **NetSuite forbids the Administrator role with TBA**. `test_connection` = read-only `GET …/employee?limit=1`. |
| `feishu_software` | software_provisioning | Feishu | seats. Four binding ``operation`` modes (driver default since 2026-05-30 is ``user_suspend``): ``user_suspend`` (default, recommended; revoke = PATCH `is_frozen=true` keeping account + chats + files, grant = PATCH unfreeze; opt-in `hard_delete_on_offboard=true` switches revoke to DELETE), ``group_member`` (niche; add/remove from a Feishu user group + `list_group_members_with_emails` via `simplelist` + `users/batch`), ``tenant_users`` (read-only roster sync — every active Feishu account is one seat, walks `/contact/v3/departments` + `/contact/v3/users/find_by_department`; grant/revoke hard-error by design because account lifecycle is owned by another channel), ``user_create`` (advanced, default OFF; POST `/contact/v3/users` to actively create on hire, revoke reuses user_suspend freeze/delete paths via independent `user_create_revoke_to_delete` opt-in). Missing/unknown `operation` → fail loud via `_resolve_operation` in driver + skip via `feishu_seat_coordinator` + `unsupported_operation` else-branch in `feishu.seat_sync` handler. **`feishu.seat_sync` reads the full-tenant roster for ALL of `tenant_users`/`user_suspend`/`user_create` (`_TENANT_ROSTER_OPERATIONS`) since the 2026-06-30 read/write decoupling** — flipping a binding to `user_suspend` for offboarding no longer disables seat reconciliation. |
| `slack_software` | software_provisioning | Slack | seats. **Deactivate idempotency (round-15, 2026-05-09)**: SCIM `PUT /Users/{id}` returning 404 in the `action_on_offboard=deactivate` branch is treated as `already_absent` (TOCTOU window — admin hard-deleted the user between Helix's `_scim_find_user` and the PUT). Symmetric with the same driver's `delete` branch which already handled 404. external_ref uses the synthetic `slack:{email}:already_absent` form (consistent with the upstream `find.user is None` path) since the captured user_id may be reaped on Slack's side. |
| `zoom_software` | software_provisioning | Zoom | seats (requires successor on revoke for meetings/recordings). **Managed-domain fallback (added 2026-05-08)**: when the bound binding is `action_on_offboard=disassociate` and the user's email belongs to an SSO-managed domain (Zoom error 1107 "You can not disassociate a user with managed domain."), `revoke_seat` falls back to `PATCH /users/{id}` with `type=1` (Basic) — releases the paid license + clears every type-2-only add-on (Large Meeting, Webinar) without removing the user from the tenant (which managed-domain SSO requires). Same billing-side outcome as a successful disassociate. The fallback is scoped to `disassociate` only — `delete` 1107s still surface as failures because the type downgrade doesn't preserve meeting/recording handoff. **Structured-error-tag layer (added 2026-05-22)**: every failure branch in `grant_seat` / `_reconcile_existing_user` / `revoke_seat` / `_disassociate_via_type_downgrade` parses Zoom's `(code, message)` via shared `_parse_zoom_error` + `_classify_zoom_user_error` helpers and prefixes the persisted `error_message` with one of `[ZOOM_BASIC_CAP_EXCEEDED]` (code 3412 on managed-domain fallback, 3413 on direct Basic create), `[ZOOM_LICENSED_CAP_EXCEEDED]` (code 200 + "licensed maximum/limit" message), or `[ZOOM_USER_IN_OTHER_ACCOUNT]` (codes 1005 / 1108 / 2030). `raw_response` carries `zoom_code` / `zoom_message` / `error_tag` for log/audit/analytics. The 3412 branch additionally calls `_count_basic_users_safe` to enrich the message with the current Basic-user count so the admin knows how much headroom they need to free. The provisioning DM dispatcher (`_dispatch_revoke_failure_dms`) detects the `[TAG]` prefix via `_extract_remediation_hint` and appends a Chinese operator-facing remediation line (e.g. "打开 Zoom Admin → 筛 User Type=Basic → 清理闲置 Basic 用户后重试"). Bit prod 2026-05-22 (P01451 looperth.wong). **Basic-pool auto-eviction (added 2026-05-24, hardened across 3 review rounds)**: managed-domain tenants where the Basic pool is full would deadlock — `revoke_seat`'s only path to release a paid license is the Licensed→Basic downgrade, and 3412 made that path fail. New driver method `delete_user_unconditional(email)` exposes `DELETE /users/{email}?action=delete` directly (action=delete bypasses the 1107 managed-domain restriction; idle Basic users have no content so the `revoke_seat`-level "successor required" guard is wrong for this code path). Coordinator-level `make_room_in_basic_pool` (see [`zoom_seat_coordinator.py`](../../server/app/core/coordinators/zoom_seat_coordinator.py)) picks the longest-idle Basic user via the same LRU algorithm used by the Licensed-side `_pick_eviction_victim` (sort by `(never_logged_in, last_login_asc)`, exclude `_resolve_protected_emails` whitelist + caller-supplied `skip_emails`) and DELETEs it; on per-victim failure advances to the next candidate up to `_MAX_BASIC_EVICTION_ATTEMPTS=3`. **Safety filter (CRITICAL Round 2)**: only delete (a) Helix persons with `employment_status='left'` OR `deleted_at IS NOT NULL`, or (b) Zoom users with NO Helix match whose Zoom-side `created_at` is older than `_ZOOM_GHOST_MIN_AGE_DAYS=90` (exact `timedelta` comparison, NOT `.days` int floor — Round-3 boundary fix). ACTIVE employees, `leaver_pending` mid-offboarding persons, and fresh-hire-shaped unmatched Zoom users (`< 90` days) are NEVER deleted. Identity matching is alias-aware via `build_person_by_email_index` + a targeted `_lookup_tombstoned_persons_by_email` fallback so Google Workspace alias mismatches don't classify an active employee as a deletable orphan. **Audit row (Round 2)**: every successful eviction writes `audit_logs.action='software_seat.basic_pool_evicted', resource_type='zoom_user', resource_id=victim_email[:128]` with structured `detail` (victim_email/last_login/created_at/helix_person_id/employment_status/eligibility_reason/attempts). **Concurrent-race guard (Round 3)**: `delete_user_unconditional` flags 404 responses as `raw_response['already_absent']=True`; the coordinator treats those as "another concurrent eviction won the race" and ADVANCES to the next candidate rather than claiming credit (audit row only fires for true 200/204 deletes). **Cross-product scope guard (CRITICAL Round 3)**: `_mark_local_alloc_revoked_if_exists` joins through `AssetSoftwareCatalog` and restricts to plans with name LIKE `%zoom%` — without this, a tombstoned person's Slack/GitHub allocations would be silently flipped to revoked by a Zoom-pool eviction (cross-product data corruption + billing leak). Wired into TWO call sites: (1) the worker's REVOKE dispatch path (`ProvisioningService._maybe_retry_zoom_on_basic_cap` in [`provisioning/service.py`](../../server/app/domains/provisioning/service.py)) calls `make_room_in_basic_pool(skip={leaver_email})` + retries `revoke_seat` once when the leaver's revoke lands ZOOM_BASIC_CAP_EXCEEDED; (2) `ensure_seat_capacity`'s Licensed-eviction leg does the same when the victim's revoke cascades into 3412 (`skip={requester, victim}`). On success the `task.context_data['zoom_basic_eviction']` payload is stamped with `flag_modified` (defends against SA JSON dirty-bit miss) for audit replay. When the Basic pool also has no eligible victim the original 3412 result is preserved so the admin still sees the existing remediation hint and acts manually. |
| `jira_software` | software_provisioning | Atlassian Jira | seats, permissions. **Revoke idempotency (round-15, 2026-05-09)**: Atlassian's `DELETE /rest/api/3/group/user` returns **HTTP 400** with body `Cannot remove user from group 'X' since user is not a member of 'X'` when the target is not in the group (i.e. seat already absent). The driver originally only treated `(200, 204, 404)` as success and bubbled this 400 as a failure — kept offboarding blockers permanently open for users whose Jira group membership had already been cleaned up out-of-band. Now `_is_not_a_member_response` mirrors `_is_already_member_response` (sibling helper used on grant for "already a member") and treats 400-not-a-member as `already_absent` on both `deactivate` and `delete` action paths. The `delete` path still proceeds to the admin lifecycle/disable call after a no-op group DELETE. |
| `github_software` | software_provisioning | GitHub | seats |
| `github_org` | **github_org** (its own kind) | GitHub | org membership: `list_members` / `list_pending_invitations` / `invite_member` / `remove_member` / `cancel_invitation` / `get_user` / `list_teams`. **Two auth modes behind one call surface** — `github_app` (RS256 JWT → per-org installation token, cached with a 5-min skew) and `pat`. Deliberately NOT a variant of the `software_provisioning` GitHub driver above: different API (org REST vs SCIM 2.0), different credential (`admin:org` / App installation vs `scim:enterprise`), and different semantics — a SCIM seat is provisioned and billable, an org membership is **offered** and exists only once accepted. `GithubOrgActionResult` keeps `invited` and `already_done` as separate flags for exactly that reason. Every method takes `org` explicitly rather than reading it from the integration config: one App legitimately covers several orgs, and pinning the org to the credential row would force one integration per org. Transport failure is never read as "already absent" — the platform driver rule, and here the difference between "we removed their access" and "we never reached GitHub". |
| `feishu_partner_directory` | **partner_directory** (its own kind) | Feishu | READ-ONLY roster of a PARTNER company's staff — the only driver in the registry that points at **somebody else's tenant** (CBG / 平凯星辰, app `cli_aa1cca…`, a different tenant from our own approval app: their custom-attr ids are a different namespace, which is how that was established). `list_members()` returns a `PartnerDirectorySnapshot` — members **plus** `complete`, because "the partner has 40 staff" and "we could only read 40 of them" must not arrive as the same value. Structurally read-only: `PartnerDirectoryDriver` has no write verb at all rather than write verbs that raise. `test_connection` asserts on **fields present in a real user record**, not on HTTP status — Feishu answers a missing field scope with `code: 0` and the field simply absent, so a status-only check reported this app healthy across four separate permission-grant rounds while it could read nothing. It also separates `40004 no dept authority` (the admin-console **data** scope, 通讯录权限范围) from field absence (**API** scope), because adding API scopes never fixes the former. |
| `nexuscrm_software` (added 2026-06-22) | software_provisioning | NexusCRM (Salesforce-compatible) | seats. OAuth `client_credentials` via the IT Connected App (Consumer Key/Secret + `instance_url`); a seat = active `User`, revoke = PATCH `IsActive=false` (Salesforce can't hard-delete a user). `test_connection` = token + `SELECT COUNT(Id) … IsActive=true`; `list_full_members` = SOQL `SELECT Id,Name,Email,IsActive,ProfileId,Profile.Name FROM User WHERE IsActive=true` (this instance has no `UserType` column — platform/integration/synthetic accounts are flagged via `_is_system_account` on email domain `00d*`/`salesforce.com`/`.invalid` + profile `*Integration User`, so the Connected App's own run-as user is never wiped). `revoke_seat`/`revoke_all_access` both deactivate (no group model on top); a read-only (`api.read`) token surfaces `INSUFFICIENT_SCOPE` 403 as an actionable non-transient error rather than a silent success. Offboarding runs **manual_notify** (binding) → DM to the software owner (jie.ni@pingcap.com) per the 2026-06-22 decision; no auto-API revoke. Reconciliation: `nexuscrm.seat_sync`. |
| `google_workspace` | software_provisioning | Google Workspace | seats, permissions, data transfer on revoke. **`_set_suspended` revoke-direction idempotency (round-15, 2026-05-09)**: `users().update({suspended: true})` returning HttpError 404 is treated as `already_absent` (admin hard-deleted the Google account out-of-band between data-transfer poll and suspend). Asymmetric ON PURPOSE with the unsuspend direction (`suspended=False`, called from `grant_seat` on rehire) — there 404 stays a hard failure because silently treating a missing-account grant as ok would mask provisioning bugs. Symmetric with the same driver's `_group_remove` 404 contract. |
| `microsoft_graph` (added 2026-06-25) | software_provisioning | Microsoft 365 / Intune | seats (M365 license assign/revoke via Graph `assignLicense`) + Intune device read. Client-credentials (Entra app reg + admin-consented application perms: `User.ReadWrite.All` / `Organization.Read.All` / `Directory.Read.All` / `DeviceManagementManagedDevices.ReadWrite.All` / `DeviceManagementConfiguration.ReadWrite.All` / `DeviceManagementApps.ReadWrite.All`). Per-instance token cache (refresh on expiry/401), bounded transient retry (429/5xx, honours `Retry-After`). Binding `operation=assign_license` carries `sku_id` + optional `upn_suffix` (Helix `work_email` localpart → `localpart@<upn_suffix>` because the M365 UPN domain differs from `@pingcap.com`) + `usage_location` (PATCHed when the user has none — `assignLicense` requires it). Idempotent: already-licensed grant / not-licensed or user-gone revoke → ok; `countViolation` → `[MSGRAPH_NO_SEATS_AVAILABLE]`. `requires_successor_on_revoke=False` (license removal needs no data hand-off). Read helpers `list_subscribed_skus` / `list_managed_devices` (paginated). Pinned by [test_microsoft_graph_driver.py](../../server/tests/test_integrations/test_microsoft_graph_driver.py) (16 tests). Backs the `日本MDM` software. |
| `jamf` (added 2026-06-26) | mdm | Jamf Pro | Apple device MDM. Read: `list_devices` (computers `/api/v1/computers-inventory` + mobile `/api/v2/mobile-devices`, flattens serial / model / OS / assigned-user email + last-contact), `get_seat_usage` (cheap `totalCount` probe — seat == managed device). Actions: `lock_device` / `wipe_device` via `/api/v2/mdm/commands` (`DEVICE_LOCK` / `ERASE_DEVICE`, resolves `managementId` from inventory detail), `clear_device_assignment` (PATCH User-and-Location, 404-idempotent — leaver 解绑). Client-credentials (`POST /api/oauth/token`); API Role needs Read Computers/Mobile Devices/User + Send Remote Lock/Wipe Command + Update Computers/Mobile Devices. Per-instance token cache + bounded transient retry (429/5xx, honours Retry-After), 401→refresh-once. Verified live against `pingcap.jamfcloud.com` (45 managed Macs). Pinned by [test_jamf_driver.py](../../server/tests/test_integrations/test_jamf_driver.py) (12 tests). **Seat reconciliation (2026-06-26, migrations 345/346 — SUPERSEDED 2026-08-26, see 设备台账化 below; kept for the `holder_key` rationale):** `jamf.seat_sync` (now retired; its handler is [mdm_handlers.py](../../server/app/scheduled_jobs/mdm_handlers.py) + [jamf_seat_coordinator.py](../../server/app/core/coordinators/jamf_seat_coordinator.py)) feeds Jamf devices into the SHARED software-seat reconciliation surface (same `software_seat_sync_stat` / `software_external_holder` / `ReconciliationPage` as Feishu/Jira/Zoom — no new table/page). Seat == device; per-device classification (matched / leaver_residual / external / no_email), `vendor_license_holders = device count`. To keep the per-holder identity balanced under per-device counting, `software_external_holder` gained a nullable **`holder_key`** column (migration 345): dedup/uniqueness is now `holder_key or email` — per-user vendors pass None → key on email exactly as before (unique key `(plan_id,email)` → `(plan_id,holder_key)`, email nullable; ORM default falls `holder_key` back to email so existing callers/factories are unchanged). Jamf originally set `holder_key = device serial`; **since migration 558 it is `device_type:external_id`** (the tenant's own device identity) — a re-enrolment leaves two records for one serial, and the collision fails the fail-loud reconciliation identity, which rolls back the whole ledger write. The multi-device-employee property is unchanged: one row per device either way. **Offboarding device actions (2026-06-26, PR-B, no migration):** [`jamf_device_action_coordinator.execute_jamf_device_action`](../../server/app/core/coordinators/jamf_device_action_coordinator.py) runs `unbind` / `lock` / `wipe` on ONE device — **confirm-gated, no cron / no auto-fire** (a leaver's irreversible wipe is the highest-stakes op; per the standing "confirm before irreversible" rule it's never auto-triggered). Surfaced via MCP tool `offboard_jamf_device` (`global_admin`, `mutates=True`). **Wipe hard-guard:** refused when the device's asset (matched by serial) is `held_by_leaver` or earmarked `keep` (buyback) — unbind still allowed; wipe also requires the device to resolve in Jamf (driver `get_device`). Deliberately does NOT touch the offboarding `_rescan` / provisioning `execute_task` core paths. Audited as `jamf.device.{action}`. Pinned by [test_jamf_device_action.py](../../server/tests/test_offboarding/test_jamf_device_action.py) (6 tests). **Asset-owner reconcile + backfill (2026-06-27, no migration):** Jamf frequently has a blank assigned-user while the asset system (matched by **serial**) knows the holder. `jamf_seat_coordinator.asset_owner_by_serial` (asset_items→identity_persons by `assigned_to`) is the ownership fallback: `jamf.seat_sync` uses it to reclassify Jamf-no-owner devices as matched / leaver_residual instead of no_email (read-only; identity still balances). `jamf_device_action_coordinator.backfill_jamf_assignments_from_assets` + MCP `backfill_jamf_device_owners` write that owner back into Jamf via driver `set_device_assignment` — **defaults `dry_run=True` (preview, no write)**, only fills ACTIVE-employee owners (never a leaver), audited `jamf.device.assign_backfill`. Reversible metadata write but untested-against-live → real backfill runs dry-run-preview + one-device verification + confirm first. **设备台账化 + 处置触发清理(2026-08-26,迁移 558)—— 这一段取代了上面 seat-sync / allocations 的描述**:MDM 纳管属于**设备**,不属于人。新表 `asset_mdm_devices` ([models.py `MdmManagedDevice`](../../server/app/domains/asset/models.py)) 一台设备一行,键 `(integration_id, device_type, external_id)`;`serial_key`(小写序列号)是与 `asset_items` 的唯一连接。**归属列(`asset_id` / `assigned_person_id` / `cleanup_task_id` / `integration_id`)一律不带外键** —— TiDB 每条带 FK 的子表 INSERT 锁父行,整队重写会让 ~50 条插入卡在同一个 integration 行的锁上(03:00 撞车形状);读面必须 miss-tolerant,悬垂 id 渲染成「—」。`jamf.seat_sync` **已退役**(行停用保留历史),`jamf.device_sync` 接任([mdm_handlers.py](../../server/app/scheduled_jobs/mdm_handlers.py) + [mdm_device_coordinator.py](../../server/app/core/coordinators/mdm_device_coordinator.py)):upsert 台账、按序列号关联资产、解析归属(Jamf 的 User-and-Location 为空时回落资产台账,`owner_source` 记 `mdm`/`asset`)、本轮未出现即 `enrollment_state='gone'`。**不再写 `software_seat_allocations`**;离职扫描按 `kind='mdm'` 跳过坐席 blocker(`jamf_seat_coordinator.mdm_managed_software_ids`,不写死 provider)。对账中心那一格保留(合同用量 45/60 是真问题),`leaver_residual` 保留但语义是**数据质量读数**,不是待办。**清理闭环**:`AssetService.dispose_asset` 之后由 [mdm_device_retirement_coordinator](../../server/app/core/coordinators/mdm_device_retirement_coordinator.py) 开 `ProvisioningTask(target_type='hardware', source='asset_disposal', status='notified')`,与处置同事务落库,**处置路径不打 Jamf API**(查本地台账)。发卡/催办是独立 cron `mdm.cleanup_notify`(工作日 9–18 点 Asia/Shanghai;首发 → 7 天周催 → 21 天抄送管理员),收件人 = MDM 软件目录 owner,owner 缺失或已离职则扇出管理员。触达前先 `withdraw_stale_cleanups` 自检前提(关联资产已非处置态则撤单,仅对已关联资产的行生效)。 **闭环两个方向都要收敛**:撤单之外,同步阶段 1e 的 `reconcile_disposed_assets_without_cleanup` 负责**开单** —— 资产处于处置态而设备仍在纳管、却没有清理请求的,补开。三种情况只能靠它:上线前的历史处置、钩子的 `best_effort` savepoint 吞掉了 DB 错误、以及任何不走 `AssetService.dispose_asset` 的写入方。从台账出发(约一台设备一行,`asset_id` 由同一 job 刚刷新)而不是从「所有曾被处置的资产」出发;幂等;审计 `mdm.cleanup.reconciled`,与「刚发生的处置」区分。 **发卡限速 `NOTIFY_BURST_CAP=5`/tick**(工作日 10 次 ⇒ 50/天,`notify_burst_cap` 可调、非正值回落默认):补完开单之后第一晚会把积压一次变成一批卡,而办公室撤点一下午处置三十台**今天**就会。三要素缺一不可 —— 限流、**最久没处理的排前面**(不排序则队尾永远轮不到)、**把压下来的条数写进摘要**(静默截断读起来永远是「5 条待办、发了 5 条、一切正常」)。**结案两条路,且说法可被事实推翻**:卡片一键确认(`cleanup_confirmed_via=admin`,说法)与同步发现设备消失(`=sync`,事实,自动关任务)。同步若仍能找到一台被标记 admin 已确认的设备,即判定说法被推翻 —— 清掉确认并**新开**一单(不复活旧单:一键确认是绑在 task id 上的 14 天非一次性 token,复用 id 会让旧卡片再关一次)。通用队列上取消硬件清理会被同步理解为「这台不需要移除」并释放设备,避免两个域安静矛盾。四条状态迁移(auto_confirmed / reopened / withdrawn / released)全部写审计。原本的:卡片一键确认(`cleanup_confirmed_via='admin'`,说法)与同步发现设备消失(`='sync'`,事实,自动关任务);确认过仍被同步找到会重回催办。HTTP 面 `/api/v1/assets/mdm-devices{,/meta,/export.xlsx,/{id},/{id}/confirm-cleanup}`(读 `require_asset_read`,确认 `require_asset_admin`);MCP `list_mdm_devices` / `list_mdm_cleanup_queue`(只读,写面留在 HTTP)。**「说法 / 事实」要靠两个字段一起读,三个读面都是**:同步推翻一次声称时会清空 `cleanup_confirmed_via`,所以只读这一个字段的界面会把「被推翻」和「从没人碰过」显示成同一种。列表列、抽屉、MCP 队列工具、以及导出表都带 `cleanup_contradicted_at`,顺序一律是**新确认压过历史标记**。通知模板 `mdm.device_cleanup` / `mdm.device_cleanup_reminder`(三语)。**模板三语齐全不等于卡片三语** —— 生产者填进 context 的值也要逐收件人解析:`disposal_label` / `holder_line` 返回 `LocalizedText`,由 `_localized_context` 按语言取;返回纯中文串会让英文/日文卡片六行要点里有两行中文(这个病 2026 年在生产 45 天 outbox 里抓过一次,这次是换了个生产者回来)。没翻译过的枚举值原样透出,不退回笼统措辞。厂商名走 `_provider_label`(`jamf` → `Jamf`),避免同一张卡把同一个租户叫成两个名字。Pinned by [test_mdm_device_sync.py](../../server/tests/test_scheduled_jobs/test_mdm_device_sync.py), [test_mdm_device_retirement.py](../../server/tests/test_offboarding/test_mdm_device_retirement.py), [test_mdm_device_ledger_api.py](../../server/tests/test_asset/test_mdm_device_ledger_api.py), [test_mdm_cleanup_queue_claims.py](../../server/tests/test_mcp/test_mdm_cleanup_queue_claims.py) —— 60 项. **前端(2026-08-26)**:桌面 `/admin/mdm/devices`([MdmDevicesPage.tsx](../../web/src/pages/asset/mdm/MdmDevicesPage.tsx) + DetailDrawer + [mdmVocab.ts](../../web/src/pages/asset/mdm/mdmVocab.ts)),菜单挂在 资产管理 组,RoleRoute `global_admin/asset_admin/auditor`(management 由 RoleRoute 自动并入),写按钮由 `meta.can_manage` 决定是否出现。**配色是语义的一部分**:`pending_cleanup` 是全页唯一的红,`leaver_residual` 是琥珀 —— 把后者染红会在界面上重建「离职留下 MDM 工作」这个要消除的信念。移动端 `/m/admin/mdm-devices`([MAdminMdmDevicesPage.tsx](../../web/src/mobile/pages/MAdminMdmDevicesPage.tsx))**只读**,深链把 `?state=pending_cleanup` 原样带过去(`mobileDeepLink.ts`,带变异验证过的用例);确认动作刻意只有两个入口(飞书卡片一键、桌面抽屉),都走 provisioning 的既有完成通道,留同一条审计轨迹。 一键确认复用 provisioning 的免登录落地页 `GET/POST /provisioning/quick-complete`(GET 无副作用、POST 校验持票人、purpose+task 双绑定、页面级 CSP),**但那一页的文案按 `source` 分支** —— 通用文案写的是「确认已回收该坐席/账号?…推进离职流程」,渲染给设备时两句都错,而第二句会在按下确认的那一刻重述这个功能要消除的信念。任何后续复用这个落地页的新 source 都要一并给文案:共享落地页的默认文案永远是**它最初那个调用方**的。 **退役护栏(复审加固)**:分子是「**已知且本轮仍被报告**的在职设备」,分母是在职台账行 —— 两边必须是同一个总体:分子用整份清单会被全新设备稀释(8 台在职、报告 7 台其中 3 台全新 = 实际退 4 台,而比例读作 7/8),极端情况下 API 被改到另一个 site 会把整队在职设备判 gone;分母算上已移除行则会随历史越收越紧,累计到在职规模后永久拒绝退役。**空清单**(租户一台都没返回)任何覆盖都不放行;**返回了但一台都不认识**是另一条拒绝,可被覆盖放行(整队换机是真实事件)。拒绝带 `[retire-refused]` 标记进 `errors`(被 helper 认作防御性拒绝,可见但不 page)。**两个覆盖键,两个不同的授权**:`retire_min_ratio` 调比例闸,`retire_allow_zero_overlap: true` 授权「租户返回了但一台都不认识」那条(整租户迁移 / 整队换机)。第二条**不读**比例 —— 曾用 `min_ratio != 默认值` 反推,于是**收紧**到 0.9 反而解锁了整队旁路。两者共用 `retire_override_until`(ISO,过期自动回默认),缺它则**整个覆盖被忽略** —— job config 是持久化行,没有过期时间的下调不是「单次例外」,是一道被悄悄削弱后没人会想起来恢复的闸。**`reopened_from_task_id` 只决定措辞,不决定时机**:它永不移除,曾同时用于豁免间隔,导致重开的待办每工作日发 10 张并抄送全部管理员;一次性机制是清空 `cleanup_last_reminded_at`(通知循环发完即重写,自限),而且清戳与写标记**在同一处**完成 —— 分在两个模块时,跳过重开的分支会留下「戳已清、标记没写」,下一张卡退回首次通知口吻。抽屉与列表的文案区分三态:「待移除」/「已确认待同步核实」/「同步已推翻」。判据是**记录下来的** `cleanup_contradicted_at`,不是推断的 —— 推翻本身会清空 `cleanup_confirmed_at`,所以任何建立在后者上的谓词**恰好在没有推翻时为真**(30 分钟宽限窗内),推翻之后永远为假,正好反了。而**新的确认压过历史标记**:那一列保留到清理真正结案(让人看到「曾被声称并被推翻过」),但同步还没检验过这次新说法,此时说「删除可能没生效」同样是报告一件尚未发生的失败。原本的:把设备判为 gone 会盖 `cleanup_confirmed_via=sync` 并以「厂商已确认删除」结掉待办 —— 一句事实性断言,所以清单不可信时一律不做:空清单拒绝、已知 ≥5 台时报告数 <50%% 拒绝,拒绝写进运行摘要。设备消失一夜又回来则**重开待办**并清掉那次确认(否则待办永久丢失,没有任何东西会回扫历史处置)。`holder_key` 用租户设备身份 `device_type:external_id` 而非序列号 —— 重新纳管会留两条同序列号记录,而对账恒等式对本 provider 是 fail-loud,一次碰撞会回滚整个台账写入。厂商字符串按列宽截断后写入(一条超长 realname 的 1406 会掐掉整夜同步)。处置钩子走 `best_effort` savepoint;通知两阶段(落库提交 → 事务外投递 → outbox_retry 兜底),不在 cron 事务内做外部调用。 见 DECISIONS.md 2026-08-26。 |
| `manual_software` | software_provisioning | (manual notify) | seats, permissions via human task |
| `smtp_email` | email | SMTP | transactional send |
| `navan` | identity_sync | Navan (Travel & Expense) | outbound SCIM 2.0 push endpoint (`app.navan.com/universal/scim/v2`) — subclass of `IdentitySyncEndpointDriver` ([`identity_sync_endpoint.py`](../../server/app/integrations/drivers/identity_sync_endpoint.py)); attach to an identity-sync target, mapping rules drive the SCIM payload |
| `scim_generic` | identity_sync | any SCIM 2.0 endpoint | generic outbound SCIM 2.0 push (Okta / Rippling / custom HRMS connectors) — same `IdentitySyncEndpointDriver` base; `base_url` + `api_token` config |

Seeded by migrations [`034`](../../server/alembic/versions/034_seed_standard_integrations.py), [`045`](../../server/alembic/versions/045_seed_hris_production_connectors.py), [`051`](../../server/alembic/versions/051_seed_zoom_integration.py), [`056`](../../server/alembic/versions/056_seed_slack_integration.py), [`066`](../../server/alembic/versions/066_tencent_ucloud_production_seeds.py), [`077`](../../server/alembic/versions/077_seed_jira_integration.py), [`081`](../../server/alembic/versions/081_seed_bailian_api_key_sync_job.py), [`315`](../../server/alembic/versions/315_seed_nexuscrm_integration.py), [`343`](../../server/alembic/versions/343_seed_jamf_integration.py).

### Service surface

[`server/app/integrations/service.py`](../../server/app/integrations/service.py) exposes:

- `get_primary(kind)` — resolves single-instance integrations (HRIS, chat_approval).
- `build_primary_driver(kind)` / `build_driver_by_id(id_or_slug)` — instantiate driver with decrypted config.
- CRUD on `integrations` (with secret split via `is_secret` flag in driver schema).
- `test_connection()` — writes `last_tested_at`, status, message back to row. Before response, persistence, and audit, `IntegrationService` scrubs both free-form message and recursive detail: credential-shaped detail keys are dropped, while message patterns include quoted/unquoted `token_prefix[:=]` (JSON/Python repr included) as well as full secret aliases. Driver probes must never construct token fragments themselves; Feishu approval/contact and Huilianyi report only exchange success. **Per-capability dry-run (added 2026-05-17, supersedes the single-call semantic above)**: walks the admin-enabled `enabled_capabilities` set, calls the matching `list_*` provider method per cap (mapping in `_CAPABILITY_PROBE_METHOD`), snapshots `provider.partial_failures` between calls, classifies as ok / partial_failure / skipped / failed. Top-level `ok=False` when ANY cap landed failed. `IntegrationTestResult.detail.capability_probes` carries the per-cap structured breakdown; legacy `sample_server_count` / `leg_failures` keys still populated for callers / tests not yet updated. `_CAPABILITY_PROBE_EXCLUDE = {user_revoke, billing, billing_daily, cost_facts}` keeps money-cost and write-side caps out of the health-check — clicking 测试 must not burn BQ dollars or fire an offboarding call. 60s per-cap timeout via `asyncio.wait_for` (`_CAPABILITY_PROBE_TIMEOUT_S`). Leg names + reasons go through `redact_provider_error` / `redact_leg_entry`; the exception branch likewise wraps `str(exc)`. Fixes the prod-observed false-positive where AliCloud-intl `object_storage` (100% sub_account auth_error legs + 0 buckets) STILL returned `ok=True` because only `list_servers` was probed. Drivers that override `test_connection` (Tencent CAM `GetUserAppId`, UCloud `GetRegion`, Azure ARM + Graph two-step, GCP `projects.search`) still probe tenant-level surfaces only — they're sufficient for those vendors' onboarding-stage check. Frontend [`IntegrationsPage.tsx::TestResultModal`](../../web/src/pages/admin/IntegrationsPage.tsx) renders the per-cap table when `capability_probes` is present.
- Approval-flow registry — `get_approval_flows_map`, `get_approval_flow(flow_key)`, `upsert_approval_flow`.
- `PlatformSettingService` — generic key-value store (NOT mixed into `integrations`).

### Routes

- `GET/POST/PATCH/DELETE /api/v1/integrations[/{id_or_slug}]`
- `GET /api/v1/integrations/kinds` — driver catalog
- `POST /api/v1/integrations/{id_or_slug}/test`
- `GET/PUT/DELETE /api/v1/integrations/{id_or_slug}/approval-flows[/{flow_key}]`
- `GET/PUT/DELETE /api/v1/platform-settings/{key}`

All admin-gated.

---

## 5. Scheduled jobs framework + worker

### Schema

[`server/app/scheduled_jobs/models.py`](../../server/app/scheduled_jobs/models.py):

- `scheduled_jobs` row per scheduled work unit:
  - identity: `id`, `name` (stable namespaced key), `display_name`, `description`, `handler_key`
  - trigger: `trigger_type` ∈ {cron, interval}, `cron_expr`, `interval_seconds`, `timezone`
  - lifecycle: `enabled`, `config` (JSON, handler-specific knobs)
  - last-run: `last_run_at`, `last_status` ∈ {ok, failed, running, skipped}, `last_duration_ms`, `last_error`, `last_result_summary` (JSON)
  - counters: `run_count`, `failure_count`
  - 🔴 **there is no `next_run_at` column** (dropped in migration **620**). One existed from migration 037 and **nothing ever wrote it** — the scheduler's next-fire state lives in the APScheduler trigger, not in this row. It survived because two *other* tables have a same-named column that IS a real cursor (`identity_sync_targets` — the 60s dispatch reads it; `cmdb_resource_report_subscriptions`), so `grep next_run_at` always looked like somebody was writing it. It was not harmlessly NULL either: migration 245 seeded `hr.recover_orphaned_left` with `next_run_at = NOW()`, so that one enabled row showed admins a "next run" frozen at deploy time. **The authoritative next-run answer is [`service.schedule_forecast`](../../server/app/scheduled_jobs/service.py)**, which computes fire times from the live trigger (same maths as the worker) and covers `trigger_type='chained'` — a chained job has no clock of its own, so no stored scalar can express it. The forecast's `next_run_at` is `upcoming[0]` when a fire falls inside the window, otherwise the first fire *beyond* `window_end` (`_next_fire_beyond_window`), and `None` for dense jobs (next fire is seconds away, inside the window) and chained jobs (an estimate on an estimate). Regression: [`test_no_stored_next_run_at_2026_09_06.py`](../../server/tests/test_scheduled_jobs/test_no_stored_next_run_at_2026_09_06.py).
  - **failure-notification dedup** (migration 137): `last_failure_signature` (16-char hex hash of handler_key + first error line with UUIDs / timestamps / numbers stripped), `last_failure_notified_at`. Used by [`failure_notifier.py`](../../server/app/scheduled_jobs/failure_notifier.py) to suppress consecutive same-signature notifications so a cron that keeps failing the same protective guard (e.g. CMDB IAM-user drop-tolerance floor) produces ONE admin alert, not one per tick.
- `scheduled_job_runs` — append-only execution log: `started_at`, `finished_at`, `duration_ms`, `status`, `triggered_by` (`scheduler` | `manual:<user_id>` | `startup`), `error_message`, `result_summary`. **Acknowledgement metadata** (migration 137): `acknowledged_at`, `acknowledged_by_id` (FK `identity_persons.id`, ON DELETE SET NULL), `ack_reason`. The `POST /api/v1/scheduled-jobs/runs/{id}/acknowledge` endpoint sets these on **failed** rows so the default red-queue UI hides them; the row keeps `status='failed'` (we never rewrite history). Index `ix_scheduled_job_runs_status_ack` on `(status, acknowledged_at)` accelerates the "failed AND unacknowledged" cross-job scan that backs both the UI default and the dedup gate.

### Retry layers in `execute_job`

Two distinct bounded retries, separated by whether the single-flight lock was ever held:

| Layer | Constant | Covers | On exhaustion |
|-------|----------|--------|---------------|
| **Acquire** (2026-08-13) | `_LOCK_ACQUIRE_ATTEMPTS` = 2, `_LOCK_ACQUIRE_RETRY_DELAY_SECS` = 2.0 | The lock acquire itself raising a classified transient error (Redis `ConnectionError` / `TimeoutError`) | `status='failed'` run row + `maybe_notify_failure` + re-raise |
| **In-slot** (2026-07-19) | `_TRANSIENT_RETRY_MAX_ELAPSED_SECS` = 120, `_TRANSIENT_RETRY_DELAY_SECS` = 10 | The handler dying of a blip AFTER the lock is held (DB pool timeout, 1205/1213, 2006/2013, Redis blip) | `status='failed'` run row from the normal write-back |

Both gate on the same `_is_transient_infra_error` classifier. A retried acquire writes **no** run row — the tick has not failed yet, and a row would make a self-healing blip look like a failure in the admin run history. Neither layer is a loop: a genuine outage must fail closed rather than run a handler without its single-flight guard. The acquire retry exists because 2026-08-13 proved the acquire was the one path with no retry at all — four unrelated jobs died on `Timeout connecting to server` inside ten minutes, one shared Redis event.

### Failure notification path ([`failure_notifier.py`](../../server/app/scheduled_jobs/failure_notifier.py))

Called from `_execute_job_locked`'s post-handler write-back, the "no handler registered" early-failure branch in `execute_job`, AND the lock-infrastructure failure branch. Computes a stable signature, asks `_should_notify` (returns True on first occurrence, on different signature than the prior failure, OR when the prior failure was acknowledged), fans out one `NotificationService.dispatch` per active `it_admin` / `global_admin` (channels: feishu + email; in-app outbox row created automatically by `dispatch`). Best-effort: notification glitches are logged + swallowed so the run-row outcome remains the source of truth. Suppressed-via-dedup runs still update `last_failure_signature` so dashboards can render "currently failing with signature X" without a fresh push.

### Handler registry ([`registry.py`](../../server/app/scheduled_jobs/registry.py))

```python
@register_scheduled_job(key="...", display_name="...", description="...")
async def handler(db: AsyncSession, config: dict) -> dict: ...
```

Decorator fires at import time. The router lists all known handlers; the worker resolves by `handler_key`.

### Worker ([`server/app/worker.py`](../../server/app/worker.py))

Boot order:
1. `_load_all_models()` — import every ORM module so FK string references resolve.
2. Import every handler module to fire `@register_scheduled_job`.
3. `_reconcile_cloud_scheduled_jobs()` — for each cloud integration's capabilities, ensure per-capability job rows exist.
4. `_register_scheduled_jobs(scheduler)` — load enabled rows, attach APScheduler triggers.
5. `_seed_first_run_auditor_jobs()` — fire `auditor.*` jobs once on first boot so the dashboard isn't empty for a day.

**Reload cadence**: every 3600s (`SCHEDULER_RELOAD_INTERVAL`) the worker re-reads `scheduled_jobs` and reconciles APScheduler. Cron/enabled changes take effect within the hour.

**Concurrency**: APScheduler now configured with `max_instances=1, coalesce=True, misfire_grace_time=900` per job — silently-dropped missed runs are gone (any tick that fired while a prior tick was still running is coalesced into the next slot, capped at 15 min late). Critical handlers use `SELECT FOR UPDATE` on a sentinel row + per-session `innodb_lock_wait_timeout=120` (default 50). HRIS incremental sync retries 3× with backoff (0/10/30s) on deadlock. **Stale-running cleanup**: `cron.cleanup_stale_running` (hourly) sweeps `cmdb_sync_jobs` and `scheduled_job_runs` rows stuck in `status='running'` for >6h (default; tunable via `config.stale_after_hours`) and flips them to `failed` so the JSON_OVERLAPS lock no longer permanently blocks subsequent syncs after a worker SIGTERM mid-run.

**Cross-job connection-pool gate** (added 2026-07-19, post-mortem below): `max_instances=1` bounds a single job's self-overlap but NOT how many *different* jobs run at once — APScheduler's `AsyncIOExecutor` fires every due cron as a bare `loop.create_task`, so a minute where many crons cluster launches them all concurrently. Each running handler holds ≥1 pooled DB connection for its whole run (`_execute_job_locked` keeps `work_session` open across `handler.run`; handlers open further nested sessions), and the process pool is only `pool_size=10 + max_overflow=20 = 30` ([`database.py`](../../server/app/core/database.py)). `execute_job` now acquires a **per-process semaphore** (`_job_concurrency_gate`, sized to `settings.SCHEDULED_JOB_MAX_CONCURRENCY`, default **8**, validator-clamped `[1, 25]`) around every `_execute_job_locked` call — so at most N handlers hold connections at once (~16 of 30, leaving headroom for nested + housekeeping sessions) and the rest await a slot (their APScheduler task is already alive, so no misfire). Gates ALL trigger sources (scheduler tick, boot overdue-seed, admin "run now", `cost_facts_monthclose` drain); safe with no self-deadlock because **no handler awaits `execute_job` in-band** — the only in-process fan-out (`run_cost_facts_monthclose`) detaches its per-account drain via `background.spawn` and returns immediately. Acquired INSIDE the single-flight lock so `LockBusy` skips and the disabled/handler-vanished early-returns never burn a slot. Tests: [`test_scheduled_jobs/test_job_concurrency_gate_2026_07_19.py`](../../server/tests/test_scheduled_jobs/test_job_concurrency_gate_2026_07_19.py).

**Pool-exhaustion cascade** (post-mortem 2026-07-19): a burst of "many sudden failures" turned out to be ONE event. Four unrelated crons (GCP + AliCloud `billing_daily_sync`, UCloud DNS `resource_sync`, `domain_expiry_alert`) failed with the identical `TimeoutError: QueuePool limit of size 10 overflow 20 reached, connection timed out, timeout 30.00`; the AliCloud `domains` `resource_sync` failed its per-capability timeout ("cloud API hung or fan-out too large"); and `identity_sync.staleness_watchdog` raised reporting 5 sync targets that "silently stopped running their scheduled full sweep". Root cause: the nightly/hourly cloud-sync crons (one job per cloud account × capability, `resource_sync` hourly) clustered and — with no cross-job cap — collectively checked out all 30 pooled connections; every other DB consumer, including the identity-sync full sweeps, then timed out at the 30s `pool_timeout`. The watchdog RuntimeError and the `domains` capability failure were downstream symptoms/alarms of the drain, not independent bugs. Not a connection *leak* — every session uses `async with`; it was unbounded *concurrency*. Fixed by the connection-pool gate above (bounds demand structurally below the pool ceiling).

**Resilience layer on top of the gate** (2026-07-19 follow-up, same post-mortem):

- **Weight classes** — `register_scheduled_job(weight="heavy")` marks multi-minute cloud vendor sweeps (`cmdb.resource_sync` / `billing_sync` / `scopes_sync` / `roles_sync` / `grants_sync` / `billing_daily_sync` / `cost_facts_sync`, `hris.full_sync`). Heavy handlers acquire a smaller **heavy sub-gate** (`SCHEDULED_JOB_HEAVY_MAX_CONCURRENCY`, default **4**, clamped ≤ global) BEFORE the global gate — strict acquisition order, so a heavy job queueing on the sub-gate holds no global slot and the two levels cannot deadlock. Guarantees fast critical crons (`provisioning.dispatch` 30s, `identity_sync.dispatch` 60s, outbox retry) always have `MAX − HEAVY` global slots slow cloud syncs can never occupy. Everything else defaults `light`.
- **Transient-infra single retry** — `_execute_job_locked` re-runs a handler ONCE on a fresh session when the first attempt died of a classified infrastructure blip (`sqlalchemy.exc.TimeoutError` pool-checkout timeout — NOT the builtin `TimeoutError` from `asyncio.wait_for`, which stays non-retryable —, 1205/1213/1305 lock errors, 2006/2013 connection drops) *within 120s elapsed*; 10s delay between attempts. Budget (120+10s) deliberately fits inside the single-flight lock's 300s TTL buffer (module-level `assert`), so a retried run can never outlive its lock. Safe because every handler is already required idempotent (the framework's lock-retry helpers rely on the same contract). Logic errors / vendor 4xx fail immediately. A retried-then-ok run records `_transient_retry: 1` in `result_summary`.
- **Failure-storm aggregation** — the failure notifier's dedup is per-job, so one systemic event (pool exhaustion, Redis outage) used to fan N first-occurrence cards to every admin, plus a fresh batch each hour as new victims joined. Now `maybe_notify_failure` first runs `_storm_gate`: when ≥ `SCHEDULED_JOB_FAILURE_STORM_THRESHOLD` (default **4**) DISTINCT jobs have failed runs inside a rolling 10-min window, a Redis SET-NX marker (TTL 30 min) elects ONE announcer that sends a single aggregated "🌊 定时任务集中失败 — 疑似系统性事件" card (affected-job list + sample error); every other victim's card is suppressed for the storm window (its `last_failure_signature` still updates, so dashboards stay truthful). **P0-tier handlers bypass** the storm gate — paging-tier breaches reach admins individually even mid-storm. Redis down fails OPEN to per-job cards (noise beats silence).
- **Per-job notification quieting** (2026-08-17, 0 migration / 0 schema) — three gaps let ONE job page every admin repeatedly. (a) The signature dedup compared a failure only against the *immediately preceding* run, so a job alternating between two error shapes (`intelligence.analysis_daily` flapping `INTELLIGENCE_OUTPUT_INVALID` ⇄ `BAILIAN_LLM_BAD_JSON`) was "a new kind of failure" on every alternation — `_should_notify` now scans the job's recent **unacked** failures (`_LOOKBACK_HOURS` 24 / `_LOOKBACK_LIMIT` 20 rows). (b) No floor between cards: non-P0 jobs now push at most one card per `SCHEDULED_JOB_FAILURE_NOTIFY_COOLDOWN_MINUTES` (default **60**), which bounds a burst even when every signature genuinely differs; an **ack still re-opens the gate immediately** (checked before both cooldown and signature scan) and P0 is exempt. The cooldown DELAYS a card, it never deletes one: a failure the quiet period held back was announced to nobody, so it cannot silence its own repeat — derived from the two clocks already on the rows (`run.started_at > job.last_failure_notified_at` ⇒ no card covered it), no new column. (c) Operator "run now" failures broadcast: `POST /scheduled-jobs/{id}/trigger` runs synchronously and hands the failed outcome back to the admin who pressed it, so `maybe_notify_failure(triggered_by="manual:…")` now records the signature and sends nothing. Manual rows are also excluded from the dedup look-back (a silent failure must not mute the next scheduled one) and from `_storm_gate`'s distinct-job count (hand re-runs are not a systemic event). `triggered_by` is threaded from all four call sites (`service.py` ×3, `worker.py` wrapper-bypass); omitting it defaults to "scheduled" — a missing argument must cost a card too many, never a real failure gone quiet. Tests: [`test_failure_notification_quiet_2026_08_17.py`](../../server/tests/test_scheduled_jobs/test_failure_notification_quiet_2026_08_17.py).
- **Gate-wait observability** — `execute_job` measures time spent queueing on the gate(s) and stamps `_gate_wait_ms` into the run row's `result_summary` (plus a log line when >5s), so "the job was late because the gate was full" is visible in `/admin/scheduled-jobs` run history rather than guessed.

Tests: [`test_scheduled_jobs/test_resilience_2026_07_19.py`](../../server/tests/test_scheduled_jobs/test_resilience_2026_07_19.py) (heavy sub-gate caps + light-flow-past-saturated-heavy + clamp; transient retry ok/no-retry-on-logic-error/twice-fails; storm announce-once-then-suppress / below-threshold per-job / P0 bypass; production heavy-classification assertion).

**Chain rescue counts** (2026-09-06): `GET /scheduled-jobs/chains` reports `rescued_7d` per member — runs whose `triggered_by` is `chain_advance`, i.e. ones the reconciler had to release because the predecessor's own spawn did not. The fast path is how a chain is supposed to move; a member rescued every day is telling you the fast path is broken *for it* (a deploy landing in its window nightly, a predecessor whose write-back keeps failing), and nothing else would say so, because the chain still advances and every status reads green. Only reconciler-triggered runs count; a normal `chain:<run_id>` run must not inflate it or the number degrades into "how often did this job run".

**Ordering inventory + ratchet** (2026-09-06, [`scheduled_jobs/ordering_inventory.py`](../../server/app/scheduled_jobs/ordering_inventory.py)): the chain primitive is only worth having if new ordering claims reach it. Every handler that names another handler near an ordering word must appear in the inventory — `chained` (naming the migration that made the edge) or `deferred` (with a reason a reader can check) — and a test fails the build otherwise. The scanner's proximity window is **measured, not guessed**: the eight genuine claims in the tree put the marker 1–22 characters from the named key because a real claim keeps both in one clause, while the two mentions that are *not* claims sit 59 and 375 characters away (one says who delivers already-queued rows, the other draws a contrast — "NOT the same as … this re-PULLS"). 30 leaves margin above every real claim and excludes both; the first, window-less version reported them as decisions to be made, and a ratchet that emits pairs nobody can act on is one people learn to silence.

**Cloud IAM triad chained per account** (2026-09-06, migration **617**): `cmdb.scopes_sync → roles_sync → grants_sync`, per integration. Not an optimisation — `cmdb.scopes.aws-primary` silently did not run for **29 of 31 consecutive days** because all three take the same `cloud_sync:{integration_id}` lock while running as independent crons minutes apart (roles starts 04:09 and runs ~15 min; scopes fires 04:18 into a held lock). Chaining makes the contention structurally impossible. Guarded by cron *shape* (`^\d+ 4 \* \* \*$` / `^\d+ 5 \* \* \*$`) rather than exact values, since 13 accounts × 2 edges cannot be enumerated; a row an operator moved to another hour is left alone. **Nothing on the billing path is chained** — every edge makes its successor start earlier, and those jobs pull the previous day's vendor line items, so earlier risks reading an unfinished batch. A test asserts the migration's declared edge lists contain no billing job, so a future billing edge fails there rather than at a month-end reconciliation.

**Two chain failure modes in the hygiene census** (same PR): `chain_orphaned` (the predecessor row was deleted — the FK clears the edge rather than cascading, so the row can never fire again while looking entirely normal in the list) and `chain_stalled` (the predecessor is disabled — recoverable and possibly deliberate, so it ranks with `integration_disabled`).

**Chained jobs — ordering as a declaration** (2026-09-06, migrations **615** column / **616** first six edges, [`scheduled_jobs/chain.py`](../../server/app/scheduled_jobs/chain.py)): `trigger_type='chained'` plus a self-referential `chain_after_job_id`. A chained row has no cron and no interval; the worker attaches no APScheduler trigger for it (and skips it at `debug`, not `warning` — a warning per chained row on every boot and hourly reload would train operators to ignore the one below it that means something is broken).

At least eight ordering dependencies were previously expressed as **the gap between two cron minute fields**, with the intent recorded only in prose ("排在 X 之后", "紧随扫描任务", "runs after the rollup"). A minute gap is a promise the platform cannot keep, and the concurrency gate makes overrun the *normal* case in the pre-dawn window: measured, `cmdb.grants` for aws-sg queued 1 384 s in front of a 9 s run.

* **Fast path**: the executor spawns a job's chained successors on terminal write-back, detached via `background.spawn` — `execute_job` must never be awaited in-band from inside another execution, because the concurrency gate is held around every run and a parent waiting on its child would deadlock the gate against itself.
* **Guarantee**: `scheduler.chain_advance` (every 5 min) compares two timestamps rather than remembering an event, so it converges however the fast path was missed, and the condition self-clears once the child runs. **One hop per tick on purpose** — advancing a whole chain inside one tick would fire a grandchild before its parent's fresh run finished, the exact inversion the chain exists to prevent. Capped at 12 releases per tick so a mass re-chain cannot reproduce the pile-up.
* 🔴 **The reconciler excludes members that cannot run at all** (2026-09-06, post-merge review). Its predicate self-clears *once the child runs* — so a child that can never run never clears it, and gets re-fired every tick forever while occupying places in the 12-per-tick cap that genuine advances need. There are two ways to be unable to run and it excludes both: `enabled = 0`, and a row bound to a disabled or deleted integration, which `execute_job` turns away at its pre-gate. The second was missing when the pre-gate shipped (#3462) and only became reachable when the cloud IAM triad was chained (#3467) — those rows are exactly the integration-bound ones, and five production integrations are disabled. **Fixed in the reconciler, deliberately not in the pre-gate**: having a suppressed skip stamp `last_run_at` would make the predicate self-clear for free, but that column is what the compliance posture gate reads to decide a control's signal is fresh (`auditor/compliance_service.py::freshness`), and a job advertising a fresh clock while doing no work shows a control green on data nobody is collecting — the failure #2217 closed. Nothing is lost by excluding: the successor is still behind its parent when the integration returns, so the next tick releases it. Both sides read the binding through one predicate (`hygiene.config_integration_id_of`) — two predicates disagreeing on a malformed `config` would stop a chain with nothing anywhere saying why.
* **Both ends of the edge are checked for a run in flight**, not just the parent. A job's `last_run_at` is written when it *finishes*, so for the whole duration of its own run a child still reads as behind its parent; without this the reconciler re-fires it every tick while it is already running, and each attempt loses the single-flight lock race and leaves a `skipped` row. Bounded (the lock frees when the run ends) but one triad member measures 17 minutes — a steady drip of junk into a 100-row history, and it makes "single-flight lock busy" fire routinely when it should mean a real collision.
* **`plan_advances` orders by how long each member has waited** (NULL first — never-run is the most overdue). With a per-tick cap and no `ORDER BY`, the database may hand back the same slice every tick, turning a cap meant to bound a burst into indefinite starvation. The ordering test passed *by luck* before the fix and went red under mutation, which is the evidence that row order really was nondeterministic.
* **The chain panel names why a member is stuck** (`blocked_reason` per member on `GET /scheduled-jobs/chains`, mirroring the reconciler's exclusions predicate-for-predicate). Excluding silently is right for the reconciler — it is not a fault and not its job to report — but it leaves the console showing a chain whose members all read "enabled" and which quietly does nothing, the worst thing that panel can display. A test pins that the panel and the reconciler give the same verdict; disagreeing would be worse than saying nothing.
* **Advances on `ok` / `skipped` / `failed`, not on `interrupted`.** This is an ordering edge, **not a success precondition**: every one of those successors runs unconditionally today because it has its own cron, so blocking on failure would be a behaviour change that *reduces* coverage (a failed `leaver_assurance` would newly stop the roster reconcile from running at all). `interrupted` is excluded because the process is leaving.
* **Cycles are rejected at the write boundary** (`SCHEDULED_JOB_CHAIN_SELF` / `_CYCLE`), with a bounded runtime walk as the second layer — a cycle can still arrive by direct DB edit, and walking one unbounded is an infinite loop inside a cron. Chaining **replaces** the row's clock (both would be two independent triggers, i.e. two runs per cycle), and detaching without supplying a new clock is refused (`SCHEDULED_JOB_CHAIN_ORPHANED`).
* **`ON DELETE SET NULL`, not CASCADE**: deleting a predecessor orphans the successor (it stops firing and is listed under `orphans` by `GET /scheduled-jobs/chains`) rather than deleting it. Losing a job row is never the right answer to losing its predecessor.
* **Timeline support**: a chained job has no cron, so the forecast used to produce nothing for it and it vanished from the timeline entirely. It is now plotted at the predecessor's fires plus the predecessor's typical duration, flagged `chain_estimated` so the UI does not present a guess with a cron's confidence.

**Interrupted runs — the process went away, the handler did not fail** (2026-09-05, migration **614**): `ScheduledJobRun.status` gained `interrupted`. Production, 30 days: 452 scheduler failures, **386 of them** the stale-row reaper's `auto-fail: stale running row swept after 6h`, produced by three individually-correct pieces of code in series — ~15 deploys a day SIGTERM the worker, the drain cancels stragglers after 8 s, and `_run_scheduled_job`'s `CancelledError` branch deliberately wrote nothing, so the row sat at `running` until the reaper stamped it `failed` six hours later. 28 of 29 sampled had already re-run successfully within fifteen minutes of the next boot.

* **`_execute_job_locked` now catches `asyncio.CancelledError`**, closes the row as `interrupted` with a duration, and re-raises. The bookkeeping runs on a fresh, `asyncio.shield`-ed, 3-second-capped session: fresh because the handler's own session may be poisoned by the cancellation, shielded because a second `cancel()` must not leave it half-written, capped because shutdown must never hang on it — and if the DB is unreachable we fall back to the reaper, i.e. to the behaviour that existed before, so the failure mode is degradation rather than a stuck container.
* **`run_count` / `failure_count` / `last_status` are deliberately untouched.** Those columns feed the Prometheus identity `helix_scheduled_job_runs_total{ok} = run_count - failure_count`; a third terminal class that bumped one side would silently move the `ok` counter. They describe *completed* attempts, and an interrupted run completed nothing. Success-rate and SLO denominators already count `ok + failed` only (Round-62), so `interrupted` is excluded there for free.
* **No failure card.** With 15 deploys a day and a heavy cloud sweep in flight at most of them, notifying here would be the loudest and least actionable alert in the platform.
* **`helix_scheduled_job_runs_interrupted_24h{handler_key}`** is the compensating signal: because interrupted runs are out of the failure rate, a worker that dies every night would otherwise be invisible. Windowed, not monotonic — the run ledger is retention-pruned, and a counter sourced from it is the exact bug Round-62 fixed.

**Lock-liveness reaping** (same PR): `cron.cleanup_stale_running` can now tell a dead run from a legitimately long one. `execute_job` takes the single-flight lock with **`auto_renew=True`** and releases it only after the terminal write-back, so a `running` row whose Redis key is gone has no owner — reclaimed on the next hourly tick instead of after six hours, and marked `interrupted` because the handler did nothing wrong. `is_lock_held()`'s docstring had described this reaper use case since it was written; nothing used it. Redis trouble **fails closed** (leave the row to the six-hour backstop — wrongly reaping a live run would double a vendor sweep), plus a 5-minute startup grace. `auto_renew` is a prerequisite, not a nicety: the concurrency-gate wait happens *inside* the lock, and prod recorded a 1617 s queue in front of an 809 s run against a 2100 s TTL — that lock expired mid-run, which would have made a working job read as orphaned. The key shape lives in one place (`single_flight_lock_key`) because a second hand-rolled copy would have the reaper querying a key nobody holds and declaring every live run an orphan.

**Ledger hygiene + capacity model** (2026-09-05, migration **613**, [`scheduled_jobs/hygiene.py`](../../server/app/scheduled_jobs/hygiene.py)): the ledger is the one table nobody owns — rows are seeded by migrations, bound to integrations through an untyped JSON `config.integration_id` with **no foreign key**, and left behind when the thing they synced goes away. Two derived views close that gap, both computed from rows that already exist.

* **`resolve_job_health(job)` → `JobHealth`** names five shapes that all render identically in the admin list ("enabled, last status ok/skipped"): `handler_missing` (the `handler_key` is not in the registry — a rename that left the row behind; it can never run again), `integration_missing` (the integration row was deleted), `integration_disabled` (the integration is off, so every tick short-circuits — **a legitimate steady state**, re-enabling the integration resumes the job by itself), `never_run` (enabled > 2 days, never fired — the grace window matters because a monthly cron seeded this morning is not broken), `idle_skipping` (≥10 runs in 14 days, **all** skipped). Resolution is two queries for a whole page (`annotate_health`), never N+1. Surfaced as `health` on `GET /scheduled-jobs`, filterable via `?health=`, and censused fleet-wide by `GET /scheduled-jobs/hygiene`.
* **`capacity_summary()`** answers "why did this job start 20 minutes late": per weight class, daily slot-seconds of demand against the ceiling (`slots × 86400`); per UTC hour, demand + average `_gate_wait_ms`; per job, the ranked consumers and the longest queuers. **Observed wall-clock only — it never parses a cron expression**, because the cron says what is planned and the ledger says what happened, and when they disagree the operator needs the latter. Ranking is by `runs/day × avg duration`, not single-run duration: prod's biggest consumer was a 7-minute job that ran 96×/day, which single-run ranking buries. `failed` runs are excluded from demand (a run killed at the timeout is real wall-clock but unrepresentative of what the job normally needs). Exposed at `GET /scheduled-jobs/capacity` and as the 容量与体检 tab.

**Integration pre-gate** (same PR): a row whose `config.integration_id` resolves to a disabled or deleted integration is skipped **before** `_execution_slot` is acquired. Every per-scope cloud handler already returned `{"status": "skipped", "reason": "integration disabled"}`, but from *inside* the handler — after the run had taken one of the four `heavy` slots. Prod carried 35 such rows across 5 disabled integrations, all firing in the pre-dawn hours where live sweeps were already queueing. Two deliberate properties: it **fails open** (any error resolving the integration lets the handler decide — a hygiene optimisation must never be why a sync did not run), and it records the **transition only** (`should_record_gate_skip`) — `MAX_RUN_HISTORY_PER_JOB = 100`, so one identical skip row per tick would evict the job's real history within days, destroying the evidence the first row exists to preserve. Handler-level checks stay as defence in depth.

**`scheduler.ledger_reconcile`** (daily 02:50 Asia/Shanghai, migration 613): the census cron. It **reports** everything and **acts** on exactly one case — an enabled row whose integration row no longer exists at all, which cannot run under any future state — auto-disabling it with an audit entry (`scheduled_job.auto_disable`). It deliberately **does not raise** on that action: raising is how this codebase gets an alert out of a cron, but it would record a run that did exactly its job as `failed`, inflate `failure_count` and trip its own SLO — the same class of ledger lie that `_run_status_for_summary` prevents in the other direction.

**Tiered resource-sync cadence** (2026-07-19, migration **418**): the per-(cloud account × capability) `cmdb.resource_sync` rows defaulted to HOURLY since inception. Prod audit: 27 enabled rows, ~1 600 ok-runs per 3-day window, **100% zero-change** (`upserted=0 deleted=0` every run) — idle churn, and the same cron fleet that clustered in the pool-exhaustion incident. Defaults are now tiered in [`scheduled_jobs_provisioning._CAPABILITY_SYNC_PERIOD_HOURS`](../../server/app/domains/cmdb/scheduled_jobs_provisioning.py): **HOT every 4h** — `servers`/`iam`/`applications`; **COLD every 12h** — `domains`/`dns_records`/`certificates`/`cdn`/`object_storage`/`load_balancers`. Cron shape `{hashed_minute} {hashed_phase}-23/{period} * * *` — hour phase AND minute are hashed per job so the tiers don't re-create a thundering herd. Migration 418 rewrites only existing rows still carrying the old hourly code default (`{sha256(name)%60} * * * *`); admin-customised cadences are untouched (the reconciler never touches cadence either). On-demand freshness stays: per-row 立即执行 + integration sync-now API. A new `ProviderCapability` MUST get an explicit tier entry — [`test_resource_sync_cadence_tiers_2026_07_19.py`](../../server/tests/test_scheduled_jobs/test_resource_sync_cadence_tiers_2026_07_19.py) fails the build otherwise, and also pins the migration's replicated hash/tier helpers to the provisioning module's.

**HRIS mass-leaver brake** (added 2026-05-04): `HrSyncService.run_scheduled_incremental` is the only auto-confirming entry point — every other path requires an IT Admin to call `confirm_batch` manually per CLAUDE.md's "manual confirmation intended" contract. The brake fires inside `run_scheduled_incremental` AFTER preview and BEFORE confirm: if the staged batch contains more than `max(25, 5% of currently-active employees)` `offboard / deactivate` events, the batch is left in `pending_review` for human inspection rather than auto-applied. Caught failure modes: SF returning a corrupted OData payload that flips a whole BU to "terminated", upstream filter regression, tenant-scope misconfig. Operator path on brake: hit the Console's HRIS sync detail page → `POST /hr/sync/batches/{id}/confirm` (or `/cancel`).

**Incremental leaver blind-spot** (post-mortem 2026-07-03, P0051 韦万 / weiwan@pingcap.com — HR reported "operated the exit 07-01, effective 07-02, but Helix still shows 在职"). Two compounding bugs let a genuine leaver stay `active` between weekly full syncs: **(1)** `SuccessFactorsClient.list_users_modified_since` filtered on `lastModifiedDateTime` only. PingCAP's SF tenant applies an implicit `status='t'` filter whenever `$filter` doesn't constrain `status` — the *exact* trap `list_all_users` was fixed for on 2026-06-01 (PR #637) but the incremental path never inherited it. So the instant SF flipped `User.status` `t→f` (which SF does only when the effective date rolls over, not at HR data-entry time), the now-forfeited user was silently dropped from the modified-since feed even though their modification sat squarely inside the window. **(2)** Even once returned, `preview_incremental_sync` needs an `endDate`-bearing `EmpEmployment` to stage the offboard with a canonical `termination_date`; that row is often modified on an *earlier* tick (HR keys terminations ahead of the effective date), so when its modification predates the lookback window the leaver fell through to an `onboard`. **Fix**: `list_users_modified_since` now pins `(status eq 't' or status eq 'f') and lastModifiedDateTime ge …` (mirrors `list_all_users`); `preview_incremental_sync` back-fills `EmpEmployment` via `get_employments_for_users` for any modified user SF now reports `status='f'` with no in-window `endDate` row. Keyed on `status='f'` so genuine rehires (`status='t'` + stale terminated `EmpEmployment`) are never offboarded — the existing 2026-06-14 rehire-guard regression still holds. Until this shipped, such leavers only self-healed on the weekly `hris.full_sync` (Sun 05:00 SGT). Regression: [`test_hr/test_incremental_leaver_blindspot_2026_07_03.py`](../../server/tests/test_hr/test_incremental_leaver_blindspot_2026_07_03.py).

**Multi-instance add-order race → future hire mis-created as a cross-tenant leaver** (post-mortem 2026-07-06, Camila P10009 — EU/Brazil hire, hire_date 07-06, stuck in Helix as `left` with a synthetic `@successfactors.local` email and no account). PingCAP runs CN (`api30`, `is_primary`) + EU (`api55`) SF tenants that **mirror one org**, and ops enters overseas hires in BOTH. Normal onboarding-module hires are safe (the first lifecycle event always comes from the owning DC), but a hire added via **import / front-end** has no ordering guarantee between the two DCs. Two bugs compounded: **(1)** `preview_full_employee_sync` routed **every** SF `User.status='f'` user to the offboard branch — but `'f'` (inactive) covers BOTH past leavers AND *future hires who haven't started yet*. The CN `run_scheduled_full` (auto-confirm) ran on 07-03/07-05 while Camila's hire was still future (`status='f'`, plus an SF `endDate` artifact predating her start), staged her as `deactivate`, and created her as a **CN-owned `left`** row. **(2)** When the EU onboard webhook arrived 07-06, `_upsert_person`'s cross-instance empId dedup found the CN-owned row and raised `ConflictError` → the owning tenant could never reclaim her. **Fix (two layers, order-independent self-heal)**: (a) the `status='f'` branch **skips future-dated hires** (`hireDate > today`) entirely — a not-yet-started person can't be a leaver; the owning DC's onboard creates them at start. (b) `_upsert_person`'s cross-instance empId conflict now lets an **`onboard` event reclaim** a row owned by another instance **when that row is `left`/`leaver_pending`** (re-attribute `hris_instance_id` + the onboard branch reactivates to `active`, clears `termination_date`); an `active` row on another tenant still raises `ConflictError` (genuine dual-ownership, manual resolution). **(c) HRBP/HR Ops resolved at onboard-event time**: the account-creation card (`_notify_admins_google_account_outcome`) picks recipients from `person.hris_hrbp_person_id`/`hris_hrops_person_id`, but those columns are filled by `apply_hris_role_relationships` in the **full-roster sync** — the webhook onboard path left them NULL, so an event-driven build carded only the IT contact. Fix: `list_job_relationships` gained an optional `user_ids` server-side filter; `apply_hris_role_relationships(..., scoped_pull=True)` does a ~3-row per-hire pull; `process_webhook_events` resolves the hire's HRBP/HR Ops **before** the coordinator dispatch so the card reaches them at build time (apply-only + best-effort — a hire whose SF relationships aren't entered yet, e.g. jianli.wang P01569, degrades to IT-only, which is correct). Regression: [`test_hr/test_full_sync_forfeited.py`](../../server/tests/test_hr/test_full_sync_forfeited.py) (future-hire skip + past-leaver guard) and [`test_hr/test_multiinstance_personaldata_2026_07_02.py`](../../server/tests/test_hr/test_multiinstance_personaldata_2026_07_02.py) (onboard reclaim + active-row conflict guard + scoped relationship pull + webhook wiring). **2026-07-16 follow-up — the same race, onboard flavour**: the incremental sync onboards pre-start hires (needed for 24h-ahead provisioning), so a mirrored pre-start hire surfacing first in the NON-owning tenant's window was created with the wrong `hris_instance_id` and permanently owned by it (the 2026-05-02 cross-instance guard only fires once a row exists, and then fires against the TRUE owner forever). Fixed with the `owned_company_codes` canonical-owner mapping — non-owner defers creation, owner reclaims poached rows; see gotcha #13 below and [`test_hr/test_onboard_poach_2026_07_16.py`](../../server/tests/test_hr/test_onboard_poach_2026_07_16.py).

**Scheduler-liveness watchdog** (added 2026-05-12, widened 2026-05-15 PR #316): a foreground asyncio task (separate from APScheduler) in `_run()` measures **per-job** staleness across every enabled interval-job and exits the worker when any one wedges. If `stale_seconds > max(30 min, 60 × interval)` AND that specific job has had time to fire at least twice (`uptime ≥ max(5 min, 2 × interval)`), the watchdog signals shutdown with exit code 1 so docker-compose's `restart: unless-stopped` recreates the container. Catches the wedge case the 2026-05 incident exposed: worker process alive, APScheduler silently stopped dispatching, prod deploys kept succeeding ("python -c sys.exit(0)" healthcheck is a no-op) while leaver-blockers piled up for 3+ days. The "fastest-job" measurement that shipped in round 20 missed a different class of wedge — one handler's import dropped while every other job keeps firing healthily (the 2026-05-15 `identity_sync.dispatch` case: silent for 9 days while `provisioning.dispatch` 30s ran healthy and dominated the staleness measure). PR #316 switched to **worst-stale-AND-actionable** measurement: `_measure_scheduler_staleness(uptime_seconds)` picks the highest `stale/interval` ratio AMONG jobs past their per-job grace, returning `(stale_seconds, interval_seconds, job_name)` so the wedge log identifies which job is dead. Per-job grace inside the helper prevents a slow never-ran job (e.g. 13h cadence) from eating the entire watchdog window and masking a faster job's wedge. **2026-08-18 — the signal is the last DISPATCH, not the last completion**: the helper reads `max(job.last_run_at, latest scheduled_job_runs.started_at)` and falls back to `created_at` for a row never dispatched at all. `last_run_at` alone is written only in the terminal write-back, so a handler that outlives the 300s grace leaves it `NULL` forever — the watchdog then kills a worker whose scheduler is provably alive, and the restart guarantees the handler never finishes, so the next boot reads `NULL` again. That deadlock crash-looped prod every ~5m20s for ~16h (the 60s `intelligence.cloud_scope_baseline_bootstrap` sweeping 271 cloud scopes), abandoned 407 runs in `running`, truncated every long cloud-cost ingest mid-flight, and surfaced as a bogus "vendor pagination truncated" alert from `cmdb.cost_facts_chain_check`. `started_at` is written at dispatch — for skipped and abandoned runs too — so it is green exactly when APScheduler is alive and red exactly when it wedges. **Corollary for any job-level give-up logic**: `run_count` shares the same terminal-write-back blind spot, so a bounded-retry that keys on it is unreachable for a handler that keeps getting killed — count non-`skipped` run rows instead (the bootstrap row's own `max_attempts` escape hatch was dead for this reason). The admin UI surfaces the same signal at `/admin/scheduled-jobs` via the `GET /scheduled-jobs/health` endpoint (banner lights up at 5× interval — earlier than the watchdog's 30-min cap), so an operator can spot a wedge before the auto-restart fires. Tests: [`test_worker_watchdog_2026_05_12.py`](../../server/tests/test_worker_watchdog_2026_05_12.py).

**Boot-time handler-registry guard** (added 2026-05-15 PR #316): `_assert_all_enabled_handlers_registered()` runs in `start_schedulers()` AFTER cloud-row reconcile and BEFORE the first `_register_scheduled_jobs()` call. Queries every enabled `scheduled_jobs` row and crashes startup with a `RuntimeError` listing ALL handler_keys whose handler module isn't loaded in this worker process. Replaces the pre-PR-#316 silent `logger.warning + skip` inside `_register_scheduled_jobs` — that path is what hid the `identity_sync.dispatch` 9-day wedge: its handler module (`app.domains.identity_sync.scheduler_handler`) was only imported by `app/main.py` (API process), so the worker's narrower import graph silently dropped the row. **Implication for new handlers**: any new `@register_scheduled_job` decorator outside `app.scheduled_jobs.*` (e.g. `app.domains.<x>.scheduler_handler`) MUST be explicitly imported in `app/worker.py::_run()` (the API process gets these transitively via `app/main.py`, but the worker does not). The boot guard turns "forgot the import" from a 9-day silent miss into a docker restart loop with a loud traceback within minutes. Tests: [`test_worker_handler_boot_guard_2026_05_15.py`](../../server/tests/test_worker_handler_boot_guard_2026_05_15.py).

**Graceful scheduler shutdown** (added 2026-06-02): on SIGTERM/SIGINT `_run()` now calls `_stop_schedulers(scheduler, reload_task)` BEFORE returning — i.e. before `asyncio.run` tears the loop down. It cancels the hourly reload loop, `scheduler.pause()`s to stop dispatching new jobs, then drains in-flight jobs (tracked in the module-level `_inflight_jobs` set, populated by `_run_scheduled_job`) within a bounded `SHUTDOWN_DRAIN_GRACE_SECONDS = 8` window (kept under docker's default 10s SIGTERM→SIGKILL grace), cancels any stragglers, and `scheduler.shutdown(wait=False)`s. Why it exists: APScheduler's `AsyncIOExecutor` runs each cron job as a bare `loop.create_task`. Pre-fix, the worker left the scheduler running and let `asyncio.run`'s teardown abandon in-flight jobs — so a job (e.g. a CMDB cloud sync) could still hit `asyncio.to_thread` AFTER the loop's default `ThreadPoolExecutor` was shut down, and Python 3.12's `_check_default_executor` raised `RuntimeError: Executor shutdown has been called`. Observed in prod as `cmdb.resource_sync <id>/iam: Executor shutdown has been called` on every restart that overlapped a running sync (the AWS/Tencent/Aliyun-Intl IAM capability reaches the cloud SDK via `asyncio.to_thread`). `_run_scheduled_job` treats the resulting `CancelledError` as an orderly stop — no synthetic failed run row, no failure-notifier (the job re-runs next tick; all handlers are idempotent). Tests: [`test_worker_graceful_shutdown_2026_06_02.py`](../../server/tests/test_worker_graceful_shutdown_2026_06_02.py).

**`scheduled_jobs.id` MUST be seeded as a dashed UUID — NEVER `REPLACE(UUID(),'-','')`** (3rd recurrence fixed 2026-06-03, migration 248; prior fixes 059, 162). The `id` column uses the `GUID` TypeDecorator (`app/core/sql_types.py`) which stores/normalises to the 36-char **hyphenated** CHAR(36) form. A seed migration that inserts `REPLACE(UUID(),'-','')` puts a 32-char no-dash value in the column. The worker reads the row (GUID dashes it), schedules APScheduler with `job_id_str = str(row.id)` (dashed), and on fire the run-wrapper does `session.get(ScheduledJob, uuid.UUID(job_id_str))` → `WHERE id = '96c704e5-5d88-…'` (dashed) which never matches the stored no-dash value → the handler raises `NotFoundError` **outside** the run-wrapper, so no `scheduled_job_runs` row is written and `run_count`/`last_run_at` stay 0/NULL forever. Symptom: an enabled job that "never runs" while the scheduler is otherwise healthy; the `/admin/scheduled-jobs` staleness banner ("APScheduler 可能停摆") fires on interval jobs because it measures elapsed since `created_at` when `last_run_at` is NULL. The watchdog *will* exit(1) once such an interval job is past its grace (observed 2026-06-03 11:08:53), but the restart can't help — the corruption is in the DB row, not the process. Detection: `SELECT id,name FROM scheduled_jobs WHERE id NOT LIKE '%-%'`. Fix: idempotent re-hyphenate UPDATE (migration 248) + seed migrations corrected to `UUID()`. Regression: [`test_scheduled_jobs/test_id_dashes_2026_06_03.py`](../../server/tests/test_scheduled_jobs/test_id_dashes_2026_06_03.py). The 2026-06-03 incident stranded the onboarding trio (#628/migration 229), `auditor.privileged_review_escalate` (#632/migration 232+233) and `role_change.scope_review_sweep` (#646/migration 236) for ~2 days.

**Deploy force-recreate** (hardening, 2026-06-03, #706): [`deploy/scripts/deploy.sh`](../../deploy/scripts/deploy.sh) runs `docker compose up -d --remove-orphans --force-recreate` so the worker always comes up on the deployed image regardless of tag mutability. Auto-deploys pass an immutable `sha-<...>` tag (so plain `up -d` already recreates on the changed reference); a *manual* `Production Deploy` dispatch defaults to the mutable `main` tag, where recreate would otherwise depend on compose detecting the digest change. This guarantees a new code-registered handler takes effect on the next deploy. (This is hardening — #706 originally shipped it on a wrong "deploy doesn't restart the worker" diagnosis; the worker does restart on every deploy, and the real stranded-cron cause was the no-dash-UUID bug above.) compose.prod has no stateful services (DB is TiDB Cloud), so force-recreate is safe.

### Registered handler keys (184 decorators; DB rows are reconciled)

The codebase currently contains 184 `@register_scheduled_job(...)` decorators under
[`server/app`](../../server/app). Enabled runtime rows are DB-driven and reconciled by the
worker at boot/reload, so this list is an orientation map rather than a generated inventory.

```
hris.incremental_sync, hris.full_sync,  ← full_sync added 2026-06-01 (migration 235):
                                            weekly Sun 05:00 SGT cron that runs
                                            preview_full_employee_sync + auto-confirm
                                            per-instance. Closes the 30-day blind
                                            spot of incremental — historical SF
                                            leavers (status='f' lastModifiedDateTime
                                            beyond 30d) used to be invisible forever
                                            (wangdi P0175 case, 73 days). Migration
                                            seeds last_run_at='1970-01-01' so the
                                            worker's "missed beat" branch fires the
                                            first run ~90s after deploy and lands
                                            the historical backfill on the same
                                            schedule as a normal weekly tick.
hris.resume_stuck_apply,  ← migration 293, 2026-06-16: 60s restart-safety
                                            backstop for the async HRIS batch
                                            apply. confirm now flips the batch to
                                            status='applying' + returns 202, and
                                            background.spawn runs the heavy
                                            per-event apply (_apply_staged_events)
                                            off the request thread — fixes the
                                            full-bootstrap confirm timing out the
                                            frontend (axios abort → nginx 499). This
                                            cron resumes any 'applying' batch whose
                                            apply_heartbeat_at went stale (backend
                                            process died mid-apply), preserving the
                                            original reviewed_by; fresh-heartbeat
                                            in-flight runs are left alone. Idempotent
                                            re-entry (per-event status guards). Same
                                            inline-primary + cron-backstop shape as
                                            migration 239 / PR #279.
hris.absent_person_check,  ← absent_person_check upgraded
                                                      to write soft tombstone
                                                      (migration 222, 2026-05-30):
                                                      sets hris_record_state='purged'
                                                      + hris_purged_at/reason + a
                                                      hr_lifecycle_events row when SF
                                                      no longer carries a person. Never
                                                      hard-deletes. employment_status
                                                      stays untouched by design.
hr.overdue_leavers_check, hr.hire_day_it_notify,
hr.hire_day_feishu_welcome,  ← migration 375; every 30 min. The Day-1
                               welcome EMAIL fires at 09:00 but the hire's
                               Feishu account is only minted minutes later
                               by identity-sync (open_id lookup at email
                               time = permanent dead-letter). This sweep
                               sends the rich Feishu welcome CARD once the
                               email resolves to an open_id; window = 3
                               days after it_info_sent_at; idempotent via
                               "feishu" ∈ it_info_sent_channels; the card
                               outbox row is hidden_from_inbox (the email
                               row already sits in the personal inbox).
hr.day90_survey_notify,       ← migration 377; daily 02:00 UTC. Invites
                               hires at 90 days of tenure (EMPLOYEE-local
                               calendar) to the onboarding-experience
                               survey — Feishu turquoise card + rich HTML
                               email, one envelope, bilingual. Idempotent
                               via identity_persons.day90_survey_sent_at;
                               90–97-day catch-up window absorbs missed
                               ticks and never storms long-tenured
                               employees at rollout.
hr.pending_leaver_notice,     ← migration 581; 0 9-19 * * * Asia/Shanghai.
                               HR 的《离职流程指引》,在 SF 离职审批批完的
                               那一刻推给本人 —— 比 OffboardingProcess 早
                               几周(见下方「审批已完成」判据小节)。范围:
                               is_china_employee + employee_type_code ∈
                               {1070 正式, 1072 实习生},命中连接器
                               excluded_termination_reason_codes 的不推。
                               两份三语模板(regular / intern),北京段落按
                               工作城市而非 office_code 分流。幂等 =
                               identity_persons.offboarding_notice_sent_at
                               + offboarding_notice_last_day(HR 改期才重
                               推;零渠道投递不盖戳)。范围/开关全部从 job
                               config 读:employee_type_codes / china_only /
                               lookahead_days / dry_run。
hr.email_name_reconcile,      ← migration 582; 20 8 * * * Asia/Shanghai.
                               对账 identity_persons.hris_email_name(HR 在
                               SF PerPersonal.customString4 指定的邮箱名)与
                               实际 work_email 的 local-part。只读 Helix 自己
                               的两列,不打 SF / Google。① 未建 Google 号且不符
                               ⇒ 自动重跑 reserve_work_email_for(顺带纠正 SF
                               PerEmail 回写与入职卡片上的预告地址);② 已建号
                               且不符 ⇒ Helix 不动,推三语卡给 global_admin(建号
                               后改名要同时动 Google Directory / 飞书 / SF 回写 /
                               Helix identity 行,不是 cron 该做的事)。hire_date
                               180 天窗口:HR 在老档案上补填字段不该去改一个人用
                               了几年的邮箱。config: dry_run / lookback_days。
                               这是「以后不准再有」那道网 —— 它不依赖建号路径正确,
                               所以下一个还没想到的形状也会在当天被看见。
                               **2026-09-07(P01574)误报修正**:上线以来这个
                               告警在生产上只产出过一条,而且是误报 —— HR 把
                               姓名 'Koji Betsuyaku' 填进了邮箱名字段,员工的
                               koji.betsuyaku@ 本来就是对的,老派生把空格连同
                               其它非字母一起删掉读成 kojibetsuyaku,于是每天叫
                               IT 去把一个正确地址改错。三层同修:①
                               _local_part_from_hris_email 把空格/下划线折成点
                               (名 姓 → 名.姓,公司约定),3 段以上的显示名弃用
                               回落到 firstName/lastName 派生;② 新谓词
                               _spelling_variant —— 只抑制两种且刻意只有两种:
                               字母相同只差标点、以及去重后缀(wei.chen01,平地址
                               属于别人 ⇒ 改名根本不可能),落 summary 的
                               spelling_variants 桶(可见、不进收件箱)。**词序
                               (chen.wei vs wei.chen)刻意不抑制** —— 名.姓 约定下
                               两者必有一个错,要人来判,生产也从没出现过这个形状,
                               顺手铺开会静默吞掉真发现;③ 卡片
                               打印真正会铸出来的完整地址而不是 HR 原始字符串
                               (旧卡片打印的 '→ Koji Betsuyaku@' 连合法地址都
                               不是)。同时 reserve 返回 skipped 不再记进
                               re_reserved(良性永久跳过每天被记成一次成功修复),
                               改记 skipped 桶 —— 也不进 errors,否则 HR 的
                               P99XX 测试记录会让 cron 每天红一次。SF 里那个脏值
                               本身改由 hris_email_name_malformed 数据质量发现
                               交给 HR 清。regression:
                               test_hr/test_email_name_display_name_2026_09_07.py
hr.process_advance, hr.post_leaver_verify, hr.daily_digest,
hr.recover_orphaned_left
hr.offboarding_delete_preview         ← migration 468; daily 23:30 UTC (the evening
                                        BEFORE the 03:00/03:10 delete sweeps). Both
                                        sweeps tell NOBODY on success — an account is
                                        permanently gone (Zoom) or in a ~20-day recovery
                                        window (Google) with no human given a chance to
                                        intervene, because the failure notifier only
                                        fires on errors. This is the heads-up: one
                                        IT-only digest naming tomorrow's deletes AND
                                        whether each handover is actually in place
                                        (verified transfer / will-run / none). States the
                                        thing no API can fix: ⚠️ Gmail message content is
                                        NOT transferable (Data Transfer moves Drive +
                                        Calendar only) — a mailbox that must survive has
                                        to be marked retained in external_account_registry
                                        BEFORE the delete. Due-date computed in the
                                        LEAVER's timezone. One warning per account, ever
                                        (reminder kind del_preview) — blocked deletes have
                                        their own one-time page-out and a daily repeat
                                        would train people to ignore it. Owner:
                                        OffboardingService.preview_pending_terminal_deletes.
hr.offboarding_handover_reconcile     ← migration 467; daily 02:40 UTC. Backfills
                                        offboarding_processes.default_handover_person_id
                                        where it is NULL, using the SAME chain as the
                                        terminal-delete gate (manager → manager chain →
                                        offboarding.default_handover_fallback_person_id).
                                        Runs BEFORE the 03:00/03:10 delete sweeps so a
                                        same-morning backfill unblocks the same morning's
                                        delete. Scope: open processes + completed ones
                                        whose vendor deletes are NOT all terminal
                                        (_handover_still_matters); cancelled/finished are
                                        skipped. Only ever fills a NULL — never overwrites
                                        IT's explicit choice. Leavers whose whole chain is
                                        gone get ONE digest to IT (reminder kind
                                        handover_unresolved); 'unresolved' is an alerted
                                        state, NOT a cron error. Owner:
                                        OffboardingService.reconcile_default_handovers.
hr.offboarding_google_account_delete  ← migration 395; daily 03:00 UTC. Terminal
                                        disposition of a leaver's Google account:
                                        hard-DELETE (users().delete) at
                                        leaver_date + delete_lead_days (config,
                                        default 2, employee-local TZ) for accounts
                                        already SUSPENDED (leaver_date+1) and NOT
                                        retention-exempt. Google keeps ~20 days
                                        undelete recovery = mistake safety net.
                                        ⚠️ Since 2026-08-13 the delete is GATED on a
                                        proven Drive/Calendar handover (see the
                                        terminal-delete handover gate below) — the
                                        old "no data transfer" behaviour deleted two
                                        prod leavers' content. Rehired/active + not-yet-
                                        confirmed-suspended skipped. Idempotency
                                        key session_enforcement.google_account_deletion
                                        (deleted/already_absent/retained terminal).
                                        Owner: OffboardingService.sweep_google_account_deletions
                                        → account_lifecycle_coordinator.delete_google_account.
hr.offboarding_zoom_account_delete    ← migration 451; daily 03:10 UTC. Terminal
                                        disposition of a leaver's Zoom account,
                                        PARITY with the Google job above: hard-DELETE
                                        (delete_user_unconditional, DELETE ?action=
                                        delete) at leaver_date + delete_lead_days
                                        (config, default 2, employee-local TZ) for
                                        accounts already DEACTIVATED (leaver_date+1 via
                                        _push_force_logout) and NOT retention-exempt.
                                        ⚠️ Zoom has NO undelete window (unlike Google)
                                        — the deactivate→delete gap IS the only mistake
                                        window. Since 2026-08-13 the delete carries
                                        transfer_email + transfer_meeting/webinar/
                                        recording and REFUSES to run without a resolved
                                        successor (reverses the 2026-08-04 "hard-delete
                                        all, no transfer" decision — see DECISIONS).
                                        Addresses
                                        the vendor by work_email → does NOT depend on
                                        identity_sync external_id (NULL for ~all baseline
                                        no_meetings accounts). Rehired/active + not-yet-
                                        confirmed-deactivated skipped. Idempotency key
                                        session_enforcement.zoom_account_deletion
                                        (deleted/already_absent/retained terminal).
                                        Owner: OffboardingService.sweep_zoom_account_deletions
                                        → account_lifecycle_coordinator.delete_zoom_account.
                                        **Transfer-recipient refusals (2026-09-06)**:
                                        Zoom validates transfer_email BEFORE deleting
                                        and refuses the whole call when the recipient
                                        can't receive. The driver tags the family
                                        (ZOOM_TRANSFER_RECIPIENT_NO_WEBINAR /
                                        _INVALID) and the coordinator forwards it as
                                        `transfer_refusal`, so the sweep branches on
                                        a tag, not on vendor prose: no-webinar-license
                                        → delete anyway without transfer (operator
                                        policy 2026-08-23); anything else (chiefly
                                        a successor with no Zoom account) → REFUSE,
                                        ledger blocked_transfer_recipient (NON-
                                        terminal), own counter, page IT once via
                                        reminder kind del_blocked_zoom_recipient.
                                        Clears itself once IT names a successor Zoom
                                        accepts — no replay needed.
hr.offboarding_feishu_deferred_delete  ← migration 407; daily 03:30 UTC. Retry of
                                         the offboarding Feishu terminal delete when
                                         it was DEFERRED: the leaver had open
                                         self-submitted Feishu approval instances
                                         (回购 second step parks on the submitter's
                                         OWN node — deleting the submitter strands
                                         it terminally; the Helix supplement
                                         fallback also needs the account to exist).
                                         While deferred the account is FROZEN
                                         (access cut). Instances closed → delete;
                                         still open past defer_max_days (config,
                                         default 30, leaver-local TZ) → treated as
                                         abandoned, deleted anyway (audited
                                         deferral_expired). Transient delete
                                         failures stay deferred → retried next tick.
                                         Idempotency key session_enforcement.
                                         feishu_account (= delete_deferred_open_
                                         approvals while deferred). Owner:
                                         OffboardingService.sweep_deferred_feishu_
                                         account_deletions
                                         → account_lifecycle_coordinator.delete_feishu_account.

offboarding.reconcile_processes
offboarding.revoke_leaver_platform_roles  ← migration 290; daily sweep of
                                            employment_status='left' people still
                                            holding privileged platform roles
                                            (global_admin / auditor / cloud_admin /
                                            hr / finance / management /
                                            asset_global_admin) → revoke via
                                            AuthService.revoke_platform_roles_for_leaver.
                                            Backstop for the inline revoke at
                                            offboarding completion (backfills leavers
                                            completed before the step existed + drift).
                                            employee + reconciler-derived asset_admin
                                            left alone; last global_admin skipped+warned.

cmdb.resource_sync, cmdb.scopes_sync, cmdb.roles_sync, cmdb.grants_sync,
cmdb.billing_sync, cmdb.billing_daily_sync, cmdb.cost_facts_sync,
cmdb.cost.nightly_recompute_allocation,
cmdb.access_expiry_sweep, cmdb.scope_idle_sweep, cmdb.leaver_grant_sweep, cmdb.manual_revoke_retry_sweep,
cmdb.cost_facts_monthclose          ← migration 247; monthly DISPATCHER (2026-07-05): fans the prior
                                       month's re-pull out to each account's own cmdb.cost_facts.<slug>
                                       job in chunk_days-day sub-windows (NOT an inline single-process loop)
cmdb.auto_owner_grant_backfill      ← migrations 239/241/244; backfills the auto-Owner
                                       grant for scopes provisioned before the
                                       post-provision finalize task existed; per-scope
                                       unresolvable backoff marker
cmdb.resource_creator_sweep         ← attributes cloud servers to their creator via
                                       provider activity/audit lookups (owner inference)
cmdb.dns_owner_recertify, cmdb.dns_pre_leaver_handover,
cmdb.dns_request_executor           ← migration 469; EVERY MINUTE; drains
                                       self-service DNS requests sitting at
                                       `approved` and performs the provider
                                       write in the worker. The Feishu approval
                                       callback no longer writes to the cloud
                                       inline — it commits `approved` and
                                       returns (Feishu redelivers a webhook it
                                       hasn't heard back from in ~3s, and a
                                       DNSPod write takes seconds).
cmdb.dns_request_execution_reaper   ← migration 469; every 10 min; un-strands
                                       rows parked at `executing` past
                                       stale_minutes (10). `create` is
                                       reconciled against the LIVE provider
                                       first — record already present ⇒ the
                                       interrupted write landed ⇒ finish the
                                       bookkeeping, never re-create; other
                                       actions are idempotent ⇒ re-arm to
                                       `approved`. Past max_attempts (3) the
                                       row lands `failed` with a message the
                                       admin resume gate accepts.
cmdb.domain_expiry_alert,
partner_directory.sync              ← migration 587; daily 06:40 Asia/Shanghai;
                                       read-only pull of a partner company's Feishu
                                       roster + cross-domain attribution of our own
                                       unmatched accounts. HEAVY. Runs BEFORE the
                                       vendor seat-reconciliation crons so the day's
                                       external_guest classifications are the ones
                                       today's roster implies. Fails the run when a
                                       connector pulls **0 people** — that is exactly
                                       what a revoked contact scope looks like from
                                       Feishu (code 0, empty body), so there is no
                                       exception to rely on.
cmdb.cost_facts_raw_purge           ← migration 584; daily 03:35 Asia/Shanghai;
                                       NULLs cmdb_cost_facts.raw beyond the
                                       retention window (default 90d, floor 45d,
                                       platform_settings-tunable). Day-at-a-time +
                                       2000-row commits (TiDB membuffer 8176 / GC
                                       safepoint); watermark in platform_settings so
                                       a steady-state tick advances exactly one day.
                                       Pairs with an ingest-side guard — without it a
                                       backfill re-populates raw below the watermark.
cmdb.cert_probe_sweep               ← migration 267; daily 03:00 UTC active TLS
                                       handshake probing (targets from DNS + domain
                                       apexes); data collection, not kill-switch gated
cmdb.cert_expiry_alert              ← migration 267; daily 04:00 UTC; 30/7/1/expired
                                       buckets + state-table dedup, clones
                                       domain_expiry_alert; 网络负责人 fallback
cmdb.owner_semiannual_confirmation  ← migration 169; daily 09:00 Asia/Shanghai;
                                       non-prod scopes due for 180-day owner
                                       confirmation get a Feishu card + 14-day
                                       no-response escalation to IT admins
cmdb.zero_spend_alert               ← migration 169; monthly 1st 09:00;
                                       non-prod scopes whose last calendar
                                       month spend = $0 prompt owner with
                                       keep(reason) / cleanup / handover-IT
cmdb.server_owner_semiannual_confirmation
                                     ← migration 216; weekly Mon 09:00 Asia/Shanghai;
                                       per-OWNER aggregated card listing every
                                       cloud server they own that's past its
                                       180-day confirmation window. Skip rules:
                                       no owner / owner leaver / first_seen_at
                                       <90d (newborn) / status in (stopped,
                                       decommissioned). One row in
                                       cmdb_server_owner_confirmations per
                                       (owner, period_start) — UNIQUE there
                                       is the same-day idempotency anchor.
cmdb.server_owner_confirmation_reconcile
                                     ← migration 416; daily 09:30 Asia/Shanghai;
                                       re-applies the scan eligibility rules to
                                       every PENDING confirmation snapshot —
                                       drops servers that stopped / soft-deleted /
                                       re-assigned / went archival AFTER the card
                                       went out, auto-resolves emptied rows
                                       (status=auto_resolved), completes rows
                                       whose remaining servers were already
                                       owner-confirmed. Weekly card cron also
                                       runs it as step-0 so the re-prompt
                                       interlock never starves an owner on a
                                       dead pending row (2026-07-14 UCloud
                                       re-sync incident). Restored in migration
                                       416 after arcade PR #1992 reverted the
                                       original #1984 seed (migration 412).
asset.asset_admin_role_reconcile     ← migration 220; daily 03:15 Asia/Shanghai;
                                       backstop reconciliation of asset_admin
                                       role membership. In-process sync via
                                       app.domains.asset.location_admin_sync
                                       .replace_office_admins is the primary
                                       path (fires on every office-admin
                                       mutation); this cron rebuilds the grant
                                       set from asset_location_admins.person_id
                                       to recover from arbitrary drift
                                       (out-of-band SQL, dropped in-process
                                       commit, etc.). Cheap (~hundreds of
                                       rows max); safe to re-run.
identity.department_role_reconcile   ← migration 296; daily 03:05 Asia/Shanghai;
                                       backstop for the department→platform-role
                                       auto-derivation (Finance BU ⇒ finance, HR
                                       ⇒ hr). Engine: app.identity
                                       .department_role_sync. In-process sync
                                       (sync_department_roles_for_person) fires
                                       per-person on every HRIS membership
                                       upsert (successfactors_sync
                                       ._upsert_primary_membership); this cron
                                       rebuilds the derived grant set as a
                                       backstop. CRITICAL: only ever grants /
                                       revokes auth_user_roles rows whose
                                       grant_source='derived' — a manual finance
                                       /hr grant (grant_source='manual', the
                                       default for every other code path) is
                                       NEVER inserted-over nor revoked. Matching
                                       = any tenant-root-stripped org_path
                                       segment equal (case-insensitive) to a
                                       configured department name; defaults
                                       cover Finance/HR EN+ZH and are extended
                                       (never replaced) via the
                                       identity.department_role_map platform
                                       setting. Raises on missing finance/hr
                                       role rows (per feedback_handler_status
                                       _must_raise).

**`auth_user_roles.grant_source` provenance** (migration 296, 2026-06-17): a
`VARCHAR(16) NOT NULL DEFAULT 'manual'` column distinguishing admin-made grants
(`manual`) from reconciler-derived ones (`derived`). Currently only the
department-role derivation writes `derived`. The rail lets that reconciler own
its grants (grant on join, revoke on transfer-out) without ever clobbering a
manual grant. Every pre-existing row + every other grant path is `manual`.

**Cloud-sync lock contract** (consolidated 2026-05-13): the `cloud_sync:{integration_id}` Redis advisory lock (`distributed_lock(..., ttl=600)`) serializes every writer to `cmdb_cloud_scopes` / `cmdb_cloud_roles` / `cmdb_cloud_access_grants` for a given integration. Holders: the three cron handlers `run_scopes_sync` / `run_roles_sync` / `run_grants_sync`, the admin sync-now BG task `_run_scope_sync_background`, AND the post-provision finalize BG task `_post_provision_finalize`. Each uses the same `try / async with distributed_lock(...): / except LockBusy: status=skipped` shape. The admin BG chain additionally treats scopes→roles→grants as an authority dependency: a returned or raised `failed` / `partial_failure` stops all later stages. Audit state is derived from actual execution (`failure` when scopes failed, `partial` for later failure/mixed skip, `skipped` when all three skipped, otherwise `success`) and carries `failed_stages` / `skipped_stages`; recursive `error` / `errors` / `reason` fields pass through `redact_provider_error` before persistence. Thus an upstream provider failure cannot be swallowed into success, followed by destructive work against stale scope state, or copy a vendor-echoed credential into audit. Independent cron and explicit force endpoints intentionally remain single-stage operations; each holds the same lock and its own service/handler fail-closed guard rather than depending on a preceding job's historical result. Without this lock and ordering, concurrent or partial runs can stamp incompatible `last_seen_sync_job_id` values and silently close scopes, empty role catalogs, or revoke grants. **Rule**: every new caller of `sync_*_for_integration` MUST acquire this lock, no exceptions.

**Post-provision finalize task** (added 2026-05-13, not a cron): `_post_provision_finalize(scope_id)` in [`lifecycle_router.py`](../../server/app/domains/cmdb/lifecycle_router.py) is the FastAPI `BackgroundTasks` invoked from `POST /scope-requests/{id}/approve` and `/manual-provision` right after `await db.commit()` (when `req.status == provisioned`). Two steps under one acquisition of the `cloud_sync` lock:

1. **Role-catalog pre-warm** — calls `sync_roles_for_scope(scope_id)` in [`lifecycle_service.py`](../../server/app/domains/cmdb/lifecycle_service.py), a single-scope variant of `sync_roles_for_integration`. Populates `cmdb_cloud_roles` for the new scope so the "添加成员" dropdown is non-empty immediately instead of waiting up to 24h for the next `cmdb.roles_sync` tick. Deliberately omits mark-and-sweep — a per-scope sweep would close every other scope's roles on the same integration.
2. **Auto-Owner grant** — calls `CloudLifecycleService.provision_initial_owner_grant(scope_id, role_key, role_display_name)` which writes a `CmdbCloudAccessGrant` row for the requester AND drives `driver.grant_access`. Per-provider map at `_DEFAULT_OWNER_ROLE_BY_PROVIDER` in [`lifecycle_router.py`](../../server/app/domains/cmdb/lifecycle_router.py): **GCP** static (`roles/owner`, `setIamPolicy`); **Azure / Tencent / UCloud** static too (built-in Owner roleDefinition GUID / `AdministratorAccess` CAM policy name / `AdminPolicy`), added 2026-08-17 — manual-provision-only providers reach this same finalize through the rescue path, which is why omitting them shipped zero-IAM owners for three months; **AWS** dynamic via `_resolve_default_owner_role` — reads platform_setting `cloud.aws.scope_request_default_owner_role_name` (default `DBaaS-DevLeader-Role`), lists Identity Center permission sets, matches by display name, and returns the resolved ARN for binding via `sso-admin.create_account_assignment` (added 2026-05-25). The requester appears in the scope's member list AND has real cloud-side IAM the instant the bg task finishes, no manual self-add step. Idempotent: `ConflictError` on re-run is treated as a no-op (the grant already exists). If AWS-side resolution returns no match (permission set renamed / never existed), `_post_provision_finalize` audit-logs the skip and the requester can self-bind via the standard owner-add flow. Driver failure lands the grant in PENDING so the requester can retry via the standard owner-add flow.

Failures in either step are logged + best-effort: the scope itself is already provisioned, and the cron remains the ultimate backstop. The grant-side failure mode is deliberately non-fatal — the auto-Owner is a UX convenience, not a correctness invariant; the four-eyes approval that minted the scope already happened.

**Third caller — owner transfer/set** (2026-07-20): `PATCH /cmdb/scopes/{id}` with `owner_person_id` (`update_scope`) now **inline-awaits `_post_provision_finalize(scope_id)` after `db.commit()`** so a person set/transferred as Owner gets the same owner-equivalent grant + real cloud IAM the create path gives — closing the split-brain where **sync-imported** accounts (no `cmdb_cloud_scope_requests` row → excluded from `cmdb.auto_owner_grant_backfill`'s candidate set) showed an `owner_person_id` with an empty member list (reported on AWS 646528577659). Awaited inline (not a `BackgroundTasks`) because prod uvicorn silently drops those. **Symmetric outgoing side**: `assign_scope_owner` gained `revoke_previous_owner_grant` — when the transfer dialog's opt-in checkbox is set, it revokes the **outgoing** owner's owner-equivalent grants on the scope **inside the rebind txn** (so rebind + revoke commit atomically). "Owner-equivalent" = `detail.auto_owner_grant is True` OR role matches the provider default owner role, resolved **without a driver call** (AWS by the configured perm-set NAME from platform_settings; static providers by `role_key` from `_DEFAULT_OWNER_ROLE_BY_PROVIDER`); the ex-owner's unrelated requested/discovered member grants survive (product decision: owner-equivalent only). Revoke uses `revoke_grant(suppress_driver_error=True)` so a provider hiccup can't roll back the transfer (row flips revoked, `requires_manual_cloud_revoke` hands the cloud unbind to reconciliation). **Both sides skip prod scopes** (`environment in PROD_ENVIRONMENTS`) — prod IAM stays under the write lock, keeping the two sides symmetric and avoiding a Helix-revoked/cloud-still-granted split-brain. Frontend: `TransferOwnerModal` renders the opt-in checkbox only for non-prod scopes that have a current owner.

**Sync skipped-unresolved feedback** (added 2026-05-13): `GET /cmdb/scopes/{scope_id}/last-sync-summary` (RBAC: `can_read_cloud`) reads the most recent `cloud.scope.sync_now` audit entry and returns the per-scope `skipped_unresolved` count (cloud-side principals whose email didn't match any active Helix identity), plus `upserted`, `touched`, and `errors`. Consumed by the scope detail page to show an Alert banner when principals were skipped. MCP tool `get_cloud_scope_last_sync_summary` ships alongside it (`_REGISTERED_TOOLS_COUNT_HINT` 137 → 138). The count is stored in the audit row's `grants_detail.skipped_per_scope[scope_id]` dict on each sync run.

apikey.budget_check, apikey.expiry_check, apikey.rotation_retire
apikey.idle_check            ← monthly (3rd 09:30 Asia/Shanghai): last calendar month $0 spend on an active key ≥ 45 days old → ledger row (apikey_idle_alerts_dedup) + IT digest; holder card gated by platform setting apikey.idle_alert.enabled_for_employees (default OFF). See apikey/idle_alerts.py (migration 603).
data_access.grant_lifecycle  ← daily (09:30 Asia/Shanghai): stamp missing expiry / owner-confirmation clocks (≥30d floor), holder reminders on the T-14/7/1 ladder (gated by data_access.grant_lifecycle.employee_push_enabled, default OFF — withheld reminders do NOT advance the ladder), expiry revoke via the DAX revoke path (auto_revoke_on_expiry, refuses to enable without the push), service-account owner confirmation request / weekly reminder / lapse revoke (owner_confirmation_push_enabled + auto_revoke_on_confirmation_lapse), IT daily digest (outbox-deduped per day). See data_access/lifecycle.py (migration 605).
permission.lifecycle_sweep   ← daily (09:50 Asia/Shanghai): pending_approval reminders on permission.approval.reminder_days (3/7) + IT escalation after escalate_to_it_after_days; active grants past expires_at get a REVOKE provisioning task whose success hook lands the row on `expired` (not `revoked`). See permission/lifecycle.py (migration 605).
auditor.self_attestation_advice ← daily 01:30 UTC: batched AI advice for the open self-attestation campaign (one auditor_self_attestation_advice row per holder). No-op while access_review.self_attestation.ai_advice_enabled is off (the default). Per-holder failures are recorded on the row and retried next run; ONE holder failing does not fail the run (a holder without a narrative still has a usable review page) — **but a run with `holders_annotated == 0` AND at least one `no_client`/`call_failed` DOES fail**, because "tolerate a partial failure" was silently standing in for "stay quiet while the feature is entirely down", which stamps SUCCESS on a night that generated nothing for anybody. `no_items` (a fact about the holder) and `ungrounded` (the grounding gate working) are deliberately excluded — a gate that pages on its own correct operation gets switched off. The raise comes AFTER the commit so the per-holder `failure_code` rows, the only record of WHY, survive it (migration 621; total-failure gate 2026-09-06).
software_seat.usage_signal_refresh ← daily 02:40 UTC (before software_seat.idle_reclaim): reads each collectable vendor's roster once and upserts asset_seat_usage_signals (jira / google_workspace / zoom). A vendor that cannot be read leaves its existing rows ALONE — a stale-but-real row is better evidence than a fresh lie, and the freshness gate retires it on its own schedule; failures are collected per provider and re-raised so the run is marked failed. See integrations/usage_signal_service.refresh_usage_signals (migration 612).
offboarding.account_assurance_rosters ← daily 04:10 UTC (after identity_sync.vendor_snapshot_refresh 03:00 and leaver_assurance 03:47): one offboarding_account_assurance row per (leaver x revocation-enabled identity-sync target), system=`isync:<slug>`, plus a snapshot-freshness heartbeat per target (chain_key `identity_sync.snapshot:<slug>`). DB-only; the evidence is the stored roster and its age is reported next to every verdict. See offboarding/assurance_service.upsert_from_identity_sync_targets (migration 609).

provisioning.dispatch
notification.outbox_retry

software_billing.auto_close
software_seat.auto_revoke_pending_cancels
software_seat.idle_reclaim          ← migration 204; idle-seat reclaim pipeline
                                       (warn → keep/reclaim; product_access-driven
                                       since the migration-276 description fix)

procurement.reminders               ← migrations 224/225; contract/payment/budget
                                       threshold reminders (edge-triggered 80/95%)

auth.pat_expiry_warning

relationships.full_sync, relationships.incremental_sync

leaver.escalation_tick

onboarding.reconcile_processes, onboarding.escalation_tick, onboarding.t7_verify
                                    ← migration 229; onboarding state machine
                                       reconcile + escalation + T+7 verify
                                       (symmetric to the offboarding loop)

role_change.scope_review_sweep      ← migration 236; chases cloud grants stalled at
                                     pending_review. 🔴 DISABLED + policy-gated
                                     (migration 610).
role_change.review_backfill         ← migration 568; every access-relevant role change
                                     must HAVE a review packet. The coordinator builds it
                                     inline and swallows its own exceptions, so an
                                     inline-only build fails invisibly. Writes only —
                                     notifies nobody, so it stays enabled.
role_change.review_notify           ← migration 568; a packet that exists must reach the
                                     person who can answer it — the NEW MANAGER, not IT.
                                     One card per packet ever (notified_at); falls back to
                                     the global_admin pool when the new manager has
                                     departed, and counts how often it had to.
                                     🔴 DISABLED + policy-gated (migration 610).
role_change.dispatch_reconcile      ← migration 565; asserts every staged org-drift row
                                     eventually has a role_change event. The confirm path
                                     calls the coordinator INLINE and the coordinator
                                     swallows its own exceptions, so a dispatch failure
                                     leaves no trace at all — detection (flags on the
                                     staged row) is therefore separated from dispatch.
                                     Replays are dated to when the move was OBSERVED and
                                     carry trigger_source='reconcile'.
                                       when the holder's department/role changed

auditor.mfa_sync_daily, auditor.public_exposure_scan_daily,
auditor.secret_rotation_check_daily, auditor.signin_anomaly_score_hourly,
auditor.pat_audit_daily, auditor.cert_expiry_check_daily,
auditor.risk_score_recompute_hourly, auditor.privileged_review_auto_open_quarterly,
auditor.privileged_review_escalate  ← migration 232; reviewer-assignment deadline
                                       escalation for privileged-role campaigns
auditor.access_certification_revoke_fulfil
                                    ← migration 257; reconciles access-cert `revoke`
                                       decisions into real downstream revokes
auditor.identity_consumer_recert_auto_open_quarterly,
auditor.identity_consumer_recert_lapse_sweep
                                    ← migration 287; quarterly identity-consumer recert
                                       campaign + daily lapse auto-disable (PLATFORM §10.6)
auditor.access_self_attestation_auto_open_semiannual
                                    ← migration 352; half-yearly all-employee access
                                       self-attestation (opens only in the 7-day window
                                       after Jun 1 / Dec 1; never retroactive)

approval_registry.owner_confirmation_semiannual,
approval_registry.owner_confirmation_escalate
                                    ← migration 556; the half-yearly ownership round.
                                       The opener runs daily but acts only inside the
                                       7-day window after Jun 1 / Dec 1 (never
                                       retroactive). The escalator chases at +14 days
                                       copying the manager, drops items that stopped
                                       applying, and closes the round at its due date.

approval_registry.feishu_admin_drift
                                    ← migration 564; daily 09:00 Asia/Shanghai, half an
                                       hour BEFORE the round's escalator, so on a day
                                       both fire IT reads 「后台还没改」 first — the
                                       escalator's transfers are what create it. One
                                       digest card to active global_admins listing every
                                       live flow whose owner is not among the console's
                                       流程管理员, printing BOTH names per row. Fixes
                                       nothing itself: there is no write API for
                                       流程管理员 (see the vendor facts above), so the
                                       edit is a human's in the Feishu console. Drift is
                                       DERIVED on read, never stored, so the 03:30 sweep
                                       clears a fixed row with no write of ours; only
                                       `feishu_admin_drift_notified_at` persists, and it
                                       is nulled again once a row stops drifting so a
                                       re-broken flow is a new finding. Sends NOTHING at
                                       zero drift, and nothing when the backlog was
                                       already raised inside DRIFT_RENAG_DAYS. An
                                       employee-initiated 「转交」 additionally alerts IT
                                       immediately (see the invariants below) and claims
                                       this digest's next mention of that flow.

approval_registry.feishu_sync       ← migration 553; nightly 03:30 Asia/Shanghai. Discovers
                                       every approval definition in use (per-person
                                       instances/query — Feishu has no "list definitions"
                                       endpoint), refreshes name + status + 流程管理员 per
                                       definition (``?with_admin_id=true`` — the field is
                                       ABSENT without it; migration 559) WITHOUT
                                       subscribing, adopts a lone still-employed admin as
                                       the candidate owner, and infers one from who
                                       actually decides each flow's tasks where no admin
                                       is declared.
                                       ``weight="heavy"``
auditor.evidence_pack_worker

compliance.control_status_recompute ← migration 250; continuous control monitoring —
                                       recomputes GRC control posture pass/fail

identity.data_quality_scan          ← migration 269; daily five-check identity
                                       data-quality scan (orphan login identity /
                                       stale tombstone / departed manager / missing
                                       manager / sync drift), findings deduped +
                                       auto-resolved on disappearance
identity_sync.run_items_cleanup     ← migration 273; retention sweep for the
                                       per-run identity-sync ledger

feed.scan_today_celebrations, feed.notify_celebrants_and_leads,
feed.expire_pinned_announcements    ← migration 235; 动态广场 birthday/anniversary
                                       scan + notify + pinned-announcement expiry

ai_spending.monthly_summary_generate, ai_spending.cny_rate_refresh,
ai_spending.period_rollup_refresh   ← migration 262; 06:30 UTC daily; refreshes the
                                       materialised `ai_spending_period_rollup`
                                       (closed-month BucketRow set, content-derived
                                       fingerprint)
ai_spending.cost_drift_check        ← migration 265; daily; compares the materialised
                                       rollup vs a live recompute per closed month ×
                                       bucket; auto re-materialises drifted windows
                                       (late/restated facts self-heal) and alarms only
                                       when the re-check still diverges
   (ai_spending.azure_openai_resources_sync retired — migration 154,
    handler removed with the cmdb_ai_resources drop)

bailian.workspace_sync, bailian.api_key_sync, bailian.usage_sync

google.group_sync, google.seat_sync
   (google.dept_sync removed in PR for Identity Sync Center —
    work moved to identity_sync_targets row 'google-dept')
   google.seat_sync ALSO (2026-06-26): links matched active staff whose
    Google mailbox exists but carries no Helix google IdentityExternalIdentity
    (Phase A) — folded into the one daily tenant sweep, no separate cron.
    Reconcile-only — never creates accounts and NEVER auto-suspends a
    leaver (retention is a human call via the orphan inventory).
google.dynamic_group_drift        ← migration 424; daily. For every active
    dynamic email group re-evaluates the structured rule against the current
    Google user-attribute scan and flags "dead values" (rule terms matching
    zero active users — the root cause of the g-all / EMEA all-hands silent
    member-drop incident) + missing/expected-vs-actual drift. Dedup-aware
    (drift_signature over dead+missing) so IT is re-alerted only when the
    drift state changes; IT-only notify (never employees).
identity_sync.dispatch          ← every 60s; per-target cron lives
                                   on identity_sync_targets.schedule_config

sso.execute_pending_google_grants ← every 5 min; for approved
                                     Google sso_grants with no
                                     created_app_id, writes the
                                     local sso_apps row + notifies
                                     IT via sso.app_create_pending
                                     to mirror manually in Google
sso.removal_reconcile             ← daily 08:40 Asia/Shanghai; SSO
                                     revoke follow-through: re-open a
                                     missing removal task, remind on
                                     the widening ladder, open the
                                     sso_removal_overdue incident at
                                     day 14, verify Google-IdP grants
                                     against the SAML login audit
                                     (migration 603)
                                     admin (Helix never calls Google
                                     APIs for the SAML catalog —
                                     no public API exists; see
                                     migration 127)

slack.seat_sync, zoom.seat_sync, jira.seat_sync
feishu.seat_sync, nexuscrm.seat_sync   ← nexuscrm added 2026-06-22 (migration 315)
jira.leaver_account_audit     ← migration 207; daily 03:30 UTC.
                                Cross-checks the Org Admin `/admin/v1/orgs/
                                {org}/users` roster against Helix `left`
                                people; any leaver still holding
                                `product_access` (i.e. still in some group)
                                gets a fresh `target_type='account'`
                                provisioning task enqueued (dispatcher
                                runs `revoke_all_access`, idempotent).
                                The safety net for offboarding's
                                remove-from-ALL-groups path. Admin
                                notification fires ONLY after the same
                                leaver has been auto-revoked ≥3 times
                                in 7d (persistent bouncer — SCIM /
                                default-group root cause needs a human).
jira.idp_sync_heartbeat       ← migration 575; HOURLY, UTC.
                                Watches the dependency Helix relies on but
                                does NOT own: Atlassian Guard's Google
                                Workspace / SCIM user provisioning, which is
                                what actually flips a leaver's account to
                                `inactive`. Reads `scim_auto_sync_complete`
                                from the org events API (healthy cadence:
                                every 4h) and flips the chain `stale` past
                                2× that, alerting DAILY (not weekly — the
                                Aug-2026 outage ran 16 days and weekly
                                would have rung twice) and naming who
                                fired `scim_sync_disabled`. An unreadable
                                vendor records `unknown`, never `stale`:
                                "your key is dead" and "Atlassian stopped"
                                are fixed in different consoles. Unlike the
                                stale-account watchdog it does not need a
                                leaver to exist to fire.
google.leaver_account_audit   ← migration 575; daily 03:20 UTC (ahead of
                                jira.leaver_account_audit at 03:30 so both
                                systems' evidence on the 账号终态 page comes
                                from the same morning). Pulls the whole
                                Google tenant and answers "can this leaver
                                still sign in to their company account",
                                writing the Google half of
                                `offboarding_account_assurance`. Matches on
                                primaryEmail ONLY — a leaver's address
                                commonly survives as an alias on a
                                colleague's mailbox, and matching aliases
                                would report that colleague's live account
                                as the leaver's. Raises on a vendor read
                                failure so a broken credential cannot
                                publish a clean bill of health.
jira.recently_revoked_audit   ← migration 213; daily 03:35 UTC.
                                Same shape, active-employee side: any
                                completed admin-revoke / self-cancel
                                whose product_access bounced back gets
                                a fresh `target_type='product_access'`
                                task per bouncing family (`revoke_
                                product_access`). Same 3-in-7d
                                escalation threshold; legitimate
                                rehires suppressed by the `_grant_
                                supersedes` cross-check.

asset.inventory_auto_start, asset.inventory_reminder
                              ← reminder cron: hourly tick; for active cycles
                                past their 1-day grace, sends a daily Feishu
                                card to each pending-item recipient ONLY when
                                the tick falls inside the recipient's local
                                Mon–Fri 10:00–17:59 window (TZ resolved via
                                `IdentityPerson.country` → IANA zone in
                                `app/core/working_hours.py`; unknown country
                                falls back to Asia/Shanghai). Per-recipient
                                local-day dedup against
                                `asset_inventory_items.reminded_at`, so the
                                hourly cron + multi-hour working window only
                                delivers once per local business day. Admin's
                                `POST /asset-inventory/cycles/{id}/remind`
                                bypasses the working-hours gate (sends
                                immediately at any clock) but still honours
                                same-local-day dedup unless `?force=true`.
                                Cron description refreshed in migration 197.
                                Manual remind also takes `notify_manager` /
                                `notify_hrbp` (POST body `CycleRemindRequest`,
                                both default off, cron never sets them): each
                                sends ONE aggregated `asset_inventory_escalation`
                                digest per direct Lead (`manager_person_id`) /
                                HRBP (`resolve_hrbp_for_person`) naming that
                                Lead/HRBP's still-unresponsive people — one card
                                per Lead, not per employee (anti-storm); skips
                                leaver Leads/HRBPs; NOT gated by the per-day /
                                working-hours throttle (separate admin-initiated
                                escalation). `send_cycle_reminder` returns
                                `ReminderOutcome(recipients_notified,
                                managers_notified, hrbps_notified)`.
asset.inventory_launch_reconcile
                              ← migration 406; hourly, seeded ENABLED (unlike
                                the 088 pair — it's a safety net, not an opt-in
                                cadence). Re-enqueues launch cards whose
                                `notification_outbox` row was never written:
                                the HTTP start-cycle paths fan out via
                                `background.spawn` in the WEB container, so a
                                deploy mid-enqueue silently drops every holder
                                not yet recorded — `notification.outbox_retry`
                                only re-delivers rows that EXIST, and
                                re-clicking「开始」409s on the active-status
                                guard. Scans active cycles past a grace window
                                (config `grace_minutes`, default 30 — so it
                                never races a healthy in-flight drain) for
                                holders with pending items and NO
                                `asset_inventory_notify` outbox row on the
                                cycle, then re-runs the standard two-phase
                                per-recipient-committed fan-out for just those
                                holders (`AssetInventoryService.
                                reconcile_launch_notifications`). Idempotent:
                                any existing launch row (whatever its delivery
                                status) counts as launched; holders with
                                nothing pending are skipped, so the missing
                                set converges to zero. No-op when no active
                                cycle exists.
asset.borrow_overdue          ← migration 115; daily reaper for past-due borrows
asset.held_by_leaver_reminder ← migration 149; weekly digest to IT admins
                                listing assets still attributed to departed
                                employees (silence by default — held_count=0
                                produces no notification)

feishu.approval_pending_reaper ← migration 115; closes stale Feishu pending requests + reconciles missed webhooks (2026-05-28)
feishu.approval_drift_reconciler ← migration 378; every 15 min replays missed
terminal callbacks and reconciles durable approval-submission intents whose
Feishu create result is unknown or whose provider success still needs local
finalization (intent table added by migration 442)
                                terminal Feishu webhooks for pending approval
                                rows older than 10 min (no expiry logic — that
                                stays with the daily reaper). Born 2026-07-03:
                                terminal webhooks for asset-linked approvals
                                had crashed for months on the form-string bug,
                                so rows sat 待处理 until a detail-drawer click.

cron.cleanup_stale_running   ← migration 114; sweeps stuck running rows
```

> 🔴 **转岗复核的推送有两道闸,而且第二道在「发送那一刻」才读** (2026-09-05, migration **610** / +2 端点 / 0 新 cron / 0 MCP). [`mover_review_settings.py`](../../server/app/hr_lifecycle/mover_review_settings.py) 是这个域的策略中心,`platform_settings['role_change.review']` 是它的落库形态。
>
> **为什么需要它**:生产上 `provision_policies` 是**空表**(0 行),而它是平台唯一的「某个部门 / 岗位应该有什么」基线。于是 `ProvisioningService.preview_role_change` 的 `expected` 恒为空集 —— 它对每个人的结论都是「回收全部」;而且它的返回值在 `role_change_coordinator` 里**根本没被接住**(纯读函数,调用方不赋值不落库)。复核包裹因此报的是这个人的**存量**(24 类持有物),不是这次转岗造成的**增量**。2026-09-05 09:30 这个误差变成 9 张真卡:CTO 收 5 张共 115 项、CEO 1 张,而 223 个 item 至今 **0 次决断**;八张卡的两侧部门**完全相同**,变的只有汇报线。
>
> **两道闸**:① `employee_push_enabled`(默认 False)—— handler 在 SEND 时重读,所以把 `scheduled_jobs.enabled` 打开**本身不足以**触达任何人;② 逐包裹的 `settings.may_push(baseline_source, delta_items)` —— `hr_role_change_reviews.baseline_source` / `delta_items` 两列在同一条迁移里加(而不是等差异引擎那一轮),既有行全是 `('none', 0)`,于是差异引擎落地之前**任何包裹都推不出去**。把不变量推迟两个 PR,中间就存在一个「翻一个 bool 就能重演」的窗口。
>
> **`push_baseline_sources` 默认不含 `peer`。** `policy`(配置的规则)与 `provenance`(授予时这个人还在旧部门)是关于「为什么会有这条权限」的陈述;`peer`(同侪持有分布)只是对现状的描述 —— 一个人人都超配的部门,和一个人人都配得刚好的部门,在统计上长得一模一样。peer 可以用来排序和解释,永远不能是某个人手机响的理由。
>
> **`validate()` 拒绝** `employee_push_enabled=True` + `require_baseline_for_push=False`:那正是产生这 9 张卡的配置,而它当初能发出去的原因就是**没有任何东西拒绝它**。
>
> **`notify_suppressed_at` / `notify_suppressed_reason` 与 `notified_at` 是三个不同的事实。** 前者是「被拦下,从未发出」,后者是「真的发过」。迁移 610 给 9/5 之前所有未通知的包裹盖了 withheld 戳,notify 的 WHERE 带上它 —— 没有这一步,任何一次重新启用都会在当天把整个积压一次性轰出去。事故之后唯一值得问的问题就是「这个人是被通知了还是被拦下了」,而一个共用字段回答不了它。
>
> `GET/PUT /api/v1/role-changes/review-config` 一次返回策略**和**基线覆盖率(active policy 行数 / 覆盖的部门数 / 有人的部门数 / 配置入口)。两半必须在同一个响应里:「推送是关的」单独看像是谁忘了打开,操作者需要第二个数字才知道要改什么。覆盖率里的「有人的部门」走 `identity_org_memberships`(`is_primary` + 未软删的 `org_node_name`)—— `IdentityPerson.primary_department` 是 Python property 不是列,而 `org_node_name` 正是 `provision_policies.department_name` 被拿来比对的那个值,两半必须说同一件事。
>
> Tests: [`test_mover_review_push_gate_2026_09_05.py`](../../server/tests/test_hr_lifecycle/test_mover_review_push_gate_2026_09_05.py)(26 条,每个「什么都没发」的断言都配了正对照)。

> 🔴 **`policy_universe` 拦得住空**表**,拦不住空**期望** —— 同一个事故的第二个粒度** (2026-09-05, 合并后复审).
>
> PR3 的 universe 规则解决的是「策略表是空的 ⇒ 什么都是多余的」。它对另一种空无能为力:一个人转入**自己没有任何策略的部门**时,`expected` 仍是空集,于是他持有的、落在**全局** universe 里的每一项都会被判成 `policy_unexpected` —— `preview_role_change` 那个「对每个人回收全部」的形状,从**表粒度降到了角色粒度**。
>
> 所以 policy 判定需要**两个**条件:`universe`(策略本来可能被写到这类持有物)**且** `matched_any`(策略确实写到了这个角色)。**「没有策略匹配这个人」意味着我们不知道这个角色该有什么,不是这个角色什么都不该有。** 通配策略(`department_name IS NULL`)算匹配。变异验证过。
>
> 一般规则:**每一处「集合差」都要先问「减数是空的,是因为答案是空,还是因为我们不知道」。** 这个域里同一个问题出现了两次,两次都是同一个答案。

> 🔴 **`IdentityPerson.primary_department` 在 async 路径里是一颗地雷** (2026-09-05, 合并后复审).
>
> 它是一个走 `org_memberships` 关系的 Python property。映射声明了 `lazy="selectin"`,所以**用 query 取出来的**实例是安全的;但 `db.get` 拿到的、**commit 之后已过期**的实例,一碰它就是从同步代码里发起 IO → `MissingGreenlet`。
>
> 差异引擎用它做部门名回退,**只在「包裹的部门码查不到组织节点」这条分支上触发**,所以躲过了所有测试,炸的是夜间重算 sweep。改成 `_primary_department_name()` 按 SQL 查。**在 cron / 服务层里需要一个人的部门名时,查,不要碰那个 property。**


> 🔴 **重新启用推送前先跑 preflight;以及「问一个人却不让他回答」的第二例** (2026-09-05, 0 迁移 / +2 端点 / 0 MCP).
>
> **`GET /role-changes/review-config/preflight`** —— 在此之前,想知道重新打开推送的爆炸半径,唯一的办法就是打开它,而 9/5 那九张卡正是这么来的。它一封也不发,用**发送路径自己的谓词**(`settings.may_push`)把开放队列跑一遍,**两半都报**(会发多少 / 被拦多少、分别为什么),并且**报到人** —— 「9 张卡」与「9 张卡,其中 5 张给同一个人」是两句不同的话。刻意用「开关强制为 ON」模拟:用当前(关着的)状态算会返回 0,而操作者问的是「我打开会怎样」,一个 0 只会教会他这件事很安全;返回体带 `push_enabled: true` 说明这是假设不是现状。
>
> **`role_change.scope_review_sweep` 的卡一直发给云 scope 负责人,而负责人做不了那个决定。** 端点是 `require_admin`,落地页是 admin-only 路由,而 scope 负责人常常是普通员工 —— **与经理那张卡是同一个缺陷,换了个域**。三处一起修:`_authorize_grant_review` 改成「global_admin 或**本条授权所在 scope 的负责人**」(逐 scope 判,不是给云授权开普适的门);卡片**按收件人分发不同落地页**,模板正文因此不再写死页面名(写死一个,对另一半收件人就是错的);负责人的落点是「我的待办」第 14 个 tab。
>
> ⚠️ **不是 scope 详情页** —— 最顺手的猜测,也是错的:那一页自己的 `?tab=pending` 是**等他审批的申请**,是另一种 pending,他会落在一个不包含卡片所问之事的页面上。一般规则:**「落地页能打开」还不够,落地页必须包含卡片问的那件事。**
>
> ⚠️ 这个 tab 的回收按钮明说「与转岗复核不同,点确认会真的开始回收云侧 IAM 绑定」。复核包裹**只记录**、这一条**会动手**,两者并排放在同一个收件箱里,不说清楚就是陷阱。
>
> **通知分类 `CAT_ROLE_CHANGE` 从 `CAT_LIFECYCLE` 拆出**:桶不对(它在 `info`,而这张卡在要一个人做决断)+ 粒度不对(关掉它会连带关掉入职 / 离职 / HRIS / identity_sync / procurement —— 一个只能整片静音的开关,最后被按下的方式就是整片静音)。**必须排在 `lifecycle` 之前** —— 分类器返回第一个命中的类别。
>
> Tests: [`test_role_change_reenable_2026_09_05.py`](../../server/tests/test_hr_lifecycle/test_role_change_reenable_2026_09_05.py)(15 条)。


> 🔴 **差异引擎:三个信号,以及「答不上来」必须与「没问题」分开** (2026-09-05, migration **611** / 0 新端点 / 0 MCP). [`mover_delta.py`](../../server/app/hr_lifecycle/mover_delta.py).
>
> 这个域此前从来没有回答过「这次转岗改变了什么」—— 它数的是**存量**,然后把那个数字当成转岗的后果写进卡片。三个信号,刻意不平等:`policy`(转入岗位配置好的开通策略,**唯一默认解锁推送的**,因为它是意图声明不是观察)/ `provenance`(在刚离开的部门时开的 —— 不是「他该有什么」的意见,是「他为什么会有」的事实)/ `peer`(新同事的持有分布,**只排序不指控**)。
>
> 🔴 **`policy_universe` 是让 policy 安全的那条规则。** 一条策略只能就它本来就可能被写到的持有物说话:universe = 任何生效策略提到过的全部 target,不在里面的持有物**得不到任何 policy 判定**。没有它,一个策略只提到 3 个坐席的部门会把转岗者携带的另外 37 样东西判成「不在新岗位策略里」—— 存量数字换身衣服又回来了。有了它,空策略表 ⇒ 空 universe ⇒ 零判定 ⇒ 推不出去,**由构造保证**。
>
> 🔴 **刻意不复用 `ProvisioningService._match_policies_for_attrs`。** 谓词是同一个,但从「空匹配」得出的**结论**不同:那个 helper 服务于开通执行器,空匹配 = 「不开通任何东西」(在那里是对的);在这里空匹配必须是「**我们不知道**」,而不是「他不该有任何东西」。两处保持独立,是为了防止其中一个被代表另一个改掉。
>
> **部门历史从 `role_change` 账本反推,而且在前端开口。** `identity_org_memberships` 只有当前态、没有有效期区间。`dept_at()` 返回 `(dept, known)`,`known=False` 表示账本不覆盖那个时刻 —— 给一条比账本更早的授权盖上「最早那个部门」是在发明事实。
>
> **七种 `delta_kind`,只有 `policy_unexpected` / `provenance_stale` 计入 `delta_items`。** 其余五种存在的唯一理由是让页面能区分「查过了,没问题」和「没有信号能说话」。`delta_summary.coverage` 存每个信号覆盖了包裹的多大比例:**delta=0 且 coverage=0 与 delta=0 且 coverage=0.8 是两个结论**,前者绝不能渲染成 all-clear。
>
> **迁移 611 刻意不回填。** 每一条既有 item 都要拿今天的策略表和今天的组织树重新分类,而七月那次移动的答案不是今天的答案。留 NULL 才是诚实的状态,页面读 NULL 为「还没算过」并说出来;给 223 条历史行写上一个自信的 `unknown` 会看起来像一个判定。
>
> Tests: [`test_mover_delta_2026_09_05.py`](../../server/tests/test_hr_lifecycle/test_mover_delta_2026_09_05.py)(14 条,重心全在拒绝回答上,三次变异验证)+ [`DeltaHeadline.test.tsx`](../../web/src/components/roleChange/DeltaHeadline.test.tsx)(4 条三态,变异验证)。


> **转岗复核控制台:变动是解析好的三根轴,不是一坨 JSON** (2026-09-05, 0 迁移 / 0 新端点 / 0 MCP). [`role_change_service._serialize_row`](../../server/app/hr_lifecycle/role_change_service.py) 现在返回 `move = {department, manager, job_title, kind}`,每根轴带 `before / after / changed`。
>
> **为什么**:此前 `变更前` / `变更后` 两列是 `JSON.stringify(detail.before)`,渲染出来是一个部门**代码**加一个**裸 UUID**,占满页面最宽的两列而不回答「谁从哪调到哪」。9/5 发出的 8 张 manager-only 卡的两侧部门完全相同,唯一动了的那根轴就藏在这个 blob 里。
>
> 🔴 **`changed` 在服务端按原始值算,不能按标签算。** 组织改名之后两个不同的 `dept_code` 可以解析成同一个显示字符串;前端拿自己渲染的东西去比,会把「变了」判成「没变」并把它藏起来。测试里有一条专门造这个形状(`3001` / `3002` 都叫 `PingCAP / Platform`)。
>
> **部门标签优先取包裹里存的那一份**(`before_dept_path` / `after_dept_path` 记的是**当时**的路径),回退才查今天的 `identity_org_memberships` —— 几个月后打开的人要的是「他当时从哪来」,而那个节点今天可能已经改名或不存在。
>
> **通知状态是三态,不是两态**:`notified_at`(真的发过)/ `notify_suppressed_at`(刻意拦下、从未发出)/ 两者皆空(还没轮到)。中间那个是这一列存在的理由 —— 事故之后唯一值得问的就是「这个人是被通知了还是被拦下了」,而一个共用字段回答不了它。
>
> [`RoleChangeBaselineBanner`](../../web/src/pages/admin/RoleChangeBaselineBanner.tsx) 把开关状态**和**基线覆盖率放进同一句话。「推送是关的」单独看像是谁忘了打开,而它触发的动作就是去打开它;第二个数字(生产实测:0 条策略 / 88 个有人的部门)才让正确的动作可见。**没有基线时前端禁用那个开关** —— 服务端其实会接受,但那会让操作者以为接下来会发生什么。
>
> 变动类型筛选**刻意做在前端**:列表是 keyset 分页,每个服务端筛选都进 cursor 指纹,加一个就让正在进行的翻页失效;一页最多 100 行。**并且把这件事写在界面上**(「本页 N 条中 M 条匹配」),否则底下的分页器仍报服务端总数,读起来像 bug 而不像筛选。


> 🔴 **`/admin/role-changes` 不挂 `RoleRoute` —— 它是一个调度器** (2026-09-05). [`RoleChangeEntry.tsx`](../../web/src/pages/admin/RoleChangeEntry.tsx).
>
> 十张已投递的通知卡带的 URL 是 `/admin/role-changes?review=<uuid>`,写在飞书消息体和邮件里,**改不了**。而这条路由此前挂在 `RoleRoute roles={['global_admin','security_auditor','auditor']}` 下,4 个收件人里 3 位只有 `employee` —— 点击被静默弹回首页。后端**从来不是**问题:`mover_review_router` 按 `reviewer_person_id` 逐包裹放行普通员工(读 / 逐条决断 / 按类别决断三个端点),前端只是没有对应地址。
>
> 调度器坐在**卡片指向的那个路径上**:console 角色渲染原页面(逐字节不变),其他人带 `?review=` 跳 `/me/role-change-review/:id`(移动端 `/m/me/role-change-review/:id`),不带就回个人待办。**这是导流不是鉴权** —— 边界始终是 API,而且非管理员在 console 挂载**之前**就被重定向走,不会代他发出任何 admin 请求。角色判断走 `canAccessRoleChangeConsole`(`lib/rbac.ts`),路由与菜单读同一个函数:两处各写一遍是「菜单里有、点进去被弹开」的标准成因。
>
> 一般规则:**一张卡片的落点必须是收件人打得开的地址**。收件人是普通员工时,`/admin/*` 就是错的落点 —— 要么给员工侧地址,要么换收件人。


Seeded by migrations 037 (initial), 050 (Google dept/group), 064 (auto-close), 081 (Bailian), 094 (auditor quarterly), 095 (relationships 5-min), [`103`](../../server/alembic/versions/103_google_account_lifecycle.py) (`google.seat_sync`), [`109`](../../server/alembic/versions/109_seed_feishu_seat_sync_job.py) (`feishu.seat_sync`), [`110`](../../server/alembic/versions/110_seed_feishu_group_integration.py) (hot-fix: missing `feishu_group` integration row that `109` assumed already existed). Renamed dashes → underscores in [`059_fix_scheduled_jobs_id_dashes.py`](../../server/alembic/versions/059_fix_scheduled_jobs_id_dashes.py).

> **Note on `feishu.seat_sync` semantics.** `feishu.seat_sync` **imports AND revokes drift**, but unlike `jira.seat_sync` / `zoom.seat_sync` / `slack.seat_sync` (which revoke immediately) it revokes only after a **grace window** — `drift_grace_days`, default 30, platform setting `feishu.seat_sync.drift_grace_days`; **set to 0 for the historical pure-import-only behaviour** (2026-05-02 shipped import-only, 2026-05-05 added the grace-window drift; this paragraph said "import-only by deliberate decision" until 2026-07-29 — it was ~3 months stale, and the same wrong claim was fixed in the job's admin-facing description back in Round-56). Feishu rosters are routinely edited by HR / IT for short-term reasons (leave, secondment, frozen accounts), so only sustained absence revokes; operators can still close Feishu seats explicitly via SeatCancel or admin revoke at any time. The handler reconciles plans via the **full-tenant roster** (mirrors every active Feishu account in the tenant — same shape as `google.seat_sync` Phase A) for any binding whose `operation` is in `_TENANT_ROSTER_OPERATIONS = {tenant_users, user_suspend, user_create}`, **or** via a group roster for `group_member` (niche; one specific user group). **Read/write decoupling (2026-06-30, [ADR](DECISIONS.md)).** `user_suspend` / `user_create` used to be EXCLUDED here on the theory that "those bindings are about offboarding, not a roster (= HRIS)". That cost a month-long silent panel freeze when prod's only Feishu connector was flipped to `user_suspend` for real offboarding (P01452) — the seat-reconciliation panel sat at the last `tenant_users` snapshot while the cron stayed green. Because the roster READ is operation-agnostic (same `list_all_tenant_users_with_emails` call regardless of lifecycle mode), all three tenant-roster ops now seat-sync; the snapshot `product_key` is normalised to `tenant_users` so each plan keeps one `software_seat_sync_stat` row. The driver still rejects `grant_seat`/`revoke_seat` on `tenant_users` (account lifecycle is HRIS-owned) — only the seat-sync READ coverage broadened, not the WRITE semantics. **Bindings whose `operation` is missing or unknown are excluded + surfaced as misconfig** (no silent default; the misconfig error shows on /admin/scheduled-jobs every tick). **Silent-freeze guard:** if an active connector to an enabled `feishu_group` integration yields 0 eligible + 0 misconfigured plans (plan/software went inactive — produces no error at all), the handler **raises** so the failure-notifier pages instead of returning green. The seat-reconciliation panel API also returns `sync_stale`/`sync_stale_days` (snapshot > 48h) → red Alert in `SeatReconciliationDrawer`, the always-on catch-all for any freeze cause. See [`server/app/scheduled_jobs/feishu_handlers.py`](../../server/app/scheduled_jobs/feishu_handlers.py) and [`server/app/core/coordinators/feishu_seat_coordinator.py`](../../server/app/core/coordinators/feishu_seat_coordinator.py).

> **Cross-vendor external-account registry (「外部 / 共用账号」, 2026-07-01, migration 370, [ADR](DECISIONS.md)).** Every vendor's reconciliation flags accounts not matching an active employee as `software_external_holder.category='external'`; the same email recurs across vendors (prod: 290 external rows / 187 unique emails; service accounts + ex-employees span 4–7 vendors each). `external_account_registry` is the ONE place an admin classifies + notes an email (keyed by `email`; **no-email vendor accounts** — since 2026-07-02 row-persisted as `software_external_holder.category='no_email'` with a vendor `holder_key` like `feishu-user:<user_id>`, email NULL — are keyed by that holder_key stored in the same `email` column); **every panel reads it at display time** ([`external_account_service.py`](../../server/app/domains/asset/external_account_service.py) `get_annotations_for_emails`, JOINed in `_compute_seat_reconciliation_{detail,overview}`). Classes `service_account`/`shared_mailbox`/`external_guest`/`contractor` are **known/expected → subtracted from each panel's `external_unmatched`** count (raw kept as `external_unmatched_raw` + `external_known`); `former_employee`/`unknown` stay flagged. Admin surface: the generalised **「外部 / 共用账号」** page (`web/src/pages/asset/ExternalAccountsPage.tsx`, `/admin/external-accounts`) — successor to the Google-orphan menu, unions all vendors' external holders + the Google orphan inventory and shows per-email vendor presence. Endpoints `GET /assets/external-accounts` (require_admin_read), `POST /assets/external-accounts/classify` + `DELETE /assets/external-accounts/{email}` (require_admin); MCP `classify_external_account`. **`google_orphan_accounts` is unchanged** — it remains the source of Google account presence + the `RES_GOOGLE_ORPHAN_OWNER` offboarding blocker (Google owner stays there; the unified page reuses the existing owner endpoint for Google rows). No seat-sync handler touched.

> **飞书对账三处口径收口 + KNOWN 扣减扩到 `leaver_residual`** (2026-07-29, 0 迁移 / 0 cron / 0 MCP / 0 RBAC). 用户拿飞书后台「总人数 446」对 `members_listed: 445` 时暴露的三件事。**(1) 遍历诊断计数** — [`list_all_tenant_users_with_emails`](../../server/app/integrations/drivers/feishu_software.py) 故意丢 `is_resigned`/`is_frozen`（不占付费坐席）却只报 `len(users)`，于是「故意跳过」与「静默漏部门」在运行结果里不可区分，而**范围外部门不报错**（`/contact/v3/departments` 直接不返回它，`departments_failed` 仍 0）。现返回 `roster_total`/`skipped_frozen`/`skipped_resigned`（按 `user_id` 去重；`is_resigned` 优先，不双计），handler 每次写入 `per_plan` 并预算 `feishu_console_total_equivalent = len(users) + skipped_frozen`。**控制台口径公式（记住这个）**：后台「总人数」= 在职 + 冻结（已离职在独立 tab）= `roster_total − skipped_resigned`。**(2) `leaver_residual` 对飞书曾恒为 0** — handler 的 person 查询带 `exclude_employment_statuses`，离职者「匹配不上」→ 落进 `external`，面板「离职残留」永远 0，读起来像「已核实无残留」，而 jira/jamf 是真分桶的。现拆 `person_index_all`（含离职，仅分类用）+ `person_index`（在职子集，导入/漂移回收仍只看它），离职者产出 `category='leaver_residual'` + `person_id`。**(3) KNOWN 扣减扩到 `leaver_residual`** — `external_account_registry` 的 KNOWN 分类是平台认定「已备案保留」的唯一标记（离职流程的 `ensure_retained_classification` 写它，#1983 离职兜底读它），但扣减只作用于 `external`，于是 (2) 一上线，生产 6 个**全部已标 `external_guest`** 的 CBG 备案保留会翻成红色「离职残留 6」，与读同一张表的闸门自相矛盾。现 detail / 概览 badge / 「全部异常」列表过滤三处同步扣减（badge==列表），新增 `leaver_residual_raw`/`leaver_residual_known`/`known_reserved_total`；等式的「已知保留」加项改用 `known_reserved_total`（只加 external 的会配不平）；健康台 `leaver_residual_holders` 同样扣减。**给下一个加桶的人**：任何 handler 新开一个 anomaly 桶，先问「这个桶有没有对应的『已备案保留』语义」——有就必须同时进 `_netted_categories` 和 anomalies-tab 过滤，否则备案保留会变红。

> **Partial-roster isolation in both `tenant_users` AND `group_member` walks (2026-05-28 incident, 5-round production review).** Two symmetric per-unit-aborts-batch bugs in [`feishu_software.py`](../../server/app/integrations/drivers/feishu_software.py): `list_all_tenant_users_with_emails` aborted the whole tenant walk on the FIRST `find_by_department` failure (Feishu code `2200 call CheckAppTenantScopes failed` — admin moved a subtree outside the app's 通讯录范围), and `list_group_members_with_emails` aborted the whole group walk on the FIRST `/users/batch` chunk failure. With ≥1 Feishu plan, `raise_if_total_failure` flipped the whole run to `failed` despite 99%+ of the roster being queryable (yesterday: 477 members, today: 0). **Fix surface (3 production code files, +28 tests):** (1) **driver per-unit isolation** — both walks now wrap each unit in `try/except AppException`, return diagnostics envelope `{users, attempted, failed, failure_examples}` (with `... and N more` sentinel when failures > 5), continue on per-unit failure; raise only when EVERY unit fails (genuine global condition). (2) **handler diagnostics** — `per_plan_summary[i]` carries `departments_failed`/`batches_failed`; `summary["errors"]` carries `"X/Y …unreachable"` strings, both whitelisted in `_DEFENSIVE_REFUSAL_FRAGMENTS` so partial runs do NOT escalate. (3) **drift-cleanup skip on partial roster** (Round-2 critical fix) — the existing 50% `safe_to_drift_revoke` ratio guard doesn't save us: losing 1 dept (~10 users) of 150 yields 97% match → guard passes → 10 active users wrongly drift-stamped → false-positive revoke after 30 days. New `partial_roster` short-circuit skips drift entirely with `"refusing drift cleanup — driver returned a partial roster"`. (4) **reconciliation-snapshot skip on partial roster** (Round-Final critical fix) — PR #551's `record_seat_reconciliation` flips not-observed external holders to `resolved=True` AND upserts a `software_seat_sync_stat` snapshot understating `holders_external`; on the next clean run, `first_observed_at` resets losing observation history. New partial-roster guard skips the entire `record_seat_reconciliation` call, preserving the previous clean snapshot. (5) **handler docstring rewrite** — said `**No drift cleanup**` (2026-05-02 baseline) but code has had grace-window drift since 2026-05-05; doc now reflects reality + partial-roster safety. (6) **URL-encoding regression test** (Round-3 security) — pins that `did` with `&`/`=`/`?`/`#` is percent-encoded by `quote(safe='')`, defending against hostile-Feishu response smuggling. Tests: [`test_feishu_seat_sync_partial_scope.py`](../../server/tests/test_integrations/test_feishu_seat_sync_partial_scope.py) (8 driver cases), [`test_feishu_seat_sync_handler.py`](../../server/tests/test_scheduled_jobs/test_feishu_seat_sync_handler.py) (3 DB-integration cases), [`test_audit_fixes.py`](../../server/tests/test_pre_launch_2026_05_16/test_audit_fixes.py) (+1 whitelist case). 657-test sweep clean.

---

> **Status colours come from `statusColor.ts`, and a lint guard enforces it** (2026-08-09). [`check-status-color.mjs`](../../web/scripts/check-status-color.mjs) fails when a `.tsx` hand-rolls a status → AntD-Tag-colour map that the shared helper already knows. It reports only when BOTH hold: every value is a valid `AntdTagColor` **and** the variable feeds a `<Tag>`/`<StatusTag>` `color=` prop — condition 1 excludes tone vocabularies (`JmlStatusPill`) and antd-mobile Tags (a different palette entirely: `danger`/`primary`, where an AntD preset renders as nothing), condition 2 excludes `<Badge status=…>`. The palette and status list are read from `statusColor.ts` itself so the guard cannot drift from the file it protects. `status-color-allowlist.json` has two sections — `nonStatusMaps` (permanent: reason/kind/bucket vocabularies that merely collide with a status word) and `pendingMigration` (the admin-side backlog, each entry naming its exact disagreement). Stale entries are a hard error.

> **A path string that matches no route compiles, type-checks, lints — and 404s** (2026-08-26). Two shipped: the bounty console's 「没人负责」 gap card, whose entire job is answering "where do I fix this", and the /help page documenting the same screen — both navigating to `/systems` when the route is `/admin/systems`. Nothing in the toolchain related a path *literal* to the `<Route path=…>` set, so the only thing that would have caught it was somebody clicking the link. [`check-internal-links.mjs`](../../web/scripts/check-internal-links.mjs) (in `pnpm lint`) relates them: every literal `navigate()` / `<Link to>` / `<Navigate to>` / `<a href="/…">` target must be accepted by one of the 458 declared routes. Kept deliberately narrow so it stays a hard error instead of becoming noise somebody switches off — a dynamic template literal is checked by its literal head as a *prefix* (still catching a wrong first segment), route params match by shape, and the catch-all `<Route path="*">` is **not** allowed to satisfy a link, because letting it would make the guard vacuous; the one target that deliberately falls through to NotFoundPage is named in the script instead. It also exits non-zero if it parses fewer than 50 routes, so a broken AST walk fails loudly rather than passing while checking nothing. Its first run found a third, pre-existing case: the health console's 「合规事件」 link pointed at `/admin/compliance` instead of `/admin/compliance-incidents`.

> **Role gates are swept per router, not listed per endpoint** (2026-08-26). A whole-feature review found the gate *present* on all twelve new bounty endpoints and *tested* on only some; two of the untested ones (`/analytics/matrix`, `/analytics/lane`) return report titles, assignee names and reward amounts, so a dropped `_require_view` there publishes the security desk's working set to ~500 employees. Per-endpoint 403 tests cannot close that, because the gap is created by *adding* endpoints — so [`test_admin_route_gate_sweep.py`](../../server/tests/test_bounty/test_admin_route_gate_sweep.py) enumerates the router: 50+ bounty routes and 11 registry routes must each refuse a plain employee, and every non-GET registry route must additionally refuse the security lane (encoding the 2026-08-25 ADR that IT maintains the register — the single pre-existing test on `POST /systems` left `transfer_owner`, the write the whole design turns on, uncovered). Both domains put their employee-facing surface on separate routers (`self_router`, `team_router`), so there is no allowlist to argue about. **Payloads are synthesised from each route's own JSON schema, not sent as `{}`** — FastAPI validates the body before the handler runs, so 22 of these answer 422 to an employee with an empty body and never reach their gate; the first version of the sweep was green while proving nothing about them. It honours minLength / minItems / formats / `$defs` / required query params and reads validator-checked vocabularies off `constants`, so a renamed status breaks the import rather than quietly dropping a route from coverage, and a route that still 422s is reported as UNPROVEN rather than counted as covered. 422-before-403 is the framework's uniform behaviour across every domain here and leaks no domain data, so it is recorded rather than "fixed" in one domain.

> **A `helpId` with no registry entry renders NOTHING** (2026-08-09). [`PageHelp.tsx`](../../web/src/components/PageHelp/PageHelp.tsx) does `if (!entry) return null` — the "?" button silently does not appear, so the page looks like it was never given help. The reverse also went unnoticed: a registry entry no page mounts can never be shown, and its three-language copy is dead weight. Both directions are now gated by [`check-page-help.mjs`](../../web/scripts/check-page-help.mjs) in `pnpm lint`, which also checks that every entry's `bullets: N` has `pageHelp.<id>.b1…bN` in zh-CN **and** en-US (ja falls back through `fallbackLng`, so it degrades rather than rendering a raw key).
>
> **Parse the registry with the TypeScript AST, never a regex.** Entries are written BOTH ways — `'audit': { … }` and `footprint: { bullets: 3 }` — so a regex that assumes quoted keys reports every bare-identifier entry as a missing one. That mistake turned 7 genuinely-broken help buttons into a reported 18, eleven of which were working fine. The guard also counts both mount forms (`helpId=` prop and a direct `<PageHelp id=…/>`), because the device-gallery entry is mounted the second way from inside a section of another page.

> **占位符的「值」也说收件人的语言** (2026-08-17). 模板目录早已三语、收件人语言早已逐人解析，
> 但生产方塞进 `{placeholder}` 的**值**按构造是单语言的 —— 拿生产 outbox 近 45 天逐 key 对 `en`
> 读者重渲染实测（在职 443 人里 **330 人 `preferred_language=en_US`**）：
> `offboarding.process_started_leaver` 给英文读者的是英文卡片 + 中文最后一行。
> `NotificationEnvelope.context` 的**值**现在可以是 `LocalizedText`（或它落库后的
> `{"zh","en","ja"}` 形态），由 [`_localized_context`](../../server/app/notifications/service.py)
> 在**四条**渲染路径统一按收件人语言解析：飞书卡片正文、卡片 chrome、邮件、铃铛重渲染。纯 `str`
> 原样透传（人名 / 用户手写回复 / 厂商报错本就只有一种语言）。落库侧配套 `_jsonable_context`
> —— 裸 `LocalizedText` 会让 outbox INSERT 直接炸，把通知打死而不是降级。
> **闸门的两个盲区同批补上**：`test_every_sent_template_key_is_registered` 的常量正则要求常量**名**
> 里出现 `TEMPLATE_KEY`，前导下划线吃掉锚点 ⇒ `_TEMPLATE_KEY` / `_NOTIFY_TEMPLATE` 一直看不见；
> f-string 拼的 key（`f"onboarding.{kind}"`）更是天然看不见 —— 现按常量名含 `TEMPLATE` 匹配 +
> 显式声明 `_DYNAMIC_KEY_FAMILIES`。生产方**逐人分别组装 title+body** 的 key（如
> `asset.oversight.operation` 走 `_is_en(person)`）是合理豁免：再注册模板等于给同一张卡片挂两套
> 语言规则，即 `hire_day_it_info.feishu_card` 那个失败模式。

> **通知卡片的「外壳」也说收件人的语言** (2026-08-09). `LocalizedText(zh=, en=, ja=)` in [`notifications/service.py`](../../server/app/notifications/service.py) is a card string in all three languages; `CardText = str | LocalizedText`, and every chrome field accepts it — `CardStat.label/value/hint/trend`, `CardAction.label`, `CardSection.title/body`, `CardHighlight.label/items`, plus the envelope's `card_subtitle` / `card_footer` / `card_highlights_title`. **Resolution is PER RECIPIENT** in `_resolve_card_chrome`, which returns COPIES: one envelope fans out to people who do not share a language, so mutating it would freeze the first recipient's language onto everyone else's card. A bare `str` still renders verbatim, deliberately — a producer with genuinely one language (a user-typed message, a vendor error string) must not be forced to invent translations. `LocalizedText` survives the outbox round-trip as `{"zh":…,"en":…,"ja":…}` and `resolve_card_text` accepts that dict form, so a worker retry does not render a dict repr at the reader.
>
> Before this, `_select_template_field` covered `subject` / `body_text` / `body_html` / `card_button_label` and **nothing else**, so ~179 structured values across 21 producer modules were hardcoded in whichever language their author wrote in. `card_subtitle` / `card_footer` / `card_highlights_title` additionally joined `template_catalog._FIELDS`, so they are admin-editable per template like the subject and body. **When adding a `_FIELDS` entry, check `web/src/pages/admin/EmailTemplatesPage.tsx`** — its save used to preserve non-edited fields via a hand-written allowlist and would have silently dropped the new ones; it now carries through every string field the catalog returned, so that class of bug cannot recur.
>
> Two guards in [`test_card_chrome_locale_2026_08_09.py`](../../server/tests/test_notifications/test_card_chrome_locale_2026_08_09.py) keep it from rotting, mirroring the pair the frontend needed after its ja bundle rotted the same way: every `LocalizedText` must name all three languages, and a `ja` value may not contain Simplified-Chinese-only characters (proof it was character-substituted rather than translated) nor be byte-identical to `zh`. `IDENTICAL_BY_DESIGN` is the escape hatch for terms Japanese genuinely writes the same way. **Never add 来 / 当 / 点 / 系 / 个 to the character set** — they are ordinary Japanese kanji (将来, 該当, 地点, 関係, 箇) and would reject correct translations.

> **`/m/*` has NO antd `App` provider** (2026-08-09). `ChromeGate` in [`App.tsx`](../../web/src/App.tsx) skips `DesktopChrome` — the only place `<AntApp>` is mounted — for every `/m/*` path, and the full-bleed routes (`/m/wrapped/:year`) sit outside `MobileLayout` too. `App.useApp()` there returns antd's DEFAULT context, literally `{message: {}, notification: {}, modal: {}}`, so `modal.confirm` / `message.*` are `undefined` and any handler touching them throws — silently, if it is inside a promise executor or an async `onClick`. A desktop page reused on a `/m/` route must mount its own `<AntApp>` boundary. Mobile-native pages use `mToast` / `mDialog` from `mobile/utils/feedback` and are unaffected.

> **Entity axis ≠ language axis** (2026-08-09). `is_en = not person.is_china_employee` is the **labour-entity** axis: HR Ops uses it to pick which HRIS manual, 汇联易/Helios vs Navan, and the hardware-reimbursement clause. `resolve_lang(person)` (preference → country → entity) is the **language** axis. Card chrome resolves on the second; anything still branching on the first will disagree with it for a China-entity hire who reads English, or a Japan-country hire on an overseas entity. When touching a card that has both, split them explicitly — content on `is_en`, prose on `lang` — and pin it with an invariant test ("this card is never in two languages"), not a per-language content assertion, which is what let the Day-1 welcome card ship a Chinese title over an English body.

> **「第几年」有且只有一个定义, 且它知道闰日** (2026-08-09). [`app/core/tenure.py`](../../server/app/core/tenure.py) — `observed_anniversary(anchor, year)` returns the date an anchor's anniversary is **actually observed** in that year (a Feb-29 anchor lands on Feb 28 in a common year, on the 29th in a leap one); `is_anniversary` and `completed_years` both derive from it, so "is today the anniversary" and "how many years is it" cannot disagree. They did: the celebration scanner fired on Feb 28 of a common year while the year counter returned one less, and the `years >= 1` gate downstream swallowed the person's **first work anniversary entirely**. It lives in `core/`, not `domains/me_universe/`, because the feed domain needs it and domains may not import each other. Any new surface showing tenure must call this rather than subtracting years.

> **`POST /feed/posts` is idempotent per author via `client_token`** (2026-08-09; migration **457**). Unique index is `(author_person_id, client_token)` — deliberately **not** global, because the year-in-review sends the deterministic `wrapped:<year>`, identical for everyone, and a global constraint would let the first colleague to share their recap lock out the whole company. NULL is exempt (MySQL permits unlimited NULLs in a unique index), so the celebration scanner, announcements, and every legacy caller are untouched. The service returns `(post, created)` and **callers must skip the audit write and the @-mention fan-out when `created` is False** — a replay is not a publish. The desktop composer mints one token per draft, the mobile composer one per open sheet (which is what makes its `retryOnNetworkError` safe), and the recap sends `wrapped:<year>`. The former guard was a `localStorage` flag: per browser tab, and invisible to curl and to the `feed_create_post` MCP tool.

> **A celebration is a two-way surface** (2026-08-09; migration **456**). The celebrant can acknowledge each blessing (`POST /feed/celebrations/{id}/blessings/{bid}/ack`, idempotent, celebrant-only) and thank everyone at once (`POST /feed/celebrations/{id}/thanks`, once per celebration, publishes one post @-mentioning every well-wisher through the **existing** mention chain — do not add a second notification path). Ownership of a system post routes through `_is_celebrant_of` / `_post_owner_person_id`: the celebrant is the owner for delete rights AND for comment/like notifications, because `author_person_id` is NULL on system posts and every `if author is None: return` guard silently dropped their notifications. Aggregate banners confer no ownership.

> **System feed posts store a key, not prose.** `FeedPost.body_i18n_key` + `body_params` (migration 456); `body` remains only as a legacy-client fallback and the search haystack. Rendering goes through `web/src/pages/feed/postBody.ts` on every surface. Storing rendered English was a 2026-06-01 decision, reversed here: it put `1 teammate celebrating their work anniversary today!` into an otherwise-Chinese feed, and each surface handled it differently so the bug looked intermittent.

> **The aggregate banner carries its own date.** `body_params["occasion_date"]` — never infer it from `created_at`. The banner is created by a cron in Asia/Shanghai while `created_at` is UTC, so for a cron running 00:00–07:59 SHA the two differ by a day and the celebrant index lookup missed **100% of the time**, leaving the card with no avatars and no CTA.

> **司龄 has ONE definition** ([`me_universe/tenure.py`](../../server/app/domains/me_universe/tenure.py), 2026-08-09). `completed_years` (满 N 年, cardinal) and `ordinal_year` (第 N 年, ordinal, always +1) are **different numbers** — the year recap rendered one value as both, so its tenure slide and its poster made contradictory claims. Both are month/day aware. Every surface showing tenure imports from here; the footprint page and the recap previously had independent implementations that disagreed by a year for anyone hired in December.

> **"Colleagues who joined after you" is its own query, never a subtraction.** `join_rank` counts leavers (historical truth); `current_headcount` excludes them. Subtracting one from the other goes negative for any company with attrition, and the clamp to 0 told every recent joiner they were "早于 0 位同事". Both `wrapped.py` and `footprint.py` now compute `earlier_than_current` directly.

> **Per-person activity is bucketed in the PERSON's timezone.** `wrapped.py` aggregates audit rows per UTC hour and folds them into local days with the real `ZoneInfo`, rather than grouping by `DATE(created_at)`. Grouping by UTC date inside a local-time year window silently dropped a UTC+8 employee's first-morning-of-the-year activity from the heat map while still counting it in "active days" — the picture and the sentence disagreed, visibly. The hour buckets also yield the busiest-day and peak-hour readings without a second query.

## 6. Notification outbox

> 🔴 **「没给收窄条件的超限列表调用,一行都不返回」(2026-09-07;0 迁移 / 0 API / 0 cron / 0 MCP)。**
> 桥层本来就在字符截断时给警告,措辞也很直白(*Do NOT tell the employee something does not exist based
> on this call*)。**生产实测证明这不够**:员工问「380838443567 这个云账号谁负责」,模型三次调
> `list_cloud_scopes`、**一次都没传 `search`**,然后回答「在当前可查询的云账号列表中,没有找到」。
> 取证(`assistant_messages.tool_calls` + `cmdb_cloud_scopes`)显示那个账号在库里、有负责人、状态
> `active` —— 它只是按 `display_name` 排在第 **197** 位,而工具一页给 200 行、总共 **271** 行。
>
> 所以修法从「警告」变成**结构性的**:`_has_narrowing_argument` 判断这次调用有没有说「我要找什么」
> (翻页参数 `page`/`page_size`/`limit`/`offset`/`cursor` **不算**收窄 —— 那正是失败时的形状),
> 没有的话超限时返回 `oversized_refusal()`:**零行数据** + 一条「你没有看到任何数据,不能据此回答
> 『没有找到』」的指令。模型无法从一份不存在的清单里挑出一个错结论。给了收窄条件的调用仍走原来的
> 「部分结果 + 警告」—— 那时模型已经尽到它该做的事,截断是数据本身太大。
>
> 🔴 **拒绝只对「能被收窄」的工具成立(2026-09-08 补,`_accepts_narrowing`)。**「模型已经尽到它该做的
> 事」还有第二种形状:**工具压根没有可收窄的入参**。只有 `page`/`page_size` 的工具答不了「带上 `search`
> 重试」—— schema 里没那个键,模型照做只换来一条 `invalid_arguments`,第二次仍是零行,而 hint 又明令
> 禁止说「没有」:员工问自己的东西问成死胡同。**这不是个别情况 —— 实测只读目录 360 个工具里 118 个是
> 这个形状**(每一个 `get_my_*` / `list_my_*`,它们本来就按调用者收窄,没有第二个维度可给)。所以判据
> 取**工具自己的 schema**(`BridgedTool.schema`,不是本次传参):可收窄 + 没给 → 零行拒绝;不可收窄 →
> 「部分结果 + 警告」。同理,部分结果里的重试建议也按 schema 分三句(`_retry_advice`):可筛的说筛,
> 只能翻页的说翻页,**一个入参都没有的(如 `whoami`)只能说「剩下的取不回来,当未知,别当不存在」**。
> 教训是通用的:**给模型一条它执行不了的指令,和不给指令一样糟 —— 它会照做、失败,然后自由发挥。**
>
> 🔴 **一条「建议你转人工 / 开工单」的回复,无论多短,都必须是卡片。** 同一次取证里的第一轮回答是
> 「这个问题有点复杂,建议「转人工 / 开工单」让 IT 同事帮你处理。」—— **下面一个按钮都没有**:
> 它 34 个字,低于 `MIN_CHARS_FOR_CARD` 的 40,被判成「短句走纯文本」。**唯一一条明确叫员工去按
> 按钮的回复,恰恰是不带按钮的那一条**,员工只能重新问一遍(用户就是这么发现的)。字数门槛的本意是
> 「别给『好的,已记下』套卡」,对这一类完全不适用 —— `should_render_as_card` 现在先看
> `recommends_escalation()`(三语关键词),命中就一定出卡。
>
> **顺带记一个事实**,因为它会被反复问到:助手**已经**能看到全平台 **360 个只读 MCP 工具,其中 64 个
> 是云 / 网络 / 成本类**(`list_cloud_scopes` / `list_cloud_servers` / `cost_*` / `cloud_resource_*` …)。
> 「云的能力没加给 agent」是个误判 —— 桥层按 `mutates=` 这条 ground truth 自动借用整个 MCP 注册表,
> 新工具当天就可用。当时的失败是**查找方式**(没用 search + 一页残缺清单),不是能力缺失。

> 🔴 **助手的聊天面:富文本回复 + 「建工单 / 转人工」两颗按钮(2026-09-07;迁移 627 / +3 API / 0 MCP)。**
>
> **富文本。** 助手的普通回复原来走飞书 ``text`` 消息,而 ``reply_hygiene.to_feishu_text`` 会把模型写的
> 加粗、链接、列表**全部剥掉**(纯文本消息渲染不了,留着标记更难看)。代价是助手的每句话都是一坨没有层次
> 的字,而它回答的常常是「你有 2 台电脑,分别是…」这种天生有结构的内容。现在有标记且够长的回复走 ``post``
> 消息的 ``md`` 元素(实测可用:加粗/链接/列表/行内代码);短句和无标记回复仍走纯文本(给「好的,已记下」
> 套富文本没有收益),富文本发失败退回纯文本。转义沿用答案卡那条规矩(``<`` / ``>``)。
>
> **一颗按钮拆成两颗。** 原来的「转人工」做的一直是**建工单**,名字和行为对不上。现在:
> `🎫 建工单` = 异步、有台账、有 SLA;`💬 转人工` = 同步 —— AI 归类 → 按路由找到负责人 → 建飞书群把员工和
> 负责人拉进来 + **原样转发**那条回答。**转人工也会建工单**(群里的活必须在台账上有一行,否则它就是一件
> 没人能查、没有 SLA、交接就消失的活),所以它是 escalate 的超集,不是替代。两颗都走同一个
> ``_escalate(with_chat=…)``:那里的 rollback / MissingGreenlet 纪律很微妙,第二份拷贝一定会漂。
>
> **分类与路由刻意分开。** 模型只把问题归到一个**已存在的** ``category_key``(闭集,返回值再校验一次 ——
> 它发明一个键等同于「没分类出来」,走兜底路由);「这一类找谁」由 ``assistant_escalation_routes`` 决定。
> 于是模型判错的后果是「找错了人」——一个人能一眼看出并改派的错,而不是「凭空产生一个没人负责的类别」。
> 置信度低于 0.5 也走兜底,但分类结果**照样记进 ``assistant_handoffs``** 供复核(否则「分类准不准」无从评估)。
>
> **降级是正式路径。** 三种情况没有群:这一类还没指派负责人 / 建群失败 / 功能开关关掉。**每一种都仍然
> 建工单**,台账上记 ``degraded_reason``,而给员工的话是「已转 IT,工单号 X」——**拉不到人时说拉到了,
> 是这条链路最坏的失败**。另有一道容易漏的闸:两个人都在群里才算拉到人(``len(open_ids) < 2``),因为
> ``work_email`` 虽是 NOT NULL 但**空字符串合法**,而只有负责人的群会让员工对着空房间等。
>
> **幂等。** 建群是不可撤销的外部动作,而飞书会重投它判为慢的回调 ⇒ ``assistant_handoffs.message_id``
> 唯一,第二次点击拿到同一个群。
>
> **后台可配**:`/admin/assistant-handoff`(读 global_admin + auditor,**写只有 global_admin** —— 审计员的
> 职责是看,改派是运营决定)。种子 8 个类别、负责人一律为空:刻意不猜任何人的邮箱,未指派会在页面顶部
> 报警,因为那一类此刻只会建工单。``category_key`` 不可改 —— 它是分类台账里的稳定标识。
>
> **迁移 629 把 ``cloud``「云与网络」拆成「云」(冯帅)和「网络」(孟凡生)两条,共 9 个类别。** 一条路由
> 一个负责人,而这两件事不是同一个人管的 —— 合起来的后果不是「分类不准」,是模型判对了类别、路由也按表
> 找对了负责人,然后办公室 WiFi 连不上的人被拉进一个只有云负责人的群:每一步都「正确」,结果是错的。
> 「新建类别」没有后台入口(``category_key`` 不该在配置面上被随手造出来),所以多一个类别必须走一次迁移;
> 两条 ``hint`` 都写上「什么不属于我、该走哪一类」—— 相邻类别之间,这句排除比各自的列举更能决定分类结果。
>
> ⚠️ **打字说「转人工」和按那颗按钮是两件事**:打字走的是既有的关键词路径(自由文本、不依赖模型、
> 拟一张工单让你先看),它不知道你指的是哪一次问答,所以给不出群。两边的文案已经各自说清自己做什么 ——
> 两个入口同名而行为不同,员工的预期就会落空,这正是那颗按钮拆成两颗的同一个理由。

> 🔴 **回执的第二条腿:覆盖 + 对账(2026-09-07;迁移 626 / +1 cron / 0 API 契约 / 0 MCP)。**
> [`settle()`](../../server/app/notifications/settlement.py) 是执行面,但它不知道任何一件事的真实状态 ——
> 那些状态在二十来个域自己的表里。
> [`notification_settlement_coordinator`](../../server/app/core/coordinators/notification_settlement_coordinator.py)
> 是那层翻译:`resource_type` + `resource_id` → 终态或 `None`。放在 `core/coordinators/` 而不是
> `notifications/` 是 CLAUDE.md 第 3 条(通知域不该 import 二十个业务域)。
>
> **两条腿。** 内联(域在终态迁移处调 `settle()`)是快路径;`notifications.card_settlement_reconcile`
> (每 15 分钟)是**保证**。有了对账,漏掉一处内联调用只是最多 15 分钟的延迟,不再是一张永久骗人的卡片。
> 两边都幂等 ⇒ 抢到同一件事也只有第一次做实事。扫描有界(一轮 ≤500 行,只看 5 分钟前~30 天内)。
>
> **两个桶,一个自动生长的闸。** 每一种出现在 actionable 卡片上的 `resource_type` 必须落在 `RESOLVERS`
> 或 `OPT_OUT` 之一。清单**不是手写的** —— 测试用 AST 扫全仓的 `NotificationEnvelope(... card_actions=...)`
> 构造点自己算出来,所以**新增一条 actionable 流程会自动进闸**。形状抄的是既有的 curated-card 纪律
> (`derived_actions.OPT_OUT` + catalogue 棘轮)。`OPT_OUT` 每条都要写明「为什么查状态列对它是错的」,
> 并有天花板 `OPT_OUT_CEILING`。
>
> 🔴 **最容易写错的一类,和它为什么值一条测试**:有些卡片问的问题**两种答案都不改变那一行的状态**。
> API Key 闲置提醒问「留着还是释放」,选『留着』之后行还是 `active`;坐席闲置回收同理;数据访问到期卡
> 的「续期」只把 `expires_at` 推后。给这类写一个「status==active 就算结案」的解析器,会在卡片**发出去的
> 那一刻**就把它判成已结案 —— 比不做回执更糟。同理 `cmdb_cloud_access_grant` 的 `active` **刻意不在**
> 映射里(那个 resource_type 上既有「批准申请」也有「即将到期」两种卡)。这三条判断各有一条会说话的测试,
> 不是只有注释。
>
> **映射写字面量,由测试钉住不漂移**:协调器 import 期不该把二十个域拖起来,所以 `StatusMap` 用
> `(模块路径, 类名)` 延迟解析、状态写字面值;`test_resolver_literals_match_the_domain_constants`
> 反过来 import 域常量断言值一致,域里改值当场红。
>
> ⚠️ **`outcomes` 必须可被外部读到。** 第一版把映射藏在 `_lazy()` 返回的闭包里,于是「解析器只能产出终态」
> 那道闸对十条里的九条**平凡通过** —— 它读不到闸内的 dict,什么都没检查,而且是绿的。改成 dataclass 字段后
> 那道闸才真的生效,并额外断言「被检查到的条数 ≥ 总数-1」,防止它再次空转。
>
> **健康指标** `stale_actionable_count`(>30 天仍未结案的 actionable 通知条数)每轮记入运行摘要:它长期涨
> 意味着有一类卡片既没被内联结案、也不被对账覆盖。

> 🔴 **回执:一张卡片在事情结束之后变成它自己的台账(2026-09-07;迁移 624 / 0 API 契约 / 0 MCP)。**
> 用户的原话:「helix 的推送没有任何回执的能力,这个审批过了,还是去审批」。一条通知说的是
> 「有人在等你做 X」,而在此之前**没有任何东西回来说 X 做完了** —— 已批准的续期卡在飞书里原样挂着
> 一颗活的「去审批」按钮,点下去落在一个早已翻页的页面上。
>
> [`settlement.settle(resource_type, resource_id, outcome, actor)`](../../server/app/notifications/settlement.py)
> 是唯一入口。`outcome` 取 `card_design.SETTLED_STATUSES`(approved / rejected / expired / withdrawn /
> done)—— **和卡片头部状态同一套词表**,所以回执长什么样由记录下来的事实派生,而不是在渲染时再选一次。
> 连接键是 `related_resource_type/_id`(46 处 actionable 卡片本来就都带着它)。
>
> **顺序是不变量,不是实现细节**:① 先在行上盖 `settled_*`(本地写,决定铃铛红点与收件箱);
> ② 再去 `PATCH` 飞书卡片(外部调用,可能失败,只记日志)。反过来会得到最坏的结果 —— 卡片变成绿色
> 「已批准」而铃铛里那条还写着「去审批」,两个面对着同一件事说不同的话,员工没有任何办法知道哪个是真的。
> 有测试专门钉这一条:卡片改写失败时结案记录**仍然**落地。
>
> **永不抛异常 + 幂等。** 调用点是领域流程的终态迁移(权威状态已经写完),一次回执失败绝不能回滚一次
> 真实的批准 ⇒ 整段包在 SAVEPOINT 里并返回计数;只结 `settled_at IS NULL` 的行 ⇒ 重复调用不会二次
> 改写卡片、不会把处理人换成后来那个人(对账 cron 直接依赖这一点)。非终态的 `outcome`(如 `pending`)
> 被**拒绝**并大声记日志 —— 一个拼错的值会把卡片改成中性态并吃掉按钮,比不改更糟。
>
> **三个面里只有两个需要它。** 飞书卡片要 PATCH;铃铛/收件箱读 `notification_outbox`,所以靠盖戳
> (`unread_actionable` 排除已结案行 —— `read_at`「我看过了」和 `settled_at`「那件事完了」是两件不同的事,
> 一条从没被点开的通知说的事情完全可能已经批准)。而**移动端行动收件箱不需要**:
> [`mobile/action_inbox.py`](../../server/app/mobile/action_inbox.py) 按设计读的是**每个域自己的待决状态**,
> 「别处做出的决定会在下一次刷新时反映出来」,它本来就是自愈的。
>
> **邮件是硬边界**:发出去改不了,回执只在卡片 + 铃铛两个面成立。刻意**不**补一封「已处理」邮件 ——
> 审批完再收一封邮件是打扰,不是服务。
>
> 首个接线点是云权限续期的批准/驳回(`renewal_service.decide_renewal_group`),批准侧只在
> `applied and not failures` 时结案 —— 部分失败的行仍然 OPEN、还在等重试,把卡片改成「已批准」
> 会告诉审批人一件没做完的事已经做完了。

> 🔴 **卡片设计语言:状态驱动,schema 2.0(2026-09-07;0 迁移 / 0 cron / 0 API 契约 / 0 MCP)。**
> 全平台每一张飞书卡片由 [`_build_interactive_card`](../../server/app/notifications/service.py) 一处渲染,
> 设计令牌在 [`card_design.py`](../../server/app/notifications/card_design.py) —— **那是配色/徽章/图标的唯一书面形式**,
> 前端 [`FeishuCardPreview`](../../web/src/pages/hr-tools/FeishuCardPreview.tsx) 照抄它(这张表两处实现骗过一次 HR:
> 预览画橙色而真卡发蓝色)。
>
> **状态取代颜色作为一等概念。** 旧模型按模板配 `header_color`,于是「这张卡是什么状态」和「它该是什么颜色」
> 各自维护,徽章和图标无处挂靠。现在 `card_status`(`STATUS_PENDING` / `INFO` / `REMINDER` / `WARNING` /
> `ALERT` / `CRITICAL` / `APPROVED` / `DONE` / `REJECTED` / `EXPIRED` / `WITHDRAWN` / `CELEBRATE`)一次决定
> 头色 + 徽章 + 图标。解析顺序:产生者显式 → `STATUS_BY_TEMPLATE` → **通知类别的 bucket**(ACTION ⇒ `pending`)
> → `info`。第三跳是关键:全平台 46 处 actionable 卡片因此自动带上「待处理」徽章,而徽章与铃铛红点
> (`unread_actionable`)派生自**同一个**事实,不可能互相矛盾。`COLOR_BY_TEMPLATE` 只留刻意偏离状态色的少数
> (庆祝类暖色分级、Day-1 卡呼应欢迎邮件报头)。
>
> **2.0 的元素词汇表与 v1 不同,两个旧元素被删掉了**(飞书拒收整张卡,不是降级渲染):`note` → `markdown` +
> `text_size:"notation"`;`action` 容器 → 单按钮直接放、多按钮放 `column_set(flex_mode:"bisect")`。
> 另外 `column_set.padding` / `.corner_radius` 与 `collapsible_panel.background_style` 均非法,而 `column.padding`
> 合法、圆角只能靠 `collapsible_panel.border.corner_radius`。**按钮类型是个陷阱**:2.0 里 `primary` 是**描边**,
> 实心的叫 `primary_filled` —— 这正是线上那张续期卡的「去审批」看起来像脚注的原因。`tests/support/card_shape.py`
> 的 `assert_card_schema` 把「不许再产出被删元素」做成断言。
>
> **新版式原语**(全部可选,产生者按需用):`CardFactBox`(唯一一个浅底信息盒:左主体、右那个决定答案的数字)、
> `CardFact`(`👤 **标签**　值` 的事实行,取代散文段落)、`CardCollapse`(长尾折叠,标题带计数;**不足
> `COLLAPSE_LINE_THRESHOLD` 行时直接铺开** —— 把一行藏在点击后面是花一次交互换零信息)、`CardBadge`(在状态徽章
> 之外**追加**分类徽章)。四者与既有 `card_stats` / `card_highlights` / `card_sections` 一样**冻进
> `card_payload`** —— 漏冻一个字段不会报错,只会让重试投递悄悄退化(当年续期卡丢按钮的同一个机制)。
>
> **卡头图标按应用运行时解析。** 飞书 `image_key` 是**按应用隔离**的,而 dev/prod 是两个自建应用 ⇒ 把 key 写进
> 源码 = 生产上每张卡图标静默变空。[`card_icon.py`](../../server/app/notifications/card_icon.py) 以
> `(app_id, PNG 内容 sha256)` 为键缓存在 `platform_settings.notifications.card_icon`,换 logo 只需替换 PNG
> (哈希变了自动重传),上传失败降级成「这张卡没有图标」并进 10 分钟冷却(否则一次全员 fan-out = 几百次无用外部调用),
> 写缓存走**独立 session**(领域事务不该被派生缓存写入污染)。语义态(已批准/告警)用飞书标准图标压过品牌 logo:
> 绿色「已批准」卡如果图标还是中性 logo,就把颜色和徽章刚建立的状态信号丢掉了 —— 品牌身份在聊天里由发送方头像承担。
>
> **助手的卡片走同一个渲染器。** `confirm_card` / `card_actions._closing_card` 不再手搓 dict:回调按钮的 payload
> 在 2.0 里从顶层 `value` 移进了 `behaviors[].value`,而**换渲染器之前发出去的卡片还会在聊天记录里躺几个月** ⇒
> `card_actions._action_value` 两种形状都读。回调响应卡与被替换的卡必须**同 schema**(2.0 卡不能被一张 v1 卡替换)。

> **Recipient language is resolved in exactly one place** (2026-08-09; 0 migration / 0 cron / 0 RBAC / 0 MCP). [`server/app/core/locale.py`](../../server/app/core/locale.py) owns "which language do we speak to this person in": `normalize_lang` (understands the free-text `english` / `chinese` / `中文` values SuccessFactors seeded into `preferred_language`), `resolve_lang` (explicit preference → `country` ISO-3 → `is_china_employee` → `zh` baseline), `is_en` (compat shim for two-way surfaces), `pick_localized` (walks `FALLBACK_CHAIN`, `ja → en → zh`). **Never re-implement a `preferred_language.startswith(...)` test** — six independent ones had drifted, two of them reading `"chinese"` as *not* Chinese, which sent Chinese-preferring employees the English Google Workspace password mail.

> **Every `template_key` that is sent MUST have a registered template, in all three languages.** An unregistered key falls through to whatever single language the producing call site hardcoded on the envelope — that is how the platform accumulated 70 monolingual flows. Templates live in [`server/app/notifications/templates/`](../../server/app/notifications/templates) (one module per domain: apikey / cloud / email_group / feed / lifecycle / platform / support), merged into `_DEFAULT_TEMPLATES` by `service.py`; the 28 oldest still sit inline in `service.py`. Contract per entry: `subject` / `body_text` (+ optional `body_html`, `card_button_label`) × `{'' , _en, _ja}`, and every `{placeholder}` must be a key the call site puts on `NotificationEnvelope.context`. Enforced by [`test_template_locale_parity.py`](../../server/tests/test_notifications/test_template_locale_parity.py) — four assertions: three-language completeness, placeholder parity across languages, no cross-language locale-fragment references, and no unregistered sent key. **Adding a template is enough to localise a flow**; no call-site change is needed beyond ensuring `context` carries the placeholders.

> **The last 7 unregistered keys, and the three call-site moves that go with them** (2026-08-17; 0 migration / 0 cron / 0 RBAC / 0 MCP). `google.active_employee_account_dark`(email_group)、`intelligence.recommendation`(platform)、`tencent_cam_initial_password`(cloud)、`offboarding_delete_blocked_no_successor` / `_delete_preview` / `_handover_unresolved` / `_stale_handover_blocker`(lifecycle)。**「加个模板就够了」只在调用点本来就送对 context 时成立** —— 这七条都不是,所以配套改了三类调用点写法,新增模板时照抄:① 明细块中立化(人名/邮箱/ISO 日期/坐席名,不带散文),块才能安全嵌进任意语言,沿用 `jira.*` `sample_block` 纪律;② 真正是散文的值走 locale fragment 三元组(删号被拒的 `reason_*`、删号预告里三选一的就绪判定 `account_list_*`、AI 建议自身的 `rec_title_*`/`rec_summary_*`),模型只出中英时日语位填英文,与 `FALLBACK_CHAIN` 的 ja→en 同向;③ `body_text` 换成 `(see template)` 哨兵——否则 `render_inbox_body` 见到非哨兵正文会直接返回存量那份中文,模板只救了飞书/邮件、救不了铃铛。`tencent_cam_initial_password` 走 `google_account_password` 那条特殊路径:不走 dispatch、模板即唯一文案来源、调用点 `pick_localized` + **严格** `str.format`(所以这类模板里绝不能出现 `{cta_line}`/`{portal_url}`),两个 key 都属 `SECRET_TEMPLATE_KEYS`。⚠️ 这两个 key 会出现在 邮件模板 编辑页(catalog 收的是全部 `_DEFAULT_TEMPLATES`),但调用点刻意只读 builtin —— 在那儿改这两条**不生效**;要改文案改代码。**编辑器已经诚实了**:同日先落的 #2898 给 catalog 加了 `builtin_only` 标记、前端渲染成只读(橙色「代码直投」标签 + 保存禁用)、`PUT /platform-settings/email_template.<builtin-only key>` 返回 422 —— 见下面「Builtin-only templates」那条。本条早先写的「需给 catalog 加 builtin-only 标记 + 前端提示,属另一件事」是**当时就已经过时**的:`tencent_cam_initial_password` 一直在 `BUILTIN_ONLY_KEYS` 里,注册模板这一步恰恰是让那个标记第一次真正生效。占位符 ↔ context 的契约由 [`test_backfilled_template_keys_2026_08_17.py`](../../server/tests/test_notifications/test_backfilled_template_keys_2026_08_17.py) 按「调用点真实 context」渲染断言 —— parity 测试只看模板自身,看不见这一半。

> **Locale fragment pairs.** When a context value is itself language-specific (a status label, a period word), the call site supplies `<name>_zh` / `<name>_en` / `<name>_ja` and each language's template interpolates its own. Deliberate: the context is frozen into the outbox row, so keeping every language means the bell still renders correctly after the reader changes their language preference. Resolving to a single value at build time would pin the row to whatever language was current at send time.

> **Overrides merge per field.** `_load_template` returns `builtin | override`, not `override`. Migrations 035 / 043 seed Chinese-only `email_template.*` rows; a whole-dict replace silently deleted the builtin's `_en` / `_ja` variants, making the English copy for `transfer_received` / `password_reset` / `alias_created` permanently unreachable.

> **The bell renders BOTH halves per reader.** `InboxService._serialize_rows` calls `render_inbox_title` *and* `render_inbox_body`; rendering only the body (pre-2026-08-09) produced cards whose title was in the producer's hardcoded language and whose body was in the reader's. Both fail safe to the stored column — except that the internal `(see template)` sentinel is never returned to a reader (empty body instead; the title still carries the message). `portal_url` is injected from `row.url` at render time because the send path injects it and it is never persisted.

> **Inbox filtering is by `category`, not `template_key` prefix.** The key namespace is mixed (flat `password_reset` alongside dotted `cloud.billing_alert`), so prefix chips silently returned zero rows for whole domains. `inbox_service._category_clauses()` builds first-match-wins SQL from the same ordered registry `categories.classify` uses, so a chip can never disagree with the label rendered on the rows it filters, and `other` catches everything unclaimed. `template_prefix` survives as a debugging-only parameter.


`notification_outbox` row ([`server/app/notifications/models.py`](../../server/app/notifications/models.py)):

| Column | Purpose |
|--------|---------|
| `recipient_person_id`, `recipient_email` | one or the other; raw email used for vendor notifications |
| `template_key`, `title`, `body_text`, `body_html`, `url`, `context` (JSON) | envelope |
| `card_payload` (JSON, migration 400) | frozen Feishu rich-card fields (action buttons / stats / highlights / sections / subtitle / footer / body_size). Sparse — NULL for plain notifications. The retry + resend paths rebuild the envelope FROM the row; without this a retried/resent card degraded to the plain title+url "Open in Helix" button (the "一键续期" cloud-renewal button vanished on any card whose first send failed). `_serialize_card_payload` freezes it at `dispatch`; `_deserialize_card_payload` restores it on retry/resend |
| `channels`, `delivered_channels`, `failed_channels` (JSON lists) | multi-channel state |
| `status` ∈ {pending, delivered, partial, failed, dead} | `partial` = ≥1 channel delivered, ≥1 failed — **no retry** (recipient was already notified on the working channel); `failed` = zero channels delivered — eligible for retry; `dead` = `attempts ≥ max_attempts` OR permanent error with zero deliveries |
| `attempts`, `max_attempts` (default 5), `last_error`, `next_attempt_at` | retry state; `next_attempt_at` is `NULL` for `partial` / `delivered` / `dead` rows |
| `read_at` | nullable; recipient marks on inbox open |
| `hidden_from_inbox` | (migration 375) push-only marker: the recipient already holds this content as another inbox row (e.g. the Day-1 Feishu welcome card trailing the Day-1 welcome email). Personal bell/inbox queries (`InboxService`) exclude these rows; admin outbox surfaces keep them visible. Set via `NotificationEnvelope.hidden_from_inbox` |
| `related_resource_type`, `related_resource_id` | provenance |
| `created_at`, `delivered_at`, `updated_at` | timeline |

The retry handler is `notification.outbox_retry` — polls rows with `status IN ('pending','failed') AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())`, dispatches by channel, exponential backoff. **`partial` rows are excluded** (PR 2026-06-02): if any channel landed on the original attempt the recipient saw the message, so re-firing the failed channel later only duplicates the same body into a second inbox (Feishu DM + a same-content email days apart). The failed-channel error is still preserved on the row for admin visibility and the admin "重试" button can still force a per-channel retry; only the automated cron leaves `partial` alone.

**Channels**: `email` (smtp_email), `feishu` (feishu_chat_approval), `slack` (slack_software). Channel ↔ driver routing is hard-wired in the handler.

**Invariant**: domain code MUST enqueue via `NotificationService.dispatch()` — that writes the row first, then attempts dispatch. No silent drops; every send has an audit row; every failure has a retry path. Surfaces in the admin UI at `/admin/notifications/outbox`.

**Channel honesty (2026-06-10)**: `_deliver` resolves each requested channel against `NotificationService.channel_availability()` (per-service-instance cached) before attempting it. Three states per channel: **available** (enabled primary integration / single-enabled fallback — send normally), **disabled** (integration row(s) exist, none usable — the admin explicitly turned the channel off, so delivery **skips** it: never lands in `failed_channels`, never blocks `DELIVERED`, a `"<channel>: skipped — integration disabled"` note goes into `last_error` for forensics, and the audit detail carries `channels_skipped`), **unconfigured** (no integration row of the kind at all — misconfiguration, not a choice: still attempted so the failure stays loud per the no-silent-drops invariant). If every push channel is skipped, the row completes `DELIVERED` with `delivered_channels=[]` — the outbox row IS the in-app inbox entry, which is a real delivery surface. Motivation: prod ran 90 days with the `email` integration at `enabled=0` while every envelope still enqueued the channel — 4 839 rows burned 5 retries each into `dead` (91% of the outbox), 414 more froze at `partial`. Channel ↔ kind map: `_CHANNEL_INTEGRATION_KIND` (`feishu`→`chat_approval`, `email`→`email`). Tests: [`test_channel_honesty_and_batch_ops_2026_06_10.py`](../../server/tests/test_notifications/test_channel_honesty_and_batch_ops_2026_06_10.py).

**Automation-health endpoint (2026-06-10, execution-plane PR5)**: `GET /dashboard/insights/automation-health?days=` (admin) — read-only composition answering "did the automation actually work?" over a rolling window: notifications (in-window by status + delivery rate + all-time `critical_dead`), cloud_revoke (`cloud.grant.revoke` audit outcomes + retry failures + live stuck/escalated backlog), provisioning (in-window by status + success rate + transient-retry / dead-letter audit counts), cron (runs / failure rate / top-5 failing jobs by name). Rendered as the `AutomationHealthStrip` four-card row atop the insights operations tab; every red number deep-links to its fixing surface. Tests: [`test_automation_health_2026_06_10.py`](../../server/tests/test_dashboard/test_automation_health_2026_06_10.py).

**Critical-alert guaranteed-delivery backstop (2026-06-10, execution-plane PR4)**: templates matching `_CRITICAL_TEMPLATE_PREFIXES` (`scheduled_job.failure`, `leaver_blocked_sla_urgent/_breach`, `cloud.billing_alert`, `cloud.grant.manual_revoke_escalated`, `provisioning.revoke_failure`) get a last-resort channel: when such a row transitions terminal **DEAD** in `_deliver`, `_fire_critical_fallback` posts a short text (template/title/recipient/error/outbox_id — deliberately never the body, the webhook audience can be broader than the original recipient) to the Feishu **group webhook** (`alert_webhook_url` on the chat_approval integration — independent transport from the DM API that just failed; same SSRF guard + trust_env=False + dual-field ok check as the billing-alert webhook). Outcome (`sent` / `no_webhook_configured` / `webhook_rejected` / `post_failed`) is stamped into `context['critical_fallback']` + audited as `notification.critical_fallback`; the backstop never raises. `GET /outbox/summary` exposes `critical_dead` (dead rows on critical templates) which the outbox page renders as a red banner. Tests: [`test_critical_fallback_2026_06_10.py`](../../server/tests/test_notifications/test_critical_fallback_2026_06_10.py).

**Dead-letter bulk ops (2026-06-10)**: admin outbox triage no longer one-row-at-a-time. `GET /notifications/outbox/error-clusters` groups unhealthy rows by `normalize_error_signature(last_error)` (emails / UUIDs / hex ids / numbers scrubbed → stable cluster key; Python-side over the newest ≤20k rows) and returns per-cluster counts + up to 500 row ids. `POST /outbox/batch-retry` revives ≤500 explicit ids back into the retry pool (`status=failed`, `next_attempt_at=NULL` = due now, `max_attempts=attempts+1` — one fresh attempt per revive, not an infinite license; one `notification.batch_revived` summary audit row) and spawns `drain_due_outbox_in_background` (owned sessions, per-batch commits) so delivery starts immediately instead of waiting for the hourly cron; recipient-departed and channel-skip guards apply in the worker, identically to cron retries. `POST /outbox/batch-cancel` loops the idempotent single-row `cancel_outbox` (per-row audit preserved — silencing alerts stays traceable). Deliberately **no filter-driven mutation mode** — explicit ids keep the blast radius equal to the admin's actual selection; the cluster endpoint hands back the ids for the storm case. `GET /outbox/summary` now also returns per-channel health (`channels.{feishu,email}.state`) which the outbox page renders as a banner; the state comes from the same `channel_availability()` the dispatcher uses, so the banner can never disagree with delivery behaviour.

**Admin triage buckets (2026-06-12)**: the outbox admin surface is cut around four ack-aware operator buckets resolved server-side, NOT the raw status machine. `ACK_NOTE_PREFIX` (`"cancelled by admin"`, [`models.py`](../../server/app/notifications/models.py)) is the single discriminator constant — `GET /outbox?status=` accepts `needs_action` (dead ∧ unacknowledged — the inbox-zero queue, page default), `in_flight` (pending+failed), `notified` (delivered+partial), `acked` (dead ∧ acknowledged = closed history); raw statuses and legacy `unhealthy` still match exactly. `GET /outbox/summary` returns `buckets.{needs_action,in_flight,notified,acked}` + `delivered_7d`; `_serialize` adds `acknowledged` / `inbox_only` flags so the FE never re-types the prefix match (prod motivation: 4 895 of 4 896 dead rows were already acknowledged, but every page counter keyed off raw `status` — the 死信 card screamed 4 896 forever and the operator stopped trusting the page). The error-cluster scan excludes acked rows (`compute_error_clusters(exclude_acked=True)`) so handled storms can't fill the 20k scan cap. FE table renders bucket-level tags (需处理/投递中/已送达/已关闭 + 仅站内/部分渠道 qualifiers); raw attempts/next-retry live in the detail drawer. Tests: [`test_outbox_triage_buckets_2026_06_12.py`](../../server/tests/test_notifications/test_outbox_triage_buckets_2026_06_12.py).

**Resend / 再次发送 (2026-07-01)**: distinct from retry. `POST /outbox/{id}/resend` + `POST /outbox/batch-resend` (≤500, reuses `BatchIdsBody`) **clone** a row's envelope (recipient / title / body / channels / template / context / provenance) into a **brand-new** outbox row and deliver it via `dispatch`, leaving the original delivery record untouched — so it works on ANY source status, `delivered` included (retry deliberately REFUSES terminal rows: re-firing the same row is a silent double-send + a stolen-PAT spam vector). Guards mirror the manual-retry path — a row whose recipient can't be resolved to a live, non-departed Person is skipped (`{status:'skipped', reason}`) rather than re-notifying an ex-employee; person-less raw-email rows are skipped too (`dispatch` needs a Person for open_id + preferences). Templates re-render from the CURRENT `template_key` + stored `context` (desired for "send again"); template-less rows reproduce the stored title/body verbatim; rich-card structure (buttons / stats / sections) is restored from the row's `card_payload` (migration 400) so a resent Feishu card keeps its action buttons. Per-row `notification.resent` audit links `source_id → new_outbox_id`. Service: `NotificationService.resend_outbox_row` / `resend_outbox_rows` ([`service.py`](../../server/app/notifications/service.py)). MCP twin: `resend_notification` (mutate, global_admin, 1:1 with the single endpoint). FE: 已送达/部分/已关闭 rows gain a 「再次发送」 button (Popconfirm) + become selectable for a toolbar 「批量再次发送」; mobile outbox page stays read-only. Tests: [`test_outbox_resend_2026_07_01.py`](../../server/tests/test_notifications/test_outbox_resend_2026_07_01.py).

**Template fallback semantics** (PR 2026-05-02). `_load_template(key)` returns `None` when neither a `platform_settings.email_template.<key>` override nor a built-in `_DEFAULT_TEMPLATES[key]` entry exists. Renderers (`_render_for_recipient`, `_send_email`, `_resolve_button_label`) MUST handle `None` by falling through to `env.title` / `env.body_text`. The previous behaviour returned a truthy placeholder dict (`{"subject": "[Helix] 通知", "body_text": "(no template configured)"}`) which silently displaced the envelope's content — the SLA URGENT dispatch on 2026-05-02 shipped that empty card to ~30 admins before the regression was caught. Tests in [`server/tests/test_notifications/test_template_fallback.py`](../../server/tests/test_notifications/test_template_fallback.py) lock the new contract.

### 6.1 Card UX conventions (PR 2026-05-02; refined 2026-05-03)

Three template-level conventions keep the Feishu card and the email body in sync without per-call branching. All are interpreted by [`app.notifications.service`](../../server/app/notifications/service.py); templates that don't use them keep working unchanged.

* **Inline emphasis** — wrap money / overage / SLA-breach numbers with `[r]…[/r]` (red + bold), general emphasis with `[b]…[/b]` (bold), or the ONE number a card is about with `[h]…[/h]` (bold + one size up — added 2026-08-18 for the leave-balance stat cells). The renderer rewrites per channel: Feishu card BODY → `<font color='red'>**…**</font>` / `**…**` / `<font size='heading_3'>**…**</font>`, plain-text email → `**…**`, HTML email → `<strong style="color:#d4380d;">…</strong>` / `<strong>…</strong>` / `<strong style="font-size:1.3em;">…</strong>`. **A new marker must be mirrored into `text_safety._INLINE_MARKUP_PATTERN` in the same PR** — that helper strips markers out of user-controlled values, and an unmirrored one is a platform-emphasis injection path. The brackets are pure ASCII so they survive `_escape_card_markdown` (which still neutralises any `<>` in user-supplied context vars before the markup pass runs — keeps the XSS guard intact). **Header titles strip the markers** (Feishu interactive-card titles are plain_text-only; rendering `<font>` literally would leak the brackets). Same strip applies to email subjects, since most clients render asterisks literally and inbox previews truncate aggressively. The 2026-05-03 cloud-billing-alert regression — `[云账单] tidb-o11y 超出月阈值 [r]$140.76[/r]` showed up character-for-character in Feishu titles — is what motivated this rule.

* **Header colour by template** — `_card_header_color(template_key)` maps templates to Feishu palette tints: `cloud.billing_alert` → `red`, `leaver_blocked_sla_warn` → `orange`, `leaver_blocked_sla_urgent` → `red`, `leaver_blocked_sla_breach` → `carmine`, `asset_inventory_reminder` → `yellow`, `asset_inventory_escalation` → `orange`. Default is `blue`. Since the title text itself can't carry colour (plain_text), the header tint is what conveys urgency at a glance.

* **`{cta_line}` placeholder** — templates put it on its own line where they used to inline `请到 Helix 审批: {portal_url}`. For the Feishu card it expands to empty (`_collapse_blank_lines` removes the resulting gap) since the card already carries an action button; for email it expands to `请到 Helix 查看: <portal_url>` so plain-text email retains its only clickable affordance. Caller-supplied `cta_line` in `env.context` wins.

The three together produce the visual pattern the user asked for: amounts pop, the action button is the single CTA on Feishu, header colour conveys urgency, and email recipients still see the URL inline.

**Provider label resolution (added 2026-05-04)**: `cloud.billing_alert` renders `provider_label_zh` / `provider_label_en` into the subject prefix (`[云账单·阿里云]` / `[Cloud bill · Aliyun]`) so recipients can tell which cloud a scope lives on at a glance. The provider string is on the `Integration` row, **not** on `CmdbCloudScope` — the model has no `provider` column. `evaluate_scope_alert_rules` accepts an optional `integration` parameter (caller-supplied to skip a round-trip; falls back to `await session.get(Integration, scope.integration_id)`). Earlier code that read `scope.provider` directly raised `AttributeError` and silently dropped every billing alert until the next deploy.

**Recipient routing (added 2026-05-06; refined later same day)**: `cloud.billing_alert` (per-rule) routes to the **union** of three sources, computed per rule and de-duplicated by `IdentityPerson.id`:

1. **Scope owner** (`scope.owner_person_id`) — always paged.
2. **Billing-class grant holders** — anyone with an active grant on this scope whose `role_key` / `display_name` matches the billing keyword sieve in [`server/app/domains/cmdb/role_classifier.py`](../../server/app/domains/cmdb/role_classifier.py) (Aliyun `AliyunBillingFullAccess` / `AliyunBSSReadOnlyAccess`, Azure `Cost Management Reader` / `Billing Reader`, GCP `roles/billing.viewer`, AWS `Billing*`, Tencent `QcloudFinanceFullAccess`, plus any custom role whose name contains `billing` / `cost` / `finance` / `bss` / `账单` / `财务` / `计费`).
3. **Owner-curated CC list** (`rule.cc_person_ids`) — the explicit override knob for non-grant-holding stakeholders (finance partner, FinOps Slack-channel owner, leadership). The owner maintains this per-rule in the alert config UI ([`OwnerAlertsPanel`](../../web/src/pages/cloud/MyCloudScopeDetailPage.tsx) on the `/my` view; admin [`SetAlertsModal`](../../web/src/pages/cloud/CloudScopeDetailPage.tsx) on `/admin`). Default routing already covers the common case; this list stays empty unless someone outside that set genuinely needs the ping.

The previous behaviour had `cc_person_ids` as the *only* fan-out source (admin-curated free-form list, easy to drift into "everyone on the team", and silently dropped non-CC'd stakeholders who held billing roles). An interim refactor earlier the same day temporarily removed `cc_person_ids` from both the dispatcher and the UI; that broke the "owner adds finance partner to a specific high-threshold rule" flow, so the override is restored — but the **default** is no longer "whatever the owner typed into a free-form list."

The daily-spike notification (`_alert_spike`) uses the same owner + billing-role policy but **does not** consult `cc_person_ids` — there's no per-rule CC concept for the spike (it's a scope-level threshold detector, not a configurable rule). It also no longer fans out to every `it_admin` / `global_admin` (which paged the entire IT pool on every transient day-over-day jump).

**Action-button URL (corrected 2026-05-06)**: the alert card's "查看账单详情" button used to deep-link to `/admin/cloud/scopes/{id}` — a non-admin scope owner / billing-grant holder hit a 403 the moment they tapped through. URL is now `/my/cloud/scopes/{id}` for both `evaluate_scope_alert_rules` and `_alert_spike`; the `/my` route is the universal entry (admins read it too) and the same backend `get_scope` / `list_grants` membership gate applies, so an alert recipient who isn't a member will see a clean 403 from the page-load check rather than a misleading admin-only 403.

**Tier suppression (added 2026-05-07)**: `evaluate_scope_alert_rules` previously paged once per crossed threshold — a scope holding the default 50% / 80% / 100% monthly tiers fired three Feishu cards on the same day when MTD cost passed the highest one (reported by an admin who got bombed for an Azure `tidb-o11y` subscription whose MTD = $3,212.71 against a $1,000 budget). The evaluator now pre-computes the highest-threshold rule per `rule_type` (daily / monthly) that would fire this run; only that rule actually dispatches. Lower-threshold siblings are still marked as fired (`last_fired[period_key] = True`) for the period so the next cron tick doesn't re-evaluate and re-page them after a transient cost dip. Daily and monthly tiers are independent (a daily-spike alert is not suppressed by a monthly-overrun winner). Regression test: [`test_alert_rules_only_fire_highest_threshold_per_type`](../../server/tests/test_cmdb/test_role_curation_and_groups.py).

**Claim-first dedup (2026-05-16, migration 171)**: pre-2026-05-16 dedup state lived in `scope.alert_rules[*].last_fired` JSON — mutated AFTER dispatch and persisted only when the outer `sync_billing_daily_for_integration` cron commit landed. Any exception between dispatch and commit (unregistered-reconciliation INSERT crash, FX-rate hiccup, transient driver failure) rolled back the JSON mutation while the Feishu card was already in flight → next cron tick re-fired. Prod incident 2026-05-16: a `backfill_cost_facts.py` run (6-month historical replay) issued ~7 cards per scope across 60 scopes for this reason. Mirrors the TOCTOU bug PR #298 fixed for `apikey.budget_alerts` with the same shape.

New table [`cmdb_cloud_scope_alert_fired`](../../server/alembic/versions/171_cmdb_cloud_scope_alert_fired.py) with `UNIQUE(scope_id, rule_type, period_key, threshold_usd)` makes dedup atomic at *claim* time:
- `_try_claim_cloud_scope_alert_slot` inserts inside `begin_nested()` (SAVEPOINT) → success owns the slot AND will dispatch; `IntegrityError` → another run already claimed → skip.
- Covers monthly rules (`rule_type='monthly'`, `period_key='YYYY-MM'`), daily rules (`rule_type='daily'`, `period_key='YYYY-MM-DD'`), AND spike alerts (`rule_type='daily_spike'`, `threshold_usd=multiplier`). One table for three signals.
- **Deploy-day back-compat**: the legacy `last_fired` JSON map and `scope.last_spike_alert_date` are read as fast-path skips BEFORE the claim attempt — so the prod hotfix that data-stamped 140 scopes' JSON for 2026-05 isn't steamrolled by an empty new table on the first post-deploy cron tick. The JSON map is still WRITTEN for back-compat read-side consumers (admin UI / MCP tools that render alert history off it).
- **Threshold-independent re-fire guard (2026-05-16 pre-launch follow-up)**: while the UNIQUE includes `threshold_usd`, the caller paths consult [`_period_already_alerted`](../../server/app/domains/cmdb/lifecycle_service.py) BEFORE attempting a claim — that helper checks existence on `(scope_id, rule_type, period_key)` only, ignoring `threshold_usd`. `evaluate_scope_alert_rules` builds a per-call SNAPSHOT `pre_existing_period_alerts: set[(rule_type, period_key)]` from one SELECT before its rule loop, so an admin who tunes a threshold mid-period (e.g. monthly $500 → $100, or daily-spike multiplier 1.5× → 1.2×) does NOT re-page the same scope on the same data via a freshly-opened slot under the new threshold. The snapshot design is essential — a live SELECT in the loop would also short-circuit WITHIN-run tier-suppression (3 monthly thresholds at 50%/80%/100% claim 3 slots in one run, only the highest dispatches), so the snapshot captures "pre-run state only". The spike path in `sync_billing_daily_for_integration` uses the live helper directly (one scope at a time, no within-loop tier siblings). Database UNIQUE remains the atomic anchor for concurrent-worker races.
- **Empty-recipient visibility (2026-05-16 pre-launch follow-up)**: when a fired rule resolves to zero recipients (scope owner unbound + no billing-class grant holders + no rule `cc_person_ids`) the slot is intentionally consumed (otherwise every cron tick re-evaluates and re-attempts — log spam without action), but the code now emits a structured `logger.warning("cloud.scope_alert dispatched to empty recipient set: scope_id=… rule_type=… period=… threshold=… current=…")` (per `evaluate_scope_alert_rules`) or `cloud.spike_alert empty recipient set: …` (per `_alert_spike`) so SRE / admin sees the gap in the log aggregator. Single-claim slot means at most one such warning per (scope, period) even across cron retries. The remediation is documented inline in the message: bind an owner or grant a billing-class role.
- **Refund/credit day skip (2026-05-16 pre-launch follow-up)**: spike detection in `sync_billing_daily_for_integration` adds `today_usd > 0` to the trigger condition. Refund / credit days are stored as negative `total_cost_usd`; without this guard, a post-refund return-to-normal (avg negative, today positive) would still hit `today_usd > avg_usd * multiplier` and fire a spurious "spike high" on what is just normal usage. Pairs with the existing `avg_usd > 0` guard for double-sided defence.

**`dispatch_alerts` switch (2026-05-16)**: `sync_billing_daily_for_integration` takes `dispatch_alerts: bool = True`. The daily cron leaves it at default. [`backfill_cost_facts.py`](../../server/scripts/backfill_cost_facts.py) passes `False` — slots still claim (so the next live cron tick doesn't re-page on historical-month data) but Feishu/email are not sent. Without this, a multi-month replay fires one card per scope per crossed-threshold per chunk — N×M card storm. `evaluate_scope_alert_rules` takes the matching `dispatch=True/False` parameter; the spike path checks the same flag after a successful claim.

**Monthly card visual refresh (2026-05-16)**: prior card was a plain `cloud.billing_alert` template (subject + 4 lines of plain text) with NO period_key in the title — a backfill-driven historical-month alert was indistinguishable from a current-month spike. Card now uses `card_stats` + `card_sections` (mirrors `_alert_spike`'s daily-spike visual): subtitle = `{integration} / {env} / {period_key}`, title = `☁️ 云账单月度阈值 — 2026-05 — {scope_label}`, stats include `2026-05 月累计花费`, `阈值`, `超出阈值`, `月度预算`, `月末预估` (linear projection over remaining days), `环境`. Monthly card also shows `↑X% vs 上月` trend chip on the MTD tile when [`sync_billing_daily_for_integration`](../../server/app/domains/cmdb/lifecycle_service.py) resolves the prior-month total from `cmdb_cloud_billing_daily`.

**Page-vs-alert data-source agreement (2026-05-16)**: alerts read MTD from `cmdb_cloud_billing_daily` but the per-scope drawer at [`scope_billing_detail.py`](../../server/app/domains/cmdb/scope_billing_detail.py) reads `cmdb_cost_facts`. Two separate crons write these. If `cmdb.cost_facts_sync` fails for a scope (e.g., AWS BQ-CUR schema heterogeneity — `cost_facts_sync` partial-fails on linked accounts missing `line_item_resource_id` / `resource_tags_user_*` columns) but `billing_daily_sync` succeeds, the page shows `$0` while the alert says `$5,505`. Fix: when `cost_facts` returns zero rows for a tenant-level (non-RG) scope, the drawer falls back to a `billing_daily` aggregation — `summary.data_source` switches to `"billing_daily_fallback"` and `data_source_note` carries `"成本明细暂未同步,数字来自每日账单汇总"` so the frontend can banner the caveat. `cost_by_service` / `top_resources` remain empty in fallback (billing_daily has no breakdown). RG-typed scopes never fall back (cost_facts is the only source). **CNY-rendered fallback budget% fix (2026-05-16 pre-launch P1)**: when the fallback rendered `currency='CNY'`, the old `month_total / budget_usd_monthly` ratio inflated budget% by ~7× (CNY ÷ USD); now `month_total_for_budget = month_total / cny_per_usd` via `app.core.fx_rate.resolve_cny_per_usd` before the ratio, and `summary.budget_currency_note` documents the conversion. Non-USD non-CNY currencies (BRL / JPY etc.) refuse the ratio and surface "budget_progress unavailable: <currency> not supported by global FX layer". **Breakdowns-availability flag (2026-05-16 pre-launch P0)**: `summary.breakdowns_available: bool` is `True` only when `data_source='cost_facts'` (i.e., the canonical line-item path produced data); fallback paths set it `False` so the frontend can render an explicit "明细暂未同步" banner instead of mysterious empty arrays.

**AWS non-USD billing conversion (2026-05-16 pre-launch P1)**: pre-fix the AWS driver set `amount_usd=0` for every non-USD CE / CUR / principal-tag row, silently undercounting AWS China (Sinnet/NWCD, CNY-billed partition) in every USD aggregate. The new top-level helper [`integrations/drivers/aws_cloud.py::_convert_to_usd(amount, currency, cny_per_usd)`](../../server/app/integrations/drivers/aws_cloud.py) maps `USD → pass-through`, `CNY → amount / cny_per_usd` (FX rate threaded through via the previously-discarded `cny_per_usd` kwarg on `list_cost_facts` — now stored on `self._cny_per_usd_for_cost_facts` for the sync paths), other currencies → 0 + one-shot `logger.warning` (Helix has no FX layer for BRL/JPY; operator must add one before those tenants show non-zero USD). All three call sites (`_list_cost_facts_sync` CE, `_list_cost_facts_bq_sync` CUR, `_list_cost_facts_principal_tag_sync` IAM-principal-tag) now route through the helper; `amount_native` continues to carry the raw vendor amount untouched.

**Known gap — AWS `list_billing_daily` non-USD rows (2026-05-16 Round-1 review)**: `_list_cost_facts_*` paths now FX-convert CNY → USD via `_convert_to_usd` (PR #330), but `list_billing_daily` (both CE and BQ paths) still hardcodes `total_cost_usd=0` for non-USD rows. Deliberate scoping — `cmdb_cloud_billing_daily.UNIQUE` doesn't include currency, so a daily-grain row's `currency` field is the sole authoritative currency for that row, and the alert evaluator already FX-converts on read (`lifecycle_service.sync_billing_daily_for_integration` → `resolve_cny_per_usd`). The asymmetry between cost_facts (driver converts) and billing_daily (reader converts) is intentional and consistent; a future unification PR could move both to driver-side conversion. Tracked here so a future reviewer doesn't mistake the disparity for a regression.

**AWS billing_source toggle guard (2026-05-16 pre-launch P1 follow-up)**: AWS-only. `cost_service.ingest_cost_facts` stamps `platform_settings.cost_facts.last_billing_source.<integration_id>` after each successful ingest with the current `integration.config_public.billing_source` value. The next ingest reads the marker first; if it differs from the live config, the ingest `raise`s with a `RuntimeError` carrying the exact `DELETE FROM cmdb_cost_facts WHERE integration_id='<uuid>' AND date BETWEEN '<start>' AND '<end>'` purge SQL bounded by the current batch's date range. Without this guard, an admin flipping `cost_explorer` ↔ `bigquery` between ticks lands TWO `cmdb_cost_facts` rows per logical day×resource (CE emits `usage_type=NULL`, BQ emits the real `line_item_usage_type` — `compute_fact_key` includes `usage_type` so the hashes diverge), and `SUM(amount_usd)` double-counts the entire toggle window. The marker is system-managed (cron writes only); admin UI does NOT expose it. Toggle procedure: docs/ops/cloud-lifecycle-ops.md §9a-aws-bq troubleshooting table includes the 5-step SQL flow.

**S3 region enrichment (2026-05-16 pre-launch P2 follow-up)**: `providers/aws.py::_list_object_storage_sync` now calls `s3.get_bucket_location` per bucket and normalises the `LocationConstraint` (`None` / `""` → `"us-east-1"`, AWS's historical "no constraint = us-east-1" wart). Per-bucket 403 / NoSuchBucket / cross-region 404 → `partial_failures` entry with `leg=bucket:<name>` so the admin sync-history UI surfaces "N buckets without region" instead of a green sync that quietly populated `region=None` for every bucket. Auth errors propagate. The per-bucket round-trip is fine for typical fleets (≤ a few hundred buckets) and reuses the session's wrapped `client` with the timeout config from #329.

**Mixed-currency day refusal in scope_billing_detail fallback (2026-05-16 pre-launch P2 follow-up)**: `_billing_daily_fallback` tracks the currency set per-date; days with `len(set) > 1` (rare cross-provider aliasing) render as `0` and the response `note` lists the affected ISO dates (max 5, "…" suffix for more). Pre-fix, a day with `CNY 100 + USD 50` summed to `150` on the wire as if they were the same unit — a 7× wrong number for any auditor reading the daily chart. With the refusal, finance sees an explicit unsummable signal instead of a plausible-but-wrong total.

**Admin overview Azure RG hint (2026-05-16 pre-launch P2 follow-up)**: `_cost_by_scope_ranking` returns a new `has_rg_breakdown: bool` field, `True` only when the row is an `azure_subscription` scope on an Azure integration. The FE renders a "点开看 RG 细分" affordance so users know the parent-subscription dollar figure is an aggregate that includes its resource groups. The per-scope drawer's resource-level breakdown remains the canonical place to see the RG split (cost_facts for an RG carry the parent subscription's `scope_id` by ingestion contract).

**AWS CUR Savings Plan discount accounting (2026-05-17, PR #339)**: every BQ CUR SUM (`_list_billing_daily_bq_sync` × 3 inner SELECTs — totals / services / owner; `_list_cost_facts_bq_sync`; `_list_billing_by_dimension_bq_sync`) MUST sum `line_item_unblended_cost` across ALL line item types, NOT filter out `SavingsPlanNegation` rows or `ComputeSavingsPlans` product-code rows. CUR emits an SP-covered EC2 hour as **three rows**: a positive `Usage` row at the on-demand list price, a negative `SavingsPlanNegation` row carrying the SP discount (with `line_item_product_code='ComputeSavingsPlans'`), and a zero-amount `SavingsPlanCoveredUsage` tracking row whose effective cost lives in `savings_plan_savings_plan_effective_cost`. Payer accounts additionally see positive `SavingsPlanRecurringFee` rows for the commitment fee. `SUM(unblended_cost)` across everything = $X(on-demand) − $Y(SP discount on linked account) + $Z(SP fee on payer) = the actual invoiced amount, matching AWS Cost Explorer's "Net unblended cost" view. Pre-fix Helix excluded both row classes which dropped the discount and inflated every AWS scope's reported cost by the SP rate (~30–40%); reproduced against prod account 552185537300 (April 2026: Helix $8,360.87 vs invoice $5,505.88, **34% over**). The fix removes the WHERE filters at all 5 sites and (for the service breakdown only) adds a `CASE` that buckets every SP-related line item — `SavingsPlanNegation` / `SavingsPlanCoveredUsage` / `SavingsPlanRecurringFee` / `SavingsPlanUpfrontFee` / `product_code='ComputeSavingsPlans'` — under a single `'Savings Plans'` pseudo-service so the per-scope donut has one clearly labelled discount slice (negative on linked accounts, positive on the payer's SP fee row) rather than emitting a CUR-internal product name like `Savings Plans for AWS Compute usage` that mixes both signs in the same legend entry. The `AwsCloudDriver` class header carries a 35-line **SP semantics doc block** enumerating the three-row CUR model + this prod incident's numbers so a future "let's clean up the SQL" PR doesn't reintroduce the filter. Pinned by 5 regression tests in [test_aws_cloud_bq_billing.py](../../server/tests/test_integrations/test_aws_cloud_bq_billing.py) — three SQL-string captures (totals / cost_facts / by-dimension all assert `'SavingsPlanNegation'` and `'ComputeSavingsPlans'` are NOT in the WHERE clause), one CASE-shape assertion on the service breakdown, and one end-to-end behavioural test that replays the prod 552185537300 numbers ($8,360.87 gross + (-$2,854.99) SP discount = $5,505.88 net). **Backfill** (operator action after merge): trigger one-shot reingest of `cmdb.billing_daily_sync` and `cmdb.cost_facts_sync` with explicit `start_date` / `end_date` in the cron's config dict (both handlers already accept the override) covering the trailing 12 months — no new code needed.

**AWS CUR 2.0 EDP / Private Rate Discount bucketing (2026-05-20, follow-up to dataset cutover)**: after PingCAP prod switched the AWS-primary integration from `aws_nonprod_billing` (CUR 1.0 export, per-account Parquet) to `aws_billing_no_payer` (CUR 2.0 / Data Exports v2, per-month native + nested tags), prod TiDB `cmdb_cost_facts` showed daily totals ~$631/day lower than CUR 1.0 on the same account (385595570414, 2026-05-01: $2687.81 → $2056.69). BQ direct probe across both datasets line-by-line found two new line_item_types present in CUR 2.0 but ABSENT in CUR 1.0: `EdpDiscount` (-$566.39 — PingCAP's negotiated Enterprise Discount Program, org-wide ~$26K/day × 93 linked accounts) and `PrivateRateDiscount` (-$45.31, ~$24K/day × 48 accounts, per-SKU rate adjustments). Both are negative line items with NULL `line_item_product_code` AND NULL `line_item_resource_id`. CUR 1.0 export was created WITHOUT "include discount details" in the original AWS Billing Console configuration so these rows never reach the dataset; CUR 2.0 / Data Exports v2 surfaces them by default. Net cost ($2056.69) is the amount PingCAP actually pays AWS per invoice; CUR 1.0 was over-reporting list-price spend by ~23% across all linked accounts. **The fix is purely cosmetic — Helix's `SUM(unblended_cost)` was already correct numerically**; the issue was that EDP+PRD rows landed on an anonymous `'Other'` service slice (because both share NULL product_code → service_name fallback) and the two discount classes collided on the synthesised resource_id `aws-account:<acc>:None`, losing one to the fact_key upsert. PR adds 2 WHEN clauses to the CASE in `_service_select` (billing_daily) and a parallel `discount_bucket` CASE in `_select_for_pick` (cost_facts) labeling them `'EDP Discount'` / `'Private Rate Discount'`; Python `_list_cost_facts_bq_sync` reads `getattr(row, 'discount_bucket', None)` and uses it as both the `service_label` and the synthesised resource_id suffix (so EDP and PRD land on distinct fact_keys per (day, account)). Frontend `ScopeBillingPanel.tsx` translates the new bucket names via a centralised `bucketLabel()` helper picking up new i18n keys `cloudBilling.drawer.edpDiscountBucket` / `cloudBilling.drawer.privateRateDiscountBucket` (zh+en+ja). Help center FAQ adds `faq.enterpriseDiscount` explaining to confused owners why their AWS total "dropped 20-23% without usage decreasing". Pinned by 2 new tests: `test_list_cost_facts_bq_buckets_edp_discount_separately` (2 synthetic rows for EDP + PRD on same day same account → asserts 2 facts emitted with distinct service / resource_id) and 4 SQL-string assertions in `test_list_billing_daily_bq_service_breakdown_buckets_sp` (the `'EDP Discount'` literal, `EdpDiscount` source line type, `'Private Rate Discount'` literal, `PrivateRateDiscount` source line type all present in the services SQL). **Backfill required**: after PR ship, trigger one-shot `sync_cost_facts_for_integration(start_date=2025-10-01, end_date=today)` to re-bucket historical CUR 2.0 data; pre-PR historical rows have EDP+PRD merged into one anonymous `'Other'` fact per (day, account) — numerically correct, but no chart visibility.

### 6.2 Personal-inbox service (added 2026-05-03)

[`server/app/notifications/inbox_service.py`](../../server/app/notifications/inbox_service.py) (`InboxService`) is the single read/write path for the recipient-facing inbox. Used by:

- HTTP `/api/v1/notifications/inbox` (list, summary, **get-by-id** `/inbox/{id}`, mark-read). `GET /inbox/{id}` (2026-07-10, `get_for_recipient`) is the owner-scoped O(1) fetch of one row — declared AFTER `/inbox/summary` so the static path wins; applies the same visibility clauses as the list (another user's / hidden / secret id → 404, no existence oracle). It replaced the mobile/desktop detail pages' "page the whole inbox to resolve a deep-linked id" workaround (#1804, up to 30×100 rows).
- MCP tools `list_my_notifications`, `my_notification_summary`, `mark_my_notifications_read`.
- The frontend bell dropdown ([`web/src/components/NotificationCenter/NotificationCenter.tsx`](../../web/src/components/NotificationCenter/NotificationCenter.tsx)) and the new full-page **通知中心** at `/me/notifications` ([`web/src/pages/me/NotificationCenterPage.tsx`](../../web/src/pages/me/NotificationCenterPage.tsx)).

Methods:
- `list_for_recipient(recipient_id, page, page_size)` — paged inbox, `status IN (delivered, partial)`, returns `{items, total, page, page_size}`. Each item includes `read_at`, `delivered_at`, plus `template_key` so the page can family-bucket rows.
- `summary_for_recipient(recipient_id)` — `{total, unread, last_created_at}` for the bell badge.
- `mark_read(recipient_id, ids)` — flips `read_at` from NULL → now() ONLY for rows the recipient owns. Empty `ids` is a no-op (zero updated). Caller commits.
- `mark_all_read(recipient_id)` — bulk variant; same row-ownership filter.

Bell dropdown now flips read state per-click (previous behaviour deferred to dropdown close), so single-row clicks reflect immediately on the badge.

### 6.3 Rich-card primitive (2026-05-15)

`NotificationEnvelope` carries 6 optional Feishu-card fields beyond the simple `title` + `body_text` + `url` shape: `card_subtitle` (grey one-liner below title), `card_stats: list[CardStat]` (renders as a 2x2 / 2x4 / 2x6 `column_set` grid with auto-coloured trend tokens — `▼` green, `▲` red, override via `trend_is_good`), `card_highlights: list[CardHighlight]` (bullet-row 人名 highlight section), `card_sections: list[CardSection]` (divider-separated free-form blocks with `tone` ∈ `info|warn|danger|muted` controlling emoji prefix + body colour), `card_actions: list[CardAction]` (multi-CTA action row that supersedes the single `url` button), `card_footer` (note element; defaults to `— Helix IT 平台`). All ignored on the email channel.

Callers who want the rich layout populate the relevant fields; callers who don't keep working unchanged. The central [`_build_interactive_card`](../../server/app/notifications/service.py) renders them; the 54 existing `NotificationService.dispatch` call sites automatically pick up the polish (better spacing, footer note, header colour) without per-call work. High-value cards (failure_notifier success-rate stats / AI budget alert / cloud-billing daily spike / asset borrow overdue / offboarding leaver+manager / new-owner-on-transfer / cmdb owner semiannual + zero-spend) populate the rich fields explicitly — pairs with the user-provided "文档统计 + 风险预警" visual reference.

> **卡片正文的两处去重都在渲染器里,不在模板里** (2026-08-28). 模板是照邮件写的 —— 邮件没有按钮,正文是唯一能点的地方 —— 所以每个模板都在 CTA 上方单独放一行目标地址,并在结尾自己签个名。飞书卡片把同一段正文再渲染一遍,于是这两样都翻倍。`_build_interactive_card` 在渲染前剥掉:**① 结尾的落款行**(`_strip_trailing_footer`)—— 原来只比对 `DEFAULT_CARD_FOOTER` 的三个精确值,而各域模板签的是自己的名字(`— Helix 安全团队` / `— Helix 流程台账` / `— Helix システム台帳`,bounty + approval_registry + system_registry 共 17 个 key),一个都不在表里,于是这些卡同时显示域落款和渲染器贴的平台落款;现在还认「行首破折号 + Helix」这个形状。**② CTA 上方那条裸链接**(`_strip_button_duplicate_links`)—— 仅当卡片真的有按钮通往同一地址时才剥,比较的是 path+query+fragment 而非字符串(正文常是生产者塞进 `context` 的相对路径 `/me/api-keys`,按钮是拼好的绝对地址);引出它的冒号跟着一起走。按钮清单必须镜像函数末尾的 `if extra_actions: … elif url:` —— 多 CTA 卡上主 `url` 不渲染成按钮,照它剥会剥掉正文里唯一一条通往那个地址的路。**两处都是卡片专属**:邮件正文没有 footer 元素也没有按钮,签名和链接必须原样留着。
>
> 默认落款同时加了一句人话(`CARD_FOOTER_WARMTH` + `compose_default_card_footer`),按模板 key 用 `zlib.crc32` 轮换 —— 必须是**稳定摘要**,内建 `hash()` 每进程加盐,outbox 重试那次会换一句。句子按「能活过最糟的那张卡」挑(SLA 违约 / 离职闸门 / 安全发现),所以给的是陪伴和求助入口,不是庆祝。自带 `card_footer` 的模板(HR 休假寄语、feed 的 6 句轮换池)不追加 —— 它们已经选过落款语气。`DEFAULT_CARD_FOOTER` 本身**不动**:那张表同时是比对模板正文的去重表(`_CANONICAL_FOOTERS`)。

### 6.3.1 Cloud-grant approval inbox (added 2026-05-19)

[`GET /api/v1/cmdb/my-pending-grant-approvals`](../../server/app/domains/cmdb/lifecycle_router.py) returns every `cmdb_cloud_access_grant` row in `status='pending'` that the caller is responsible for approving — `global_admin` sees org-wide; plain scope owners see pending grants on scopes they own, with self-requested rows filtered out (four-eyes rule, defence in depth alongside the service-layer ConflictError on self-approve); everyone else gets a 200 with empty items (the MyTodosPage badge polls this for every authenticated user, so a 403 would flash an error toast on every page load).

Powers two discovery surfaces fixing the original "owner can't find where to approve" UX bug:

- **MyTodosPage cloud-grant approvals tab** ([`web/src/pages/my/MyTodosPage.tsx`](../../web/src/pages/my/MyTodosPage.tsx)) — third tab next to Asset Transfers + Provisioning. Inline approve/reject per row; row click jumps to `/cloud/scopes/{id}` (role-aware redirect → admin or `/my/` view).
- **Per-scope "待审批 · N" tab** ([`PendingGrantsTab.tsx`](../../web/src/components/cloud/PendingGrantsTab.tsx)) embedded as the first tab on both `MyCloudScopeDetailPage` and `CloudScopeDetailPage` when `pendingGrantCount > 0`. Groups by `request_group_id` so a multi-role submission renders as one card with one batch approve button — same semantics as the existing batchAct path in the members tab, just surfaced where it's discoverable.

The notification dispatch on `submit_access_request` now emits `f"/cloud/scopes/{id}"` (bare path, role-aware) instead of `f"/my/cloud/scopes/{id}"` — every other cloud-scope-targeting `NotificationEnvelope.url` (spike alert, escalation, scope-request provisioned, idle-grant warning) was flipped in the same PR for consistency. Frontend `MyCloudScopeDetailPage.isOwner` was widened to `canManage = isOwner || isGlobalAdmin` for action gates (approve / reject / add-member / settings tabs), reserving `isOwner` for the transfer-owner button copy that's literally "you will lose your management rights". Backend `_require_scope_owner_or_admin` was already permissive — this brings the UI gate to parity.

Member search: both scope detail pages now ship a case-insensitive `person_label` filter at the top of the members tab. Closes the 130-member scrollability dead-end on large AWS production accounts.

MCP twin: `list_my_pending_cloud_grant_approvals` ([`server/app/mcp/tools.py`](../../server/app/mcp/tools.py)) — same visibility rules, returns scope label / requester / role / requested TTL / reason so an AI client can rank or filter before calling the existing approve/reject HTTP routes. `_REGISTERED_TOOLS_COUNT_HINT` 153 → 154.

Tests: [`test_my_pending_grant_approvals.py`](../../server/tests/test_cmdb/test_my_pending_grant_approvals.py) pins the three contract scenarios (owner narrow + self-filter, admin org-wide, plain employee empty 200).

### 6.4 Cloud-scope owner governance (2026-05-15)

`cmdb_cloud_scope_owner_confirmations` (migration 168) is the single timeline table for owner-governance events on a `cmdb_cloud_scope`. Ten `kind` values share one table so the detail-page tab "Owner 与治理记录" renders them chronologically without a UNION: `semiannual_pending` / `semiannual_confirmed` / `semiannual_transferred` / `zero_spend_pending` / `zero_spend_keep` / `zero_spend_cleanup` / `zero_spend_handover_it` / `escalated_to_it` (historical — see the escalation-retirement note below) / `owner_transferred` / `idle_notified`. Pending-kind rows track `status='pending'`; owner responses flip to `responded` and stamp `response_kind` + `response_at`; 14-day escalation writes a separate `escalated_to_it` row threaded via `parent_confirmation_id` (NOT a status flip on the pending row — keeps cause→consequence visible). The standalone `owner_transferred` leaf (added 2026-05-16) is written by `assign_scope_owner` whenever a transfer happens with no outstanding `semiannual_pending` row to flip — admin-initiated rebinds and self-transfers without an active prompt. Without it the transfer would only land in `audit_log` (which the timeline tab does not read) and owners/auditors would see no trace. **Owner-history completeness (2026-08-24)**: two owner-CHANGE paths never write a row here, so the timeline used to omit them entirely — the sync's initial auto-claim (`cloud.scope.auto_owner`; `assign_scope_owner` deliberately skips the `owner_transferred` leaf when there is no previous owner, since "transferred from —" reads as a bug) and offboarding's force-transfer ([`hr_lifecycle/advance_service.py`](../../server/app/hr_lifecycle/advance_service.py) rewrites `owner_person_id` directly and never calls `assign_scope_owner` at all). `OwnerConfirmationService.list_timeline_for_scope()` now merges those two `audit_logs` actions in **at read time** as read-only `OwnerTimelineEntry` values with the display-only kinds `owner_auto_assigned` / `owner_force_transferred` and `derived=true`. Read-time synthesis rather than back-writing rows + a backfill migration: one source of truth per event, and the full pre-existing history for free (212 such audit rows in prod on 2026-08-24, so the extra indexed SELECT is cheap). The API endpoint and the MCP tool both call this method so the two surfaces cannot drift; the frontend must never render response buttons on a `derived` entry. `voided_owner_changed` is a **response_kind only** (never a row `kind`) — see the stale-addressee rule below.

The `idle_notified` leaf (added 2026-06-09) is written by the weekly `cmdb.scope_idle_sweep` for each scope it actually notifies (0 active grants + $0/30d) so the owner has a per-scope record that they WERE pinged — previously the sweep only sent a `related_resource_id=NULL` digest + overwrote `last_idle_notified_at`, leaving no per-scope trace. Leaf shape (`status='responded'`, no `notified_at` — `created_at` marks the ping; `spend_recent_30d_usd=0` + `member_count_snapshot=0` snapshot), so it is NOT picked up by `escalate_overdue` (which only escalates the two `_pending` kinds). Dedup is inherited from the sweep itself (`last_idle_notified_at` + `IDLE_SWEEP_DEDUP_DAYS=14d`) → at most ~one row per scope per 2 weeks.

`cmdb_cloud_scopes.last_owner_confirmed_at` is the source of truth for the 180-day cron threshold. Updated by:
- The semiannual `confirm` endpoint (owner clicked ✅).
- The offboarding force-transfer (`hr_lifecycle/advance_service.py`, migration 535 for history): the handover **is** the confirmation — the successor already receives the "[自动转让] … 已转给你" push naming the subscription, so leaving the clock NULL would read as "never confirmed" and ask them again days later about the same handover. Migration 532 back-stamped the 9 historical handovers that still had a NULL clock, using each scope's most recent successful handover timestamp (not `now`, which would silently grant a fresh half-year).
- [`assign_scope_owner`](../../server/app/domains/cmdb/lifecycle_service.py) on every successful transfer (the new owner implicitly resets the 180-day clock; the cron also marks any outstanding semiannual_pending as `semiannual_transferred` so the at-most-one-outstanding invariant holds for the new owner).

Two crons (registry: `cmdb.owner_semiannual_confirmation` daily 09:00, `cmdb.zero_spend_alert` monthly 1st 09:00 — both Asia/Shanghai) live in [`server/app/scheduled_jobs/cloud_owner_handlers.py`](../../server/app/scheduled_jobs/cloud_owner_handlers.py). Eligibility for both:
- `environment ≠ prod` (PROD_ENVIRONMENTS is the centralised constant)
- `status = active`, `deleted_at IS NULL`, `owner_person_id IS NOT NULL`
- Owner not a leaver (`employment_status` not in `left`/`leaver_pending`)
- At-most-one-outstanding-pending per (scope, kind) — `list_scopes_due_*` short-circuits when a pending row exists, so same-day cron re-runs are idempotent.

Daily cron ALSO runs the escalation sweep (`escalate_overdue`) so zero-spend prompts don't have to wait up to 30 days for their first chance at IT escalation; both kinds share the same 14-day cutoff.

**Init-sweep override (2026-05-19)**: `run_owner_semiannual_confirmation` accepts a `min_age_days` config knob (default `DEFAULT_NEWBORN_GUARD_DAYS = 30`) so an admin can do a first-time sweep on a fresh tenant by triggering `POST /scheduled-jobs/{id}/trigger` with `config_override={"min_age_days": 0}`. Pre-fix every prod tick on the 2026-05 cutover returned `scopes_prompted=0` because every cmdb_cloud_scope was bulk-imported within the last 30 days and the hardcoded newborn guard hid all 169 dev/active scopes. Default scheduled ticks are unchanged (config dict doesn't set the key → 30 stays). The handler validates `min_age_days >= 0` and raises on negative / non-int (defence-in-depth: a typo in `config_override` pages the SRE instead of silently skipping every scope).

**Escalation atomicity (pre-launch hardening 2026-05-16)**: `escalate_overdue` dispatches the escalation Feishu card to all active `it_admin` recipients FIRST and only flips `pending.status='escalated'` + writes the sibling `escalated_to_it` timeline row + commits the success audit row once **≥1 admin actually received the card**. If every dispatch raises (Feishu outage / every admin's open_id stale / no `it_admin` user seeded in this tenant), the pending row stays PENDING so the next sweep retries, and a `cloud.scope.owner_confirmation_escalated` audit row lands with `status='failed'` + `reason='all_admin_dispatches_failed'` + the list of failing admin IDs. The success-path audit detail records `admins_notified` as the actually-dispatched list (was the candidate list pre-fix — masked stale-open_id admins as "notified"); `admins_failed` is recorded separately for forensics. Counterpart regression test: `test_escalate_overdue_leaves_pending_when_no_admin_recipients`.

**IT escalation retired in favour of daily owner reminders (2026-08-24, migration 531)**: an unanswered prompt is now re-sent to its OWNER once a day (`OwnerConfirmationService.remind_unanswered_owners`, `REMINDER_MIN_INTERVAL_HOURS = 20` so the daily cron sends exactly one and a manual re-trigger is a no-op) until they press a button on it. `reminder_count` / `last_reminded_at` on the row are the bookkeeping and are surfaced through the API, MCP and the timeline UI; `notified_at` keeps meaning "first asked" so "open since" stays answerable. The card recomputes its spend/member figures on every send — the row's stored snapshot is written once and NOT refreshed, because the timeline's contract is "what the owner saw when first asked". Three cases are never pushed: scope gone/decommissioned (prompt closed `voided_scope_closed`), owner changed while open (closed `voided_owner_changed`), owner no longer active (**skipped, left open** — offboarding's force-transfer re-points the scope and the previous case then closes it; nothing is ever pushed to a leaver). Migration 531 re-opened every `status='escalated'` row to `pending`: that status was a dead end, since the respond endpoints accept only PENDING, so an escalated prompt could not be answered by anyone. `escalated_at`, the `escalated_to_it` kind and the `cloud.owner_confirmation_escalated` template are retained read-only for the rows already written; `build_escalation_card_fields` was deleted with its last caller.

**Why the escalation policy was replaced (and the bug that exposed it)**:  the recipient query filtered on the BARE legacy alias `AuthRole.name == "it_admin"`. Production `auth_roles` has no such row — the canonical name is `global_admin` and `it_admin` survives only as an alias that [`core/rbac.py`](../../server/app/core/rbac.py) canonicalises at request time; it is **not a stored role**. The join therefore matched zero people on every tick, and because the atomicity rule above (correctly) refuses to flip a pending row without ≥1 successful dispatch, each overdue prompt logged `admin_candidate_count: 0` → `all_admin_dispatches_failed` and re-ran identically the next night: **8814 failed audit rows across 132 scopes between 2026-06-03 and the fix, with not one escalation card ever delivered**. Now queries `name IN ('global_admin','it_admin')` + `.distinct()` (every other admin-roster query in the codebase already did). ⚠️ The pre-existing regression tests seeded an `it_admin` AuthRole themselves, so they passed against the broken predicate — they encoded the bug instead of catching it. `tests/test_cmdb/test_owner_governance_timeline_2026_08_24.py::test_escalation_resolves_global_admin_recipients` deliberately grants only `global_admin`.

**Stale-addressee rule (2026-08-24)**: a `_pending` row names the person the card was sent to. Under a daily cadence this stops being cosmetic — a wrong name means pushing to the previous owner every morning. If the scope changes hands while that card is outstanding, the row is both unanswerable and **blocking** — `list_scopes_due_semiannual` skips any scope with an open pending row, so the NEW owner is never prompted (clock frozen indefinitely), while the sweep keeps paging admins about a card sent to the previous owner (often a leaver). `OwnerConfirmationService.void_pending_for_owner_change()` closes such rows with `response_kind='voided_owner_changed'` + `transferred_to_person_id` = the successor, and is called from two places: the offboarding force-transfer branch (same transaction as the ownership rewrite) and `escalate_overdue` itself, which voids instead of escalating whenever `pending.owner_person_id != scope.owner_person_id`. The sweep-side check is what self-heals the 7 prod rows still addressed to leavers on 2026-08-24 (oldest 2026-05-19) — **no data-fix script needed**.

**Reminder batch cap (2026-08-24)**: `remind_unanswered_owners(max_per_run=...)` bounds one sweep's fan-out, oldest `notified_at` first. Unset by default — the steady state is one card per open prompt per day, bounded by the subscription count. It exists for the FIRST pass over the ~96-prompt backlog the dead escalation sweep left behind, where an operator may want to ramp rather than reach ~90 owners on day one: `config_override={"reminder_max_per_run": 20}`. The summary always carries `deferred_over_cap`, `voided_owner_changed`, `voided_scope_closed` and `skipped_owner_inactive` so a truncated or all-skipped run can never read as a drained queue.

**Round-22 hardening (2026-05-17, pre-500-employee-launch)**:
- **`LIVE_GRANT_STATUSES` collapsed to `("active",)`** — previous tuple `("active", "pending", "approved")` included a dead "approved" literal (the canonical grant statuses are `pending → active → revoked/expired/disabled`) AND inflated owner-card "可访问人数" with PENDING (request-not-yet-bound) grants. The owner card now counts only cloud-side-bound members.
- **`escalate_overdue` skips `SCOPE_STATUS_CLOSED` scopes** — decommission deliberately does NOT set `deleted_at` (billing FK retention), so the prior `deleted_at IS NULL` guard let escalation page IT admins about already-decommissioned scopes. Now also stamps the pending row ESCALATED + writes audit `reason: scope_decommissioned` and per-row commits so subsequent failures don't roll back the short-circuit.
- **Per-row commit after successful dispatch** — pre-fix the outer session committed only at sweep end; iteration N+1 hitting a Feishu blip rolled back iterations 1..N's `pending.status=escalated` + `esc_row` writes, the next sweep re-fired every card, and 5 IT admins repeatedly received 30 duplicate escalations. Now every successful dispatch commits before incrementing the counter; a commit failure rolls back JUST that iteration's writes.
- **Admin recipient filter widened to `work_email IS NOT NULL/''`** — matches `failure_notifier._list_admin_recipients`; otherwise a ghost-admin row (role=it_admin, work_email=NULL) burned 5 outbox retries per failure.
- **Card label rename "活跃成员" → "最近添加的成员"** — `_top_member_names` orders by `CmdbCloudAccessGrant.created_at DESC` (recency of grant, NOT actual usage); the prior label misled owners into thinking the listed people were actively using the subscription.

Service layer: [`OwnerConfirmationService`](../../server/app/domains/cmdb/owner_confirmation_service.py). Card formatting: [`owner_confirmation_cards.py`](../../server/app/domains/cmdb/owner_confirmation_cards.py) — four bilingual builders (semiannual / zero_spend / new_owner_welcome / escalation_to_it) that populate the rich-card fields described in §6.3.

HTTP surface:
- `GET /cmdb/scopes/{scope_id}/owner-confirmations` — merged timeline list (stored rows + audit-derived owner-change entries, `derived` flag per row), RBAC: scope owner OR cloud admin OR finance reader.
- `POST /cmdb/scopes/{scope_id}/owner-confirmations/{conf_id}/semiannual-confirm` — flip pending → responded + bump `last_owner_confirmed_at`.
- `POST /cmdb/scopes/{scope_id}/owner-confirmations/{conf_id}/zero-spend-respond` — body `{action: keep|cleanup|handover, reason?: str}`; `keep` requires non-empty `reason` (400 `OWNER_CONFIRMATION_REASON_REQUIRED` otherwise).

MCP twins (MCP-sync rule): `list_cloud_scope_owner_confirmations`, `respond_cloud_scope_semiannual_confirm`, `respond_cloud_scope_zero_spend`.

---

## 7. Workflow journal

Cross-domain ledger of multi-step operations. [`server/app/core/workflow_journal/models.py`](../../server/app/core/workflow_journal/models.py):

- `workflow_runs` — one row per orchestration: `workflow_type`, `person_id`, `related_resource_type/id`, `status` ∈ {running, succeeded, failed, blocked}, `current_step`, `step_count`, `step_log` (JSON summary), `context` (JSON), `trace_id`, `trigger_source`, `triggered_by_user_id`, timestamps, `error_class`, `error_message`.
- `workflow_step_events` — append-only: `sequence`, `step_name`, `status`, `started_at`, `completed_at`, `duration_ms`, `attempt`, `actor_user_id`, `actor_kind`, `payload` (JSON), error fields. **`UNIQUE(workflow_run_id, sequence)`** (`uq_workflow_step_events_run_seq`, migration **354**; replaced the old non-unique `idx_wf_step_events_run_seq` — the unique index leads with `workflow_run_id` so it still backs the `fk_wf_step_events_run` FK + per-run lookups). The sequence is `MAX(sequence)+1` per run; because `start_run` is idempotent-by-resource it can hand the same in-flight run to two concurrent coordinator/scheduler paths, so `record_step` allocates inside a SAVEPOINT and **retries on the 1062**, re-reading MAX with a locking (`FOR UPDATE`) read so the retry sees the winner's committed row under REPEATABLE READ. Without the constraint two writers could silently land two events at the same timeline position.

Service ([`service.py`](../../server/app/core/workflow_journal/service.py)) is the **only writer**:
- `start_run(workflow_type, person_id, ...)`
- `record_step(run_id, step_name, status, ...)` — appends + updates summary
- `finish_run(run_id, status, ...)`

**Readers**: `LifecyclePersonFlowPage` (admin UI), audit drilldowns, anywhere needing "what happened in this offboarding".

Constants in [`constants.py`](../../server/app/core/workflow_journal/constants.py): `RUN_STATUS_*`, `ACTOR_SYSTEM`/`ACTOR_USER`, `TRIGGER_API`/`TRIGGER_SCHEDULER`/`TRIGGER_SYSTEM`.

---

## 8. Audit & compliance

### `audit_logs` ([`server/app/audit/models.py`](../../server/app/audit/models.py))

Append-only by **DB trigger** ([`014_audit_log_insert_only_trigger.py`](../../server/alembic/versions/014_audit_log_insert_only_trigger.py)) — UPDATE raises. Columns:

`id, trace_id, actor_user_id, actor_type, action (namespaced, e.g. integration.create), status, resource_type, resource_id, detail (JSON), ip_address, user_agent, created_at`

Routes:
- `GET /api/v1/audit/logs` — admin + auditor + management; filterable by action / actor / resource_type / resource_id / status / actor_type / date range / free-text (trace_id + resource_id + action substring). `resource_type` accepts comma-separated values → IN-list filter.
- `GET /api/v1/audit/resources/history` — per-resource lifecycle view; required params `resource_type` (comma-separated supported for IN-list filtering) + `resource_id` (exact). Access is **resource-type-aware** via [`_RESOURCE_HISTORY_PREFIX_ACCESS`](../../server/app/audit/router.py): cloud resources (`cmdb_*`, `integration`) → Cloud Admin / Auditor / Global Admin / Management; asset resources (`asset`, `asset_*`, `procurement_order`, `employee_request`) → Asset Admin / Auditor / Global Admin / Management; permission/SSO resources → Global Admin / Auditor / Management. Unknown types fall back to the audit-admin gate (anti-default-broaden). 403 does NOT echo the offending resource_type (anti-enumeration). Backed by composite index `ix_audit_logs_resource_lookup(resource_type, resource_id, created_at)` (migration 180). **Owner-history union**: for cloud network resources (`cmdb_dns_record` / `cmdb_domain` / `cmdb_cdn_domain` / `cmdb_load_balancer`) the endpoint also reads `cmdb_network_owner_history` (separate append-only owner-transfer table from migration 175) and merges those rows into the timeline as synthetic `cmdb.owner_history.<change_reason>` audit-shaped entries. Owner transfers are not written into `audit_logs`, so the union is the only way to see "every owner change to this DNS record"; `cmdb_server` and asset/permission resources have all lifecycle entries in `audit_logs` directly and skip the union.
- `GET /api/v1/audit/stats` / `/actions` / `/resource-types` / `/logs/{id}` / `/trace/{trace_id}` / `/export` — admin + auditor + management.

Per-resource history surface (`/admin/audit` is the firehose; this is the lens for ONE row):

| Surface | resource_type filter | UI affordance |
|---------|----------------------|---------------|
| `CloudDnsPage` row | `cmdb_dns_record` (auto-merged with owner_history) | History icon column |
| `CloudDomainsPage` row | `cmdb_domain` (auto-merged with owner_history) | History icon column |
| `CloudServersPage` row | `cmdb_server` | History icon column |
| `AssetDetailPage` header | `asset` | "View history" button next to action buttons |
| `AssetDetailModal` operations block | `asset` | "View history" button on the operations heading |
| `PermissionAdminPage` grants tab | `user_permission` | History icon in actions cell |

Shared component: [`ResourceHistoryDrawer`](../../web/src/components/ResourceHistoryDrawer.tsx). Props: `open`, `onClose`, `resourceType: string | string[]`, `resourceId`, `title`, `resourceLabel`. Renders 760px right drawer with 4-column table (time / action / actor / status) + expandable detail (trace_id / IP / UA / full detail JSON). Inline 403 alert when caller lacks the role-set for any requested `resource_type`. Add new detail surfaces by importing this component — the backend access table is the only place to teach a new resource type.

### Point-in-time access ledgers ([`access_cycle_listener.py`](../../server/app/core/access_cycle_listener.py), migration 484, 2026-08-16)

Two tables answer "does X hold this **now**" but destroyed the answer to "did X hold this **on date D**" — the standing SOC2 CC6.1 / CC6.3 evidence question:

* `auth_user_roles` revokes by **DELETing** the row (no `revoked_at`).
* `user_permissions` carries `UNIQUE(user_id, permission_id)`; re-request **UPDATEs the row in place**, overwriting `granted_at` and clearing `revoked_at`. (`cmdb_cloud_access_grants` hit the same wall and compensated with a `detail._resurrection_history` JSON array; the permission row had nothing.)

Two append-only **interval** ledgers now record every holding:

| Table | Interval columns | Point-in-time predicate |
|---|---|---|
| `auth_user_role_cycles` | `granted_at` / `revoked_at` (+ `role_name` snapshot, `grant_source`, actor + trace on both ends) | `granted_at <= D AND (revoked_at IS NULL OR revoked_at > D)` |
| `user_permission_cycles` | `opened_at` / `granted_at` / `closed_at` (+ `open_status`, `close_status`, approval + provisioning task, reason, actor + trace) | `granted_at IS NOT NULL AND granted_at <= D AND (closed_at IS NULL OR closed_at > D)` |

`granted_at IS NOT NULL` is load-bearing on the permission side: a cycle opens on the PENDING row (auditable) but only gains `granted_at` when the provisioning task confirms the vendor-side grant, so a rejected/cancelled request never appears in an access roster.

Written by a SQLAlchemy `before_flush`/`after_flush` listener — same shape as [`identity/audit_listener.py`](../../server/app/identity/audit_listener.py) — because the transitions are spread across `permission/service.py`, `provisioning/service.py` (the canonical PENDING→ACTIVE / →REVOKED flips), `auth/service.py`, `office_admin_sync`, `department_role_sync` and the leaver guard. **Register in both `main.py` and `worker.py`**: the worker owns the async grant/revoke completion.

Four design calls worth not re-deriving:

* **Core DML in `after_flush`, not `session.add()`.** A resurrection closes and opens inside ONE flush; a deferred INSERT would land after the close statement, leaving the new interval open forever.
* **No FK columns** (same reasoning as `audit_logs`): evidence must outlive soft-deleted catalog rows and HRIS-purged people, and FK child inserts take shared locks on parent rows in the grant/revoke hot path.
* **No UNIQUE constraints.** An evidence ledger must never be able to fail the transaction it observes. "At most one open cycle per key" is held by close-before-open ordering plus tests, and is a drift-finding candidate rather than a 1062.
* **`DATETIME(6)`** on interval columns ([`PreciseDateTime`](../../server/app/core/sql_types.py)): at second granularity two cycles opened in the same second are unorderable, so the same evidence query returns rows in a different order per run and the bundle stops being reproducible.

Core-level `session.execute(delete(AuthUserRole)...)` bypasses the listener entirely. All five pre-existing sites were converted to ORM deletes (the leaver guard's `asset_global_admin` strip was the dangerous one — stripping the role without closing the interval reads as "still a global asset admin" forever), and `tests/test_permission/test_access_cycle_ledger_guard.py` AST-scans `app/` to block the sixth. No historical backfill: the ledgers start at deploy time; earlier history stays in `audit_logs`.

### Evidence queries & frozen bundles ([`auditor/evidence_query/`](../../server/app/domains/auditor/evidence_query/), migration 485, 2026-08-17)

The deterministic half of the audit/compliance copilot. **No free-form query path and no model-generated SQL exists anywhere in this feature** — a question either lives in [`catalog.py`](../../server/app/domains/auditor/evidence_query/catalog.py) with a named handler, a declared parameter list and a version, or it cannot be asked. `validate_catalog()` runs at import (same posture as `intelligence.catalog`), so a malformed registration fails process start rather than an auditor's click.

A run is **frozen**, not live:

| Table | Holds |
|---|---|
| `auditor_evidence_bundles` | `query_key` + `query_version` + canonicalised `params` + `content_hash` over the rows + declared `data_gaps` + actor/`generated_via`/trace |
| `auditor_evidence_bundle_rows` | one row per fact, with `source_table`+`source_pk` (or `audit_log_id`+`source_trace_id`), `subject_*`, `occurred_at`, `payload` |

Re-running the same question produces a **new** bundle. A differing hash means the underlying data moved between runs — an audit signal, not a tool defect — which is why neither table has an UPDATE path. Together the params + version + hash are what makes the output usable as SOC2 **IPE** (Information Produced by Entity): completeness can be established rather than taken on faith from a screenshot.

Invariants enforced in [`service.py`](../../server/app/domains/auditor/evidence_query/service.py), each for a specific failure mode:

* **Every row cites a record.** A row with neither source pk nor audit-log id is refused *before* persistence — an unciteable line is indistinguishable from a checkable one to the reader.
* **Unknown params are refused, not ignored.** A silently dropped typo yields a *wider* roster than asked for, with nothing in the output revealing it.
* **Row cap is a hard error, never a truncation.** A truncated roster reads exactly like a complete one.
* **Omitted upper bounds are materialised** into the stored params (`EvidenceParam.fill_with_run_time`). Leaving "as of now" implicit would make identical params hash differently on every run and destroy the signal the hash carries.
* **The bound rounds UP to the whole second.** MySQL/TiDB *round* sub-second values into second-granular `DATETIME` columns (measured: `12:00:01.618` → `12:00:02`), so an exact-instant bound excludes a grant made moments earlier — "the row I just created is missing" is the fastest way to lose an auditor's trust.
* **Running a query is itself audited** (`auditor.evidence_query.run`, with params and hash). An access-forensics tool that leaves no trace of who searched for whom is a governance hole in its own right.

Handler conventions ([`handlers/access_roster.py`](../../server/app/domains/auditor/evidence_query/handlers/access_roster.py)): period queries match on **interval overlap, not containment** (`granted_at BETWEEN` silently drops everyone who held access for the whole window — the population auditors most want); person labels deliberately do **not** filter `deleted_at` (a May roster must still name the person who left in June); cross-type ids are matched in Python, never in SQL (`Uuid` ↔ `String(36)` joins return zero rows silently on asyncmy — same trap `access_certification_service` documents).

**Registered queries** (8 as of PR3). Adding one means a definition in the catalogue plus a handler module — there is no other path.

| Key | Answers | Source of truth |
|---|---|---|
| `software_seat_roster` | who held a seat of software S during a period | `software_seat_allocations` (complete lifecycle columns) |
| `sso_grant_roster` | who held an SSO app grant, incl. the handover chain | `sso_grants` |
| `privileged_role_roster` | who held a Helix platform role on date D | `auth_user_role_cycles` (migration 484) |
| `cloud_grant_roster` | who held a cloud role on a scope during a period | `cmdb_cloud_access_grants` + `detail._resurrection_history` |
| `software_permission_roster` | who held a software permission during a period | `user_permission_cycles` (migration 484) |
| `api_key_roster` | who held an API-key assignment during a period | `api_key_assignments` |
| `data_access_roster` | which principals held a BigQuery entitlement | `data_access_bindings` (snapshot columns) |
| `approval_chain` | who approved a grant, and when | `approval_steps` **and** `cloud.grant.approve/reject` audit rows |
| `person_footprint` | everything one person held + did | the rosters above (via optional `person_id`) + `audit_logs` as actor |
| `revocation_proof` | was a leaver's access removed, by whom, how fast | seats / cloud grants / permission + role cycles |
| `object_lifecycle` | who changed object O, when | `audit_logs` ∪ `cmdb_network_owner_history` |
| `access_review_proof` | was the review completed, what was revoked | `access_certification_items` |

Two of these need more than a read of the live row, both because the row is **reused across holdings**:

* **Cloud grants** resurrect under `UNIQUE(scope_id, person_id, role_key)`; prior cycles survive only in `detail._resurrection_history`. The handler emits **one row per holding**, and deliberately does not push the window filter into SQL — a grant whose *live* cycle falls outside the window may carry an in-window historical cycle, and filtering in SQL drops exactly the holdings the handler exists to surface. Reconstructed cycles raise a declared data gap because their start times are inferred from adjacent history entries rather than read from a column.
* **Software permissions** had the same problem with no compensation at all before migration 484, so the roster reads the interval ledger. A cycle that never reached ACTIVE is a request, not access — counting it would overstate who had access, which is the direction of error an audit is looking for.

**`revocation_proof` applies no cutoff, and that is the design.** The first version excluded holdings revoked *before* the termination date as "not an offboarding result" — which hid exactly the well-run cases, because planned offboarding revokes a few days EARLY and a tidy departure therefore produced an empty proof. Any threshold here decides for the auditor what counts, invisibly. Every holding is reported with `open_at_termination` and `still_held` flags plus elapsed days (negative = revoked before the date, the platform's existing leaver convention), and the reader draws the line. Still-open holdings are included for the mirror-image reason: a query returning only successful revocations answers "all clear" by construction.

**`object_lifecycle` unions `cmdb_network_owner_history` into `audit_logs`.** Owner changes for DNS / domain / CDN / load-balancer objects are written ONLY to that table, so an audit-only query reports "no changes" for a domain that changed hands three times — reads as a clean object, is actually a blind spot.

**`person_footprint` composes the rosters rather than `ForensicTimelineService`.** That service returns rendered timeline dicts with no row ids, so its entries cannot satisfy the every-row-cites-a-record rule. Composing the rosters (each narrowed via the optional `person_id` param, in SQL) additionally guarantees the footprint and the per-system rosters can never disagree about the same holding.

**`approval_chain` returns two authority paths, tagged rather than flattened.** Most grants carry `approval_request_id` → `approval_steps` (`approver_id`, `acted_at`). **Cloud grants carry none**: they are decided in Helix's own inbox (`/cmdb/my-pending-grant-approvals`) and never create an `approval_requests` row, so their authority record is the `cloud.grant.approve` / `.reject` audit row plus `granted_by_person_id`. That is equivalent evidence — actor, timestamp, trace id — not a degraded fallback, so those evidence rows cite the **audit row itself** rather than the grant row, which carries no decision. Only acted-upon steps and `status='success'` audit rows count.

**Surfaces.** Five HTTP endpoints on the auditor router (`GET /evidence-queries`, `POST /evidence-queries/{key}/run`, `GET /evidence-bundles`, `GET /evidence-bundles/{id}`, `GET /evidence-bundles/{id}/rows`) sharing its `can_view_security` / `can_manage_security` gates, plus MCP twins `auditor_list_evidence_queries` (read) and `auditor_run_evidence_query` (`mutates=True` — it freezes a bundle and writes an audit row). `run` is a POST for the same reason: modelling it as a GET would make an evidence run look cacheable and repeatable, when the design rests on each run being a distinct dated artefact.

Money in an evidence row goes through `redact_invoice_basis_amounts`, pointed at the **embedded `detail` sub-object using the row's own `action`** — not at the whole payload with a synthetic action. The classification table is keyed by audit action and fails closed on unknown ones, so the synthetic-action shape would blank out money-shaped fields on rows unrelated to cloud cost (asset residual values, leave balances, budgets) and that over-redaction is silent. Tests lock both directions.

The `access_evidence` evidence-pack module renders **stored** bundles rather than re-running the queries: a pack that re-derived its numbers at build time would disagree with the bundle an auditor already reviewed, and the hash printed beside it would verify nothing. CSV goes through `_csv_row` (RFC-4180) because a comma in a display name silently shifts every later column — a corrupted exhibit, not a cosmetic bug.

**Intelligence generation policies** ([`generation.py`](../../server/app/intelligence/generation.py), 2026-08-17). Cache, cooldown, system prompt and output contract are selected by an explicit per-subject `GenerationPolicy`; platform/domain retain their one-day, one-minute default while cloud scopes retain their configured seven-day cache and 30-minute cooldown through the existing canonical setting keys. Subject-specific output models must subclass `IntelligenceOutput`, so the same grounding, anonymous-reference and low-confidence guards remain mandatory. The catch-all failure path records returned Token counts before marking an analysis failed.

**Intelligence Center backend governance** (2026-08-18; 0 migration / 0 new tool / 0 new HTTP surface).

* **Audit.** All four HTTP mutations on [`intelligence/router.py`](../../server/app/intelligence/router.py) write an `AuditService(db).log(...)` row on the caller's session before their commit, using the platform's `getattr(request.state, "trace_id", …)` idiom: `intelligence.analysis.generate` (records `force`, `cache_reused`, `model_calls`, token counts — i.e. who paid), `intelligence.recommendation.update` (from/to status, savings), `intelligence.recommendation.send` and `intelligence.recommendation.execute` (fan-out size, action kind, handoff destination). The MCP twins keep their own `mcp.<tool>` rows from [`mcp/runtime.py`](../../server/app/mcp/runtime.py). The dismissal free text is deliberately NOT copied into `audit_logs.detail`; it already lives on the recommendation the row points at.
* **Recommendation lifecycle.** `transition_recommendation` enforces an explicit `_ALLOWED_TRANSITIONS` map matching the only flow the desktop panel and H5 sheet offer (`new → viewed`, `new|viewed → in_progress`, `in_progress → resolved`, anything → `ignored`, `resolved → in_progress` reopen, `ignored →` back into the funnel). An illegal move raises `INTELLIGENCE_RECOMMENDATION_TRANSITION` (422). Re-entering the same status stays legal (rule 5); `resolved_at` is written once so a savings correction cannot move the booked date, and `ignored_reason` is cleared when a row leaves `ignored`.
* **Cloud-scope token-spend authority.** `CloudScopeAnalysisAccess.can_spend_model_tokens` is resolved separately from the read gate: `can_manage_cloud` (global/cloud admin) OR an actual relationship with the subscription (owner, Azure-parent owner, active unexpired grant). Member-triggered shared analysis stays a product feature; the roles admitted by `can_read_cloud` alone — `auditor`, `finance`, `management` — can read every scope but cannot bill a model call on one they have no relationship with (`CLOUD_SCOPE_ANALYSIS_SPEND_FORBIDDEN`, 403). `capabilities.can_generate` now reports that authority rather than only "the scope is open".
* **Which generation gate is shut is now on the wire.** `capabilities.generate_blocked_reason` (`spend_forbidden` / `scope_closed` / `null`) mirrors, in order, the two refusals `generate_cloud_scope_analysis` raises — spend authority first, then `SCOPE_STATUS_CLOSED`. `can_generate` is derived from it. Both shells render the matching sentence next to the disabled control and gate the force button on `can_generate` too: `can_force_generate` is pure role authority, so on a closed subscription with a prior analysis it used to offer a live button whose only answer was 409 `CLOUD_SCOPE_CLOSED`.
* **`generated_by` is privileged metadata.** The generator's display name is a real colleague's directory data attached to an operational event, so it sits inside the same `if privileged:` block as `model_used` and the token counts rather than in the base `_generation_meta` dict every grantee reads. Both cards already guarded the field, so a non-privileged reader simply loses the line.
* **Platform brief and cloud twin agree on model-spend metadata.** `read_latest_analysis` used to hand `model_used` + both token counts to every admitted role (HR, finance, auditor, cloud_admin, both asset-admin tiers) while the cloud-subscription projection withheld exactly those three. The stricter posture won: they are now gated on the plan's operator flag (`can_manage_recommendations`, i.e. `global_admin`). Brief CONTENT is unchanged for every role.
* **Cooldown counts failures.** `_enforce_cooldown` counts `pending|succeeded|failed` attempts: a failed attempt already spent the tokens the provider billed. The 429 names the previous `failure_code` so the throttle does not swallow the diagnosis.
* **`cache_reused` is persisted.** A cache hit marks the served `IntelligenceAnalysis` row (monotonic, never reset), so `read_latest_analysis` and the subscription projection stop reporting `false` for a narrative that has not been regenerated in days.
* **`failure_message` is still not projected, to anyone.** It is the one stored Intelligence field that can quote the model or the provider verbatim, and #2799 encoded that intent in its own fixture. Re-running it through `assert_llm_safe` was evaluated and rejected: that guard is written for the outbound direction and has known coverage holes (no IPv6, a fixed TLD list, bare hostnames), so it cannot be the only wall between a vendor sentence and a reader. The operator need — separating an invented number from a schema drift — should be met with a server-derived enumerated reason, not by forwarding free text. `failure_code` remains visible to everyone.
* **MCP hints.** [`mcp/annotations.py`](../../server/app/mcp/annotations.py) grows `NON_IDEMPOTENT_TOOLS` = {`intelligence_generate_platform_analysis`, `cloud_scope_generate_ai_analysis`}. With `force=True` those bypass the cache, debit the daily quota and issue a fresh billed call, so `idempotentHint=False`; the CLAUDE.md rule-5 invariant still stamps `True` on every other mutating tool.

### Audit post-log hooks ([`AuditService.log`](../../server/app/audit/service.py), 2026-07-08)

Generic in-process extension point: `register_audit_log_hook(fn)` appends an async `(entry, session)` callable that runs once per successful `AuditService.log` write, AFTER the audit row is flushed to the caller's session (so it rides the same transaction and may await DB work / dispatch notifications — unlike the SQLAlchemy `after_flush` listeners in [`identity/audit_listener.py`](../../server/app/identity/audit_listener.py), which can only queue more ORM rows). Hooks are best-effort: a raising hook is logged and swallowed — an observer never breaks the audit write or the audited operation. Register at startup in **both** `main.py` and `worker.py` so the hook fires regardless of which process logged.

Consumers:
- **Asset oversight notifier** ([`asset/oversight_notifier.py`](../../server/app/domains/asset/oversight_notifier.py)) — pushes to the global IT asset admin (孟凡生) when a *regional* `asset_admin` operates on an asset. Single seam for all ~27 asset-item custody/lifecycle actions (allowlist `_WATCHED_ACTIONS`; reference-data CRUD / exports / employee self-service / auto reserve-for-hire excluded). Fires only for `status="success"` by an actor holding `asset_admin` without global asset scope (`has_global_asset_scope` → skip; system/cron actors have no `actor_user_id` → skip). Recipients = `asset.oversight_recipient_person_ids` platform setting (JSON id list) if set, else active `asset_global_admin` holders; actor always excluded; kill switch `asset.oversight_notify_enabled` (default on). Localized per recipient; lands in the actionable `CAT_ASSETS` bell bucket via `template_key="asset.oversight.operation"`.

### Compliance ([`server/app/compliance/`](../../server/app/compliance))

`compliance_incidents` — `incident_type`, `person_id`, `severity`, `status`, `description`, `remediation_steps`, `evidence_url` (JSON of audit / workflow links), `resolved_by_user_id`, `resolved_at`. Populated by auditor scheduled jobs and by domain code on detected violations (e.g. `cloud_scope_unowned`, `discovered_grant_drift_removed`). Admin UI: `/admin/compliance-incidents`.

### Access review assignment (PR-5 of symmetric-onboarding, migration 235, 2026-06-01)

[`AuditorPrivilegedRoleHolder`](../../server/app/domains/auditor/models.py) gained 3 columns:

* `assigned_to_person_id` (FK identity_persons, SET NULL) — designated reviewer per row
* `decided_at_deadline` — per-row deadline override (cycle.due_at is the baseline)
* `escalated_at` — set by the daily sweep when row is > 14d past deadline + still pending

Companion code:

* [`PrivilegedReviewService.assign_reviewer`](../../server/app/domains/auditor/services.py) — service helper. Refuses inactive reviewer; refuses already-decided holder. Writes `auditor.privileged_review.assigned` audit row.
* [`AccessReviewEscalationService.escalate_overdue`](../../server/app/domains/auditor/services.py) — daily sweep (cron `auditor.privileged_review_escalate`, 03:30 SH). Pending + 14d past deadline + escalated_at IS NULL → set escalated_at + open `compliance_incident` (type `access_review_overdue`, severity warning). Idempotent — re-running is a no-op for already-escalated rows.
* New HTTP endpoint `PUT /api/v1/auditor/privileged-review/holders/{id}/assignment`.
* 2 new MCP tools (`_REGISTERED_TOOLS_COUNT_HINT` 221 → 223): `assign_privileged_review_holder` (admin-write) + `list_my_pending_privileged_reviews` (self-scoped — reviewer sees their own backlog).
* New `compliance_incidents.incident_type` value `access_review_overdue`.

Mirrors the [`CmdbCloudScopeOwnerConfirmation`](../../server/app/domains/cmdb/models.py) 14-day escalation pattern; that one runs semiannually for cloud scope owners, this one runs daily on the pending-decision backlog.

---

## 9. Approval (Feishu chat-approval)

### Unified admin approval read model (2026-08-20)

[`approval/admin_overview.py`](../../server/app/approval/admin_overview.py) is the single read-only source registry for the pending board, all-status history, export, analytics, source catalogue and MCP list/detail surfaces. Domain tables remain the records of truth and decisions still execute in their owning services; the registry normalises discovery only. It covers `employee_request`, `cloud_scope`, `cloud_grant` (Helix-originated only), grouped `cloud_renewal`, `email_group_join`, `dns`, `domain`, `vpn`, `ai_tool`, `announcement`, `apikey_release`, `identity_consumer`, `sso`, `data_access`, external-origin `soc2_email_group`, and `leaver_override`. Support tickets, discovered/imported cloud grants and software permission rows without a real approval path are intentionally excluded.

`GET /approval/admin/sources` owns frontend filter capabilities; `GET /approval/admin/all-requests/detail?id={source}:{id}` and MCP `admin_request_detail` reuse the source-agnostic request-detail adapters plus best-effort live Feishu timeline. SSO detail includes post-approval app/config delivery state while redacting SAML XML and all SP/IdP certificate bodies. The all-status and analytics readers have no per-source truncation cap, so pagination/export/statistics never silently omit older rows. Multi-role cloud renewal submissions collapse by `request_group_id`.

`ChatApprovalDriver` is a generic interface; today only Feishu (`feishu_chat_approval`) implements it. Tables ([`server/app/approval/models.py`](../../server/app/approval/models.py)):

- `approval_requests` — `integration_id`, `flow_key`, `external_instance_code` (Feishu instance_code), `requestor_user_id`, `approver_person_ids` (JSON), `status`, `approved_at`, `approved_by_user_id`, `context_data`.

Service flow:
1. Domain calls `ApprovalService.submit_approval(flow_key, requestor, fields, ...)`.
2. Service resolves flow definition (from `integrations.approval_flows` or seeded definitions), calls driver's `submit_approval(ApprovalSubmission)`.
3. Returns `instance_code`, persists row.
4. Webhook `POST /webhooks/feishu/approval` fires when approver acts; driver `verify_webhook(body, headers)` validates signature; service updates row status.

Used by: API key requests, software seat requests, scope requests, leaver-blocked overrides, asset requests.

**Buyback amount enforcement** (`offboarding_buyback` + `laptop_replacement`, added 2026-05-12, hardened in the same PR after 2 rounds of security review; extended to `laptop_replacement` 2026-06-24). The Amount widget value the Feishu approval card shows is **always** the server-computed quote — never trust the client. For `laptop_replacement` this is the **buyback price of the old computer**, applied only when the applicant chose `是否回购旧电脑 = 是` (`_laptop_replacement_buyback_chosen`); `create_asset_approval_request` drops the amount widget from `widgets` when buyback was NOT chosen, so the statically-required-but-un-gated amount control can't 400 the common no-buyback path and no figure leaks onto a keep-my-old-laptop request — independent of whether the Feishu template carries a `display_condition` on it. `_enforce_server_buyback_amount` runs in `create_asset_approval_request` AFTER `_augment_answers_with_asset` and BEFORE `_serialize_feishu_form`; it strips every Amount alias a client could plant (id, exact name, lower(), upper()) and writes the canonical entry exactly once keyed by widget id. `_serialize_feishu_form` is layered defence: name-keyed entries are processed in Pass 1 (only if id slot empty), id-keyed in Pass 2 (always wins), with a trailing `widget_ids` filter dropping anything that isn't a known widget id. Both layers are required — removing either reopens the round-2 sibling-bypass attack where `answers={"amount": SERVER, "Amount": ATTACKER}` would clobber the canonical value via name→id resolution in the old serialiser. Server also rejects `offboarding_buyback` submissions without `asset_id` (`OFFBOARDING_BUYBACK_REQUIRES_ASSET`) so an attacker can't skip enforcement by omitting the asset link. Regression: [`test_buyback_submit_tamper_guard.py`](../../server/tests/test_asset/test_buyback_submit_tamper_guard.py).

**Buyback amount quote** (`offboarding_buyback` only, added 2026-05-12). The 离职回购 form's Amount widget is NOT an open input — IT policy fixes the price. `GET /api/v1/approval/asset/buyback-quote?asset_id=<uuid>&currency=<CNY|USD|JPY|SGD>` computes the figure server-side via [`app.approval.buyback_price`](../../server/app/approval/buyback_price.py):

| Asset age | Formula | Currency support |
|---|---|---|
| **< 36 months in service** | `purchase_cost / 36 × (36 - months_used)` (linear depreciation in the asset's purchase currency, converted to the display currency via the policy FX pivot through CNY) | All 4 supported currencies |
| **≥ 36 months in service** | Tier floor table keyed by CNY-equivalent purchase price: < 8 000 → 400 CNY / 8 000 JPY / 75 SGD / 55 USD; < 20 000 → 500 / 10 400 / 95 / 73; < 30 000 → 1 000 / 20 000 / 185 / 135; ≥ 30 000 → 5 000 / 100 000 / 950 / 700 (CNY 5 000 is the policy spec; non-CNY tier-4 derived from CNY via policy rates) | All 4 supported currencies |

Default currency follows the asset's `purchase_entity` → `asset_legal_entities.currency` (Singapore-bought hardware quotes SGD by default), falling back to CNY. The response carries `prices: {CNY, USD, JPY, SGD}` so the frontend currency switcher rerenders without a round-trip; the `?currency=…` parameter exists so the policy figure stays authoritative on switch. **CAD was dropped 2026-08-04** (the fifth currency until then): no Canada legal entity exists and asset intake never accepts CAD (`asset/schemas.py::ALLOWED_CURRENCIES` has no CAD), so no real asset could ever feed a CAD quote — it survived only as a manual MCP override that the Feishu `offboarding_buyback` widget (currencyRange `["CNY","USD","JPY","SGD","MYR"]`) can't even render. MYR is the mirror-image known gap: PINGCAP SDN. BHD. assets are real and the widget offers MYR, but IT policy hasn't handed down MYR floor values, so a MYR asset raises `UNSUPPORTED_PURCHASE_CURRENCY` rather than being quoted against a fabricated floor — add MYR here (4-tier floor + policy FX) once IT provides the numbers. Policy FX (1 CNY ≈ 20 JPY ≈ 0.19 SGD ≈ 0.14 USD) is *not* `app/core/fx_rate` — that module's live ECB rate would drift the quoted figure daily; buyback is a policy quote, stable on purpose. Authz: applicant must own / borrow the asset OR be asset_admin (employees can't enumerate other people's purchase prices). MCP tool `get_buyback_quote` mirrors the endpoint 1:1 (MCP-sync rule). Errors are 422 with stable codes `MISSING_PURCHASE_COST` / `MISSING_PURCHASE_DATE` so the frontend can surface "ask IT to backfill" in the form-item help slot. Frontend: [`EmployeeRequestPage.tsx`](../../web/src/pages/asset/EmployeeRequestPage.tsx) renders the Amount widget read-only (employees can't haggle), with the currency Select editable. Regression: [`test_buyback_price.py`](../../server/tests/test_asset/test_buyback_price.py) (29 unit) + [`test_buyback_quote_api.py`](../../server/tests/test_asset/test_buyback_quote_api.py) (6 integration).

**Current approver columns** (2026-08-26). The unified `/me/requests` list plus the administrator 「待办总览」and 「申请列表」now carry `current_approvers` for pending `employee_request` rows (computer and software permission requests). [`_provider_employee`](../../server/app/domains/me_universe/my_requests.py) and the admin overview registry derive names from the callback-cached `detail.feishu.latest_instance_detail` through `ApprovalService._build_progress_from_instance(..., include_raw_identifiers=False)`: no per-row Feishu network fan-out, and unresolved Feishu IDs are intentionally omitted rather than exposed as opaque identifiers. The three list views render 「当前审核人」; pending Feishu rows without a named task explicitly show that assignment is pending, while sources without a reliable cached task assignee remain blank. The detail modal continues to live-refresh the full flow on demand.

**Admin on-behalf buyback — 代提回购** (2026-06-30). A leaver can't self-submit the offboarding buyback once they're gone (their Feishu account is frozen, codes 230002/230003), so an asset admin starts the SAME `offboarding_buyback` flow for them. `POST /api/v1/approval/asset/requests/on-behalf` ([`AdminBuybackOnBehalfRequest`](../../server/app/approval/schemas.py) `{asset_id, reason?, notes?, currency?}`, `can_manage_assets`-gated; re-enforced in the service) → [`ApprovalService.create_offboarding_buyback_on_behalf`](../../server/app/approval/service.py). The buyback **subject** (leaver) is derived from the asset's current holder (`assigned_to`, or `borrowed_by` for a borrowed leaver device) — IT picks an *asset*, not a person, so the disposal can't be pointed at someone who doesn't hold the hardware; an asset with no holder returns `BUYBACK_ON_BEHALF_NO_HOLDER` (400). The submission reuses the exact self-service path (`create_asset_approval_request` with the new kw-only `internal_on_behalf_of_person_id` / `internal_buyback_currency`): the **admin is the Feishu applicant** (active account) while the stored `requester_id` is the **leaver**, so the approve-time auto-execute TOCTOU check (`requester == asset.assigned_to`) passes and the disposal (status → sold) attributes to the leaver. Last-working-day auto-fills from the leaver's `identity_persons.termination_date` (the `offboarding_buyback`-only branch in `create_asset_approval_request`, gated on the resignation-date date widget being empty); amount/payment-info/owner-entity/serial all flow through the same server enforcement + `_augment_answers_with_asset` backstop. `_assert_asset_caller_authorized` gains an on-behalf branch (admin + named person must be the holder). Frontend: 资产清单 [`AssetListPage.tsx`](../../web/src/pages/asset/AssetListPage.tsx) row action 「代提回购」on `held_by_leaver` rows (asset-admin only) → [`OnBehalfBuybackModal.tsx`](../../web/src/pages/asset/OnBehalfBuybackModal.tsx) (shows holder + server quote + currency switch + optional reason). MCP: `submit_buyback_on_behalf` mirrors the endpoint 1:1 (MCP-sync rule). Regression: [`test_buyback_on_behalf.py`](../../server/tests/test_asset/test_buyback_on_behalf.py) (4 — applicant=admin / stored requester=leaver / policy amount / auto-filled last working day / currency override / employee 403 / no-holder 400).

**Applicant-visible widget denylist (contract, NON-NEGOTIABLE)** — inverted 2026-05-12 from the original whitelist. Feishu approval forms commonly mark ~20 widgets `required=True` statically while gating most of them with `display_condition` ("Police Report Receipt" only when Type=Lost, the buyback payment cluster only when Yes-buyback, etc.). The applicant fills a small unconditional subset; approvers / auto-execute fill the rest. **Source of truth: `ApprovalService._approver_only_widget_names()` per template** — a small **denylist** of widgets only the approver fills (`Payment Amount`, `IT资产编码`, `Type`, `Police Report Receipt`, `快递单号`, `回购设备资产号`, `资产所属主体`, plus bilingual aliases). **2026-05-18 update**: `Payment Information` / `付款信息` was removed from the denylist for `laptop_replacement` — it now flows through to the applicant pre-filled (read-only) from the asset's `purchase_entity` → `asset_legal_entities` bank info via `_resolve_payment_entity` + `_format_payment_info` + the new `GET /api/v1/approval/asset/payment-info?asset_id=...` endpoint (asset-owner-or-borrower auth, mirrors `buyback_quote`). When the asset has no `purchase_entity` on file, `_augment_answers_with_asset` writes a placeholder string (`[资产 X 未配置采购主体...请联系 IT 补全后再走流程]`) so the required-field guard at `_serialize_feishu_form` doesn't 400 the submission — the placeholder doubles as a nudge to the approver. **2026-06-24 update**: `Payment Amount` / `付款金额` was ALSO removed from the `laptop_replacement` denylist — in the current Feishu template it is the buyback price of the old computer (是否回购 = 是), auto-filled with the policy quote + server-enforced exactly like `offboarding_buyback`; `create_asset_approval_request` drops it again whenever buyback was NOT chosen (see **Buyback amount enforcement** above). It remains denylisted for any other template. Every other widget — including static `type=="text"` description blocks (FY-year spec sheets, delivery-time advisories) and Feishu-admin-added input fields like `机型配置` — flows through to the applicant. **Both** `list_asset_templates` (read) **and** `create_asset_approval_request` (write) MUST filter through `_filter_applicant_widgets(template_key, ...)` before any per-widget logic (augment, required-validation, serialization) — otherwise hidden conditional widgets fail hard-required check on submit even though Feishu's UI hides them, and every conditional applicant submission 400s with `APPROVAL_FIELD_REQUIRED` until someone audits prod-TiDB to notice the empty `employee_requests` table for that template. Regression tests: `test_asset_feishu_approval_api.py::test_template_api_only_exposes_applicant_fields` (denylist behaviour, text-widget pass-through, `description` capture) + `test_employee_can_create_feishu_backed_asset_request` (Chinese-renamed widget names submit cleanly). Bug history: 2026-04-18 shipped the whitelist; 2026-05-11 surfaced silent submission failures from `display_condition`-gated widgets; 2026-05-12 IT admins renamed the Feishu form widgets from English to Chinese AND added a FY-year spec block + delivery-time notice + `机型配置` input — the English-only whitelist silently dropped most of the rebuilt form, and the metadata extractor at `_collect_request_type_metadata` silently returned `None` for renamed fields. Both flipped to denylist + bilingual-alias resolution in the same PR. Why denylist: the failure mode of "admin renamed a widget" is now "approver-only widget leaks to applicant" (visible, easy to fix) rather than "applicant-visible widget silently disappears" (invisible, took 3 weeks to detect).

**Form snapshot (audit trail, 2026-05-12)**. `ApprovalService.create_asset_approval_request` writes `detail.feishu.form_snapshot = [{id, name, type, required, description, answer}, ...]` for every applicant-visible widget at submission time. The admin All Approvals drawer and the employee My Requests drawer both render this via `web/src/components/FormSnapshotDisplay.tsx` as a labelled `Descriptions` list (text widgets become `Alert` blocks with `whiteSpace: pre-line`). Lets IT and the requester re-read exactly what form was filled out and what was answered — without resolving widget IDs ad-hoc against the live Feishu definition (which may have been edited since). Older rows (created before this change) have no `form_snapshot`; the drawer falls back to the raw `detail` dump via `RequestDetailExtra` so legacy data isn't hidden.

**Approval progress + employee detail endpoint (2026-05-14)**. The detail drawer used to show only a flat "who did what when" timeline — no concept of "Step N of M" or which approver is currently waited on. The admin drawer also lacked the software-seat enrichment block (软件 / 坐席 / SSO / 价格) the employee drawer already rendered, so IT couldn't tell at a glance which plan a seat request was for. Fixes shipped together:
- `ApprovalService._build_progress_from_instance(instance_detail)` collapses Feishu `task_list` by `node_id` (preserving first-seen order), aggregates approver lists per node (uid-primary dedup: collapses same-uid rows + their associated unames so a later uname-only row for the same person also collapses, but keeps same-display-name-different-uid users separate so "张伟"/USR_A + "张伟"/USR_B both surface), filters Feishu OR-mode "loser" task statuses (REVOKED / SKIPPED / RECALL / RECALLED) before the AND/OR collapse, and returns `ApprovalProgress { total_nodes, completed_nodes, current_node_index, current_node_name, current_approvers, is_finished, sync_lag, nodes[] }`. OR-mode nodes (multiple approvers on one node) collapse to one progress node with a multi-approver list and "first APPROVED wins" semantics over REJECTED. Legacy templates that omit `node_id` are bucketed one-task-per-node as a fallback. `is_finished` requires AND: every node terminal AND instance status not in `{PENDING, RUNNING}` (or explicit terminal instance status as a shortcut) — avoids the "node done but instance status hasn't propagated" false-positive. `sync_lag` is an explicit flag set when no node is PENDING but the flow isn't finished (frontend renders "等待飞书状态同步" instead of highlighting node 1 as in-progress); the flag is consumed directly — frontend MUST NOT infer it from null names because anonymous-node templates are a legitimate in-progress state. Wired into `get_admin_approval_request_detail`'s response as `progress: ApprovalProgress | None` (None when no Feishu instance has ever been created).
- `GET /api/v1/approval/my/requests/{id}` — owner-scoped twin of the admin detail endpoint. Reuses the same service method via a new `owner_user_id` kwarg; mismatched `requester_id` returns 404 (NOT 403) so the endpoint cannot be used as an existence oracle. Employees now see the same live timeline + progress UX admins see.
- Shared frontend components: `web/src/components/ApprovalProgressSteps.tsx` (Antd `Steps` with per-node title, status tag, approvers description), `web/src/components/ApprovalTimelineSection.tsx` (extracted from the admin page so both drawers use the identical timeline render), `web/src/components/softwareSeatContext.ts` (resolves software/plan/SSO/price from `detail.software_seat` with a legacy-row fallback to `detail.submitted_answers`).
- Both drawers reorder sections so the "form snapshot (审批表单内容)" lands ABOVE the timeline — that's the data the user actually needs front-and-center; the timeline is "what happened next".
- New i18n keys: `approvalProgress.{sectionTitle,summary,summaryFinished,currentNode,waitingFor,stepFallbackTitle,empty}`, `asset.request.detailFetchError`.
- Regression: `test_admin_approval_center_api.py::TestAdminApprovalCenterAPI` adds 4 progress cases (multi-node, OR-collapse, finished, none); `TestMyApprovalDetailEndpoint` adds 3 (owner-hit, stranger-404, unknown-404).

**Approval detail approver-name enrichment (2026-05-15 PR #316)**. Feishu's `/approval/v4/instances/{code}` response in the PingCAP tenant ships `task_list[]` + `comment_list[]` entries with `open_id` + `user_id` populated but `user_name` always null (verified across multiple approval_codes, both PENDING and APPROVED states). Pre-PR-#316 the admin/employee modal then fell back to rendering the raw `user_id` short code (e.g. `d8d2646c`) as the approver label, and the frontend `ApprovalFlowTimeline` dedup logic — which keys on `user_name` — double-rendered each row. **Fix**: new `ApprovalService._enrich_task_list_user_names(instance_detail)` runs in `get_admin_approval_request_detail` AFTER live/cached resolution and BEFORE `_build_progress_from_instance` / `_build_timeline_from_instance` consume the snapshot. Walks `task_list` + `comment_list` (NOT the raw `timeline` array — no downstream builder reads it), collects every distinct open_id / user_id whose `user_name` is empty, calls `FeishuApprovalClient.batch_get_user_names(open_ids=[...], user_ids=[...])` once, and writes the resolved name back into each entry's `user_name` in place. The batch helper hits `GET /contact/v3/users/{id}?user_id_type={open_id|user_id}` (Feishu has no ID→user batch endpoint), parallelised under `asyncio.Semaphore(5)` with a class-level positive cache (`_user_name_cache` keyed by `(app_id, id_type, id_value)` — no TTL, names rarely change and workers restart on every deploy). open_id is preferred over user_id (more stable + globally unique within an app); URL path segments are `quote(safe="")`-encoded as defense-in-depth. On the live-success path, enrichment happens **before** the persist back to `row.detail.feishu.latest_instance_detail`, so future cached-only reads serve enriched names without hitting Feishu again. Persistence is gated by an explicit `live_fetch_succeeded` boolean (not an identity check against the cached blob) so future refactors can mutate the cached snapshot in place without silently breaking the gate. Best-effort throughout: any lookup miss (deleted user, network blip, 404) leaves that one entry's `user_name` as Feishu returned it and the legacy uid-fallback in `_build_progress_from_instance` continues to render the raw id — the modal never fails to open because Feishu hiccuped. Tests: [`test_user_name_enrichment_2026_05_15.py`](../../server/tests/test_approval/test_user_name_enrichment_2026_05_15.py) (9 cases including comment_list walk + the negative pin that the raw `timeline` field is NOT walked).

**Approval detail Modal + per-approver flow timeline (2026-05-15)**. The legacy right-side Drawer + flat Antd `Steps` was replaced by a 960px centered Modal with four Tabs (概览 / 审批流 / 申请表单 / 原始数据), shared across every approval entry point. Triggered by user feedback ("现在一点都不像生产系统") **after** a P0547 prod incident exposed a long-standing wiring bug.
- **P0547 (root-cause fix)**: `EmployeeRequestPage` (powers `/admin/asset-mgmt/requests`, `/admin/software-mgmt/requests`, `/admin/email-mgmt/requests`, AND `/my/requests`) always called `approvalApi.getMyRequestDetail(id)` regardless of view mode. The backend `/api/v1/approval/my/requests/{id}` endpoint enforces `requester_id == current_user.id` and returns a `NotFoundError("EmployeeRequest", id)` for any stranger row — so admins clicking into a teammate's request always 404'd. The frontend `getApiErrorMessage` extracted the AppException `.message` field and rendered it inside the "Could not refresh from Feishu — showing cached snapshot" warning, masking a backend-self error as a Feishu-side hiccup. **Fix**: `fetchDetailForId = isAdminView ? getAdminRequestDetail : getMyRequestDetail`, hoisted `isAdminView` to the top of the component so the detail-fetch callbacks can reference it without TDZ. Pairs with explicit HTTP-vs-Feishu error split in the Modal (red `approvalDetail.httpErrorTitle` banner + Retry for HTTP failures, yellow `approvalAdmin.timelineStaleTitle` banner only inside 审批流 tab for `enrichment.timeline_error`). Bug class to avoid: any frontend page that surfaces a single error-state via a "Feishu refresh" alert — must distinguish HTTP fetch failure from `timeline_error` field.
- **New shared components**:
  - [`ApprovalDetailModal`](../../web/src/components/ApprovalDetailModal.tsx) — 960px centered Antd `Modal` (replaces the 620/680 Drawer), 4 Tabs, persistent Refresh button in the header, internal `useEffect` resets `activeTab='overview'` + `showRaw=false` on every re-open / different summary id (so closing on 原始数据 doesn't land the next click on the raw JSON view). Takes `summary: ApprovalDetailSummary | null` (always available from the list row) + `enrichment: AdminApprovalDetail | null` (live fetched) — merges them with enrichment-preferred precedence on shared fields. `viewMode: 'admin' | 'employee'` drives BOTH the snapshot synth filter (`templateKey: viewMode === 'employee' ? merged.request_type : undefined`) AND the raw-data `feishu` blob denylist (employee on approver-only-widget flows never sees raw `feishu` even if synth fails). 原始数据 tab is collapsed by default — "Show raw data ({{count}} item(s))" button reveals the legacy JSON dump. Wired from three pages.
  - [`ApprovalFlowTimeline`](../../web/src/components/ApprovalFlowTimeline.tsx) — vertical Antd `Timeline` driven by `progress.nodes` as the skeleton + `timeline` entries bucketed by `node_name` match. Each node renders: status-coloured dot (Approved=green CheckCircleFilled, Rejected=red CloseCircleFilled, Current=blue PlayCircleFilled, Pending=grey ClockCircleFilled), step name, per-approver rows (deterministic pastel avatar via `personAvatarColor` + `personInitials`, name, status tag, comment in light-grey card, timestamp). Submit + End sentinels render before/after the real nodes (End only when `progress.is_finished`); orphan timeline entries (no matching node_name) collect into an "其他动作" row. Current node has grey background to mirror Feishu's own UI.
- **Replaced pages**: [`AllApprovalsPage`](../../web/src/pages/admin/AllApprovalsPage.tsx) (审批中心) and [`EmployeeRequestPage`](../../web/src/pages/asset/EmployeeRequestPage.tsx) (admin asset/software/email-mgmt views + employee /my/requests). Both delete their inline Drawer + `RequestDetailExtra` + `employeeDetailSnapshot` and pass through the same `<ApprovalDetailModal>` invocation; only differences are `viewMode` and the underlying detail-fetch callback (admin one is unconditional, employee page uses `fetchDetailForId`).
- **Retained**: `ApprovalProgressSteps` is still referenced by the new Modal indirectly through `progress.nodes`, but as raw data — the old "horizontal Steps + flat Timeline" UI no longer exists. `ApprovalTimelineSection` is no longer rendered anywhere (kept for one PR cycle in case a third caller surfaces; flag for removal in the next sweep). `FormSnapshotDisplay` is invoked from inside the new Modal's 申请表单 tab, same denylist semantics.
- **New i18n keys** (bilingual, zh-CN + en-US): `approvalDetail.tab.{overview,flow,form,raw}`, `approvalDetail.flow.{submitNode,endNode,currentBadge,noApprovers,unknownApprover,otherActions}`, `approvalDetail.form.empty`, `approvalDetail.raw.{empty,hiddenHint,toggleShow,toggleHide}`, `approvalDetail.httpError{Title,Description}`, `approvalDetail.section.submittedContent`.
- **Backend changes shipped same PR (2026-05-15)**:
  - `/admin/requests/{id}` gate widened from `require_admin()` to `require_asset_admin()` (allows `{global_admin, asset_admin}`). Mirrors the frontend `RoleRoute` whitelist on `/admin/assets/{employee,software}-requests` (`[global_admin, asset_admin]`); the previous strict gate created a P0547 round-2 regression where asset_admin clicking any list row 403'd against the admin detail endpoint. The unified-LIST endpoint `/admin/requests` stays `require_admin()` because that surface (`AllApprovalsPage`) is global-admin-only by frontend route. Asymmetric-by-design.
  - `ApprovalProgressNode.node_id: str | None` added to the schema and populated by `_build_progress_from_instance` (Feishu's stable id, or `__fb_{i}` synthetic fallback for legacy templates that omit it). Frontend `ApprovalFlowTimeline` indexes timeline entries by node_id first, falls back to name only when entries don't carry an id. Eliminates the "two same-named nodes collapse into one" bucketing bug that legacy templates with two "部门审批" stages would trip.
- **`merged` precedence (Modal `useMemo`)**: any field on `enrichment` that's not null/undefined wins over `summary`; `summary` fills the rest. Loose `v != null` predicate matters — Pydantic `Optional[str] = None` serializes to JSON `null`, not `undefined`, so a naive `v !== undefined` filter would let an enrichment hit (with no resolved requester row) splat `null` over a cached email/name from the list row.
- **Per-approver matching (ApprovalFlowTimeline)**: first-unmatched-by-task-index lookup, NOT a strict name `find`. Two approvers sharing a display name ("张伟"/USR_A and "张伟"/USR_B on the same OR-node) each get their own task entry by arrival order instead of both rebinding to task[0].
- **End sentinel double-display guard**: rendered only when `progress.is_finished && progress.current_node_index == null`. Feishu's mid-transition window ("status APPROVED but task list still claims a current node") no longer paints a green "End" alongside a highlighted "current step".
- **Tests**: `test_admin_approval_center_api.py` gains `test_detail_accessible_to_asset_admin` (asset_admin → 200), `test_detail_still_forbidden_to_plain_employee` (employee → 403), and `test_detail_includes_progress_summary` updated to assert `progress.nodes[*].node_id` is surfaced.

**Approval flow stages — admin-curated notes + all-flow scope (2026-05-21 round 2)**. Three follow-up upgrades to the pre-submit preview:

- **Preview moved to its own Modal tab.** Inlining the Steps strip ABOVE the form crowded the applicant's filling experience. The create Modal now wraps the form + preview in `<Tabs items={[{key:'form'}, {key:'flow'}]}>` (Antd). Form is the default tab; preview shown only when `stages.length > 0`. The form-state is still bound to a single `Form` instance so submit / validation / handleAssetSelect prefill chains work unchanged.
- **All-flow scope** — new `ApprovalService.list_all_flow_stages(force_refresh)` enumerates every row in `integration_approval_flows` (14 flows in prod: asset 4 + software_seat + api_key + dns / vpn / sso / soc2 email-group / ai_tool / domain_registration / cloud_scope / announcement), fans out Feishu `get_approval_definition` in parallel under a single `asyncio.gather`, and caches the result under `platform_settings['approval.flow_stages_cache']`. Per-flow fetch failures land on `FlowStageBundle.fetch_error` (instead of vanishing the entry) so the admin Modal still shows the broken row with the upstream error.
- **Admin-curated notes per stage.** New `platform_settings['approval.stage_notes']` map shaped `{approval_code: {node_id: note_text}}`, owned by IT admins and joined into every stage response (both the asset-template cache reads AND the new flow-stages cache) at response-construction time. Refreshing stages from Feishu does NOT touch notes; nodes that disappear from a redrawn flow stop surfacing their note (orphan entry stays in storage but never renders).
- **Three new endpoints** ([router.py](../../server/app/approval/router.py)):
  - `GET /approval/admin/flow-stages?refresh={bool}` — admin-only list of every flow + notes
  - `PUT /approval/admin/stage-notes` body `{approval_code, node_id, note}` — upsert / delete (null = delete)
  - `GET /approval/flow-stages/{approval_code}` — public single-flow read so non-asset create modals (api_key / vpn / dns / ...) can render the same `ApprovalStagesPreview` without going through `list_asset_templates`
  Admin gate uses `can_manage_assets` (same as `/asset/templates/refresh`), so asset_admin + global_admin both have access. Audit row written on every refresh + every upsert.
- **MCP-sync rule**: matching tools `list_approval_flow_stages` + `set_approval_stage_note` shipped same PR. `_REGISTERED_TOOLS_COUNT_HINT` bumped 168 → 170.
- **Frontend admin Modal** [`ApprovalStageNotesModal`](../../web/src/components/ApprovalStageNotesModal.tsx) opens from the EmployeeRequestPage admin toolbar (`canManageAssets` gated). Collapse-per-flow, inline TextArea + Save per stage with dirty-check (Save disabled when draft === saved). Top "从飞书重新获取" button audit-logs and re-fetches.
- **Stages-preview branch detection (heuristic)** — Feishu's OpenAPI flat `node_list` doesn't carry branch/condition metadata; consecutive same-name nodes are the empirical signal of a multi-branch step. `ApprovalStagesPreview.groupConsecutiveSameName` collapses runs into one Steps entry with a "N 个分支" purple tag + tooltip "根据您填写的内容路由". Filtered out: 提交 / Submit / 结束 / End / CC / CC_NODE / empty-name-empty-approver sentinels. Vertical Steps + scrollable container handles 14-16-node templates without crushing labels.
- **Widget default extraction fix**: Feishu v4 stores defaults under `widget_default_value` + `enable_default_value: True` (NOT `default` / `defaultValue` / `value` as the prior parser assumed); the previous PR shipped a parser that missed every default in prod. Fix preserves the legacy fallbacks for older schemas. Verified against prod laptop_replacement: 4 admin-baked defaults now extract correctly (Request Type=Retirement Replacement / 机型=MacBook Pro / Non-expired type / 是否回购=否).
- **Requested-model default override (2026-07-09)**: Helix now resolves widget defaults through shared frontend helper [`resolveWidgetDefaultValue`](../../web/src/components/approvalWidgetEngine.ts) instead of blindly trusting Feishu's `default_value`. For `template_key='laptop_replacement'` and widget name `申请电脑机型 / Request for Computer Model`, desktop [`EmployeeRequestPage`](../../web/src/pages/asset/EmployeeRequestPage.tsx) and mobile [`MRequestForm`](../../web/src/mobile/components/MRequestForm.tsx) both prefer the `MacBook Air` radio option from the live option set, then fall back to the raw Feishu default if that option is absent. Scope is intentionally narrow: this is a UI-open-time default only, does not rewrite stored template metadata, and does not mutate submission values after the user edits the field.
- **Location auto-fill on form** — backend backstop was already there; frontend `buildAssetPrefill` now also matches location-shaped widget names (`Computer pickup location` / `电脑领取办公点` / `Base Location` / `Return Location` / `归还地点` etc., negative-guarded against Payment / Bank / Tax) and fills `asset.location` so the field is visibly populated before the applicant clicks anything.

**Approval-template static stages + widget defaults + location auto-fill (2026-05-21)**. Three small, related upgrades to the self-service approval surface, all in the same PR:

- **Static stage chain in the template payload.** `ApprovalService._parse_template_stages(definition)` parses Feishu's `node_list` (modern v4) or `process_node_list` (legacy v3) — each node carries `node_id`, `name`, `node_type` (AND / OR / SEQUENCE / CC), and `approver_names` (pre-resolved when admins statically named approvers; empty when Feishu uses a dynamic resolver like "applicant's manager"). `AssetApprovalTemplateResponse` gained `stages: list[ApprovalTemplateStage]` so the frontend can show the entire chain BEFORE submission. Cached alongside widgets in `platform_settings['approval.asset_template_cache']` — zero new tables, zero extra Feishu RTT on the hot path. Bare ids in `approver_uid_list` (legacy) are surfaced as-is rather than as empty stages, so the UI shows something rather than rendering a blank node. **2026-05-26**: `approver_chosen_value` parse extended from `name / user_name` to `name / user_name / role_name / department_name` — Feishu role-resolved nodes (e.g. `{role_id, role_name: "财务"}`) and department-resolved nodes (`{department_id, department_name: "财务部"}`) previously landed with `approver_names=[]` and rendered "该环节暂无审批人" on the post-submit timeline. Frontend `ApprovalFlowTimeline` also distinguishes wait-state empty-approver nodes from anomalous ones via `approvalDetail.flow.approverDeterminedAtRuntime` (was: always "该环节暂无审批人", which read like a config error).
- **Post-submit progress now shows the FULL chain, not just traversed nodes.** `_build_progress_from_instance` accepts a new `static_stages` kwarg. When supplied, any stage NOT seen in the live `task_list` is appended to `progress.nodes` with `status=None`; the frontend `ApprovalFlowTimeline` already maps `status=None` to the `wait` UI state (grey `ClockCircleFilled`), so no renderer change was required. Empty `instance_detail` + non-empty `static_stages` also falls back to a fully-WAIT progress so a freshly-submitted request (instance created, no tasks pushed yet) still shows the chain. `total_nodes` was changed from `len(order)` to `len(progress_nodes)` so the "1 / N" Steps header reflects the static-stage overlay; `completed_nodes` / `current_node_index` continue to be derived from live data only. Plumbed through `get_admin_approval_request_detail` / `get_my_approval_request_detail` via `_lookup_cached_template_stages(approval_code)` — non-asset flows (api_key / google_*) miss the cache and silently fall back to the legacy task_list-only progress, no behaviour regression.
- **Widget `default_value` extraction (frontend prefill).** `_parse_template_widgets` now pulls each widget's pre-baked default from one of `default` / `defaultValue` / `value`, or the option-list `selected: true` flag (radioV2 / checkboxV2). For constrained widgets (`radioV2`, `select`, `checkboxV2`) the parser resolves the raw default against the option set up front: a stale default (admin renamed an option but forgot to fix the default) is nulled out **here**, before it can ride through `_serialize_feishu_form` and trip the `APPROVAL_FIELD_INVALID_OPTION` (Feishu 1390001) backstop. Frontend `EmployeeRequestPage` seeds `answers` from `widget.default_value` on modal-open, only when the slot is empty — user-typed values are preserved across modal re-opens.
- **Location auto-fill for laptop_replacement / asset_return / offboarding_buyback.** `_augment_answers_with_asset` accepts a new `person` kwarg (always `current_user`, which IS `IdentityPerson` via the `AuthUser = IdentityPerson` re-export in `app/auth/models.py`, so no extra query). Resolution chain (user-confirmed 2026-05-21): `asset.location` → `person.work_location_name` → `person.city`. The injection is gated on `_is_location_widget(name)` — bilingual alias set (`Computer pickup location` / `电脑领取地点` / `Base Location` / `Return Location` / `归还地点` / `工作地点` / `办公地点`) with a negative guard rejecting Payment / Bank / Tax widgets so the Payment-Information overwrite branch above can't be clobbered. Only `input` / `textarea` widget types receive the value (a location string almost never matches a constrained radioV2 option). Frontend submission carries the user's value when typed; backend backstop fires for MCP / replay clients that skipped the prefill round-trip.
- **Default first asset on the 3 asset-required flows.** `EmployeeRequestPage`'s create modal seeds `asset_id` to `assets[0].id` when the picker is empty and the user has at least one owned asset — fires the same downstream chain `handleAssetSelect` would (buyback quote for offboarding_buyback, Payment Information prefill, asset-metadata widget prefill). Lives in a `useEffect` on `[createModalOpen, selectedTemplate?.template_key, assets.length]` rather than inside `openCreateModal` because `handleAssetSelect` closes over `selectedTemplate` and reading it inside the same event-handler tick sees the pre-setState value. **Bug class to avoid**: calling a callback that reads from state IMMEDIATELY after `setX(value)` — the callback closure was bound on the previous render and the new state isn't visible until React commits.
- **New shared frontend component**: [`ApprovalStagesPreview`](../../web/src/components/ApprovalStagesPreview.tsx) — card-style `Steps labelPlacement="vertical"` strip with `mode='full'` (Modal top) and `mode='row'` (list-row mini, reserved for later use). i18n keys `approvalStages.previewTitle` / `approvalStages.previewSubtitle` / `approvalStages.nodeType.{AND,OR,SEQUENCE,CC,SINGLE}` / `approvalStages.unknownApprovers` / `approvalStages.row{Progress,Finished,Waiting}` in all three bundles.
- **Why this is shipped as one PR**: the four pieces share the same Feishu definition parse (`_parse_template_widgets` + `_parse_template_stages` are sibling extractors of the same payload), the same cache write-through, and the same self-service modal. Splitting would have meant 4 PRs touching the same `EmployeeRequestPage.tsx` modal block + the same `service.py` `_augment_answers_with_asset` + the same `AssetApprovalTemplateResponse` schema.

**Widget-label i18n layer (frontend, 2026-05-19)**. Feishu's OpenAPI returns `widget.name` as a single string in whatever language the form admin used at design time — no bilingual variant in the response. The 4 self-service flows (`laptop_replacement`, `computer_borrow`, `asset_return`, `offboarding_buyback`) all dynamically fetch widgets from Feishu and render them via `EmployeeRequestPage.renderWidgetField`, so the resulting form was a mixed CN/EN soup that ignored the user's i18next locale. Fix: a Helix-side translation map at [`web/src/utils/approvalWidgetI18n.ts`](../../web/src/utils/approvalWidgetI18n.ts) — flat `name → i18n key` table that accepts both CN and EN aliases of every known widget (`付款信息` → `approval.widget.paymentInfo`, `Payment Information` → same key, etc.) and falls back to the literal `widget.name` for unknowns so a Feishu-side rename does not regress to a blank label. Applied at every `widget.name` UI site: Form.Item labels, Input/Select placeholders, supplement-upload widget headers (`components/RequestSupplementUploads.tsx`). Keys live under `approval.widget.*` in all three i18n bundles. When the Feishu admin adds a new widget, the new translation pair MUST land in `approvalWidgetI18n.ts` + 3 i18n bundles in the same PR — without it the new field shows the raw Feishu string in EN/JA locales. **The map's job is to MIRROR Feishu's wording, not to improve on it (2026-08-14)**: `GET /approval/v4/approvals/{code}?locale=zh-CN|en-US|ja-JP` returns the widget names in that locale (the asset template cache stores the EN default; ja-JP falls back to EN for these templates), so an employee always has Helix's label and Feishu's label side by side. When they disagree the employee answers the wrong question: `computer_borrow`'s `Reason for borrowing and computer model/configuration requirements` (zh: 借用原因和电脑配置需求) was rendered as plain 借用原因 / “Borrow Reason”, dropping the model/config half of the ask. Also note that two templates can carry near-identical EN names with **different** Chinese ones — `laptop_replacement`'s radioV2 `Computer pickup location` is 电脑领取地点 while `computer_borrow`'s textarea `Computer pick-up location` is 电脑领用地点 — so they need distinct keys (`approval.widget.pickupLocation` vs `approval.widget.borrowPickupLocation`), not one shared "close enough" label. Verify a new/renamed widget against `?locale=zh-CN` rather than the cached EN name.

**`display_condition` evaluator (primary visibility gate, 2026-05-12)**. The Feishu form designer lets admins gate widgets behind "when widget X = value Y" rules (e.g. "回购照片" only when "是否回购旧电脑 = 是"). Helix evaluates this in three places, all using identical operator/shape semantics: (a) backend `_evaluate_display_conditions` ([`server/app/approval/service.py`](../../server/app/approval/service.py)) — called from `_serialize_feishu_form` to skip required-check + serialization for hidden widgets AND from the `form_snapshot` builder to omit hidden widgets from the audit snapshot; (b) frontend `evaluateDisplayConditions` ([`web/src/components/formSnapshot.ts`](../../web/src/components/formSnapshot.ts)) — `EmployeeRequestPage` uses it with `Form.useWatch('answers', form)` + `useMemo` to hide widgets in the create-form modal, and re-runs at submit in `buildSubmissionAnswers` to strip stale answers; (c) Feishu's own UI applies the same rules on the applicant's mobile form, which is why the previous denylist worked for known approver-only widgets but failed for arbitrary admin-authored conditional widgets. **Supported schemas** (no canonical Feishu doc; reverse-engineered): `{conditions, operator: AND|OR}`, `{condition_groups: [{conditions}, ...]}` (OR-between AND-within), bare single condition dict, bare list at root. **Operators**: `equal/equals/=/eq/is/selected`, `not_equal/neq/!=`, `in/contain/contains/includes`, `not_in/not_contain/excludes` — string-coerce comparison so radioV2 machine keys and any future numberV2 values agree across Python ↔ TS. **Fail-open** on unknown shapes or operators — a future Feishu schema change shows a previously-hidden widget but never blocks submission. The denylist (`_approver_only_widget_names`) is now narrowed to genuinely approver-fillable widgets (`Payment Information`, `Payment Amount`, `IT资产编码`, etc.); display_condition handles the conditional ones. Regression test: `test_display_condition_hides_conditional_widget` exercises both branches (gated widget skipped when condition false; required-check fires when condition true).

**Generic-flow domains** (added 2026-05-02). Four request types now extend the same Feishu-callback dispatcher and share a single submit pattern (resolve flow → fetch definition → keyword-match widgets → create instance → write parallel `ApprovalRequest`):

| Flow key | Service | Frontend entry | Cancel | Leaver handover |
|---|---|---|---|---|
| `dns_record_request` | [`cmdb/dns_request_service.py`](../../server/app/domains/cmdb/dns_request_service.py) | `/my/cloud/dns` | yes | (DNS records use the existing `RES_DNS_RECORD` blocker scan) |
<!-- DNS request actions: `create` / `update` / `delete` / `enable` / `disable`. The
     last two are "暂停 / 恢复 解析" — owner-initiated cloud-side status flip
     without touching name/value/TTL. Frontend gate on three doors before
     rendering the button: (1) row owner = current user, (2) row's integration
     provider in `PAUSE_CAPABLE_PROVIDERS = {tencent, aliyun, alicloud_intl}`
     (only drivers that implement `set_dns_record_status` natively — AWS Route 53
     has no equivalent, will 501), (3) zone has `allow_dns_request=True` on the
     `/cmdb/domains/selectable-for-dns` list. Backend `dns_request_service.submit`
     enforces (3) server-side too, so the picker filter is purely a UX hint. -->

| `cloud_scope_request` | [`cmdb/scope_request_service.py`](../../server/app/domains/cmdb/scope_request_service.py) | `/my/cloud/scopes` ▸ `SubmitScopeRequestModal` | yes (withdraw) | uses existing `RES_CLOUD_SCOPE_OWNER` |
| `sso_grant_request` | [`domains/sso/service.py`](../../server/app/domains/sso/service.py) | `/my/requests` ▸ 软件资产类 ▸ SSO 系统访问申请 | yes | new `RES_SSO_GRANT` blocker; admin handover via `POST /offboarding/blockers/{id}/handover` |
| `domain_registration_request` | [`domains/domain_registration/service.py`](../../server/app/domains/domain_registration/service.py) | `/my/requests` ▸ 网络资产类 ▸ 新域名注册 | yes | new `RES_DOMAIN_REGISTRATION` blocker; same handover endpoint |
| `vpn_application_request` | [`domains/vpn_application/service.py`](../../server/app/domains/vpn_application/service.py) | `/my/requests` ▸ 网络资产类 ▸ VPN(飞连) Full Mode | no | per-person privilege; admin revokes when the requester offboards (no handover) |
| `announcement_request` | [`domains/announcement_request/service.py`](../../server/app/domains/announcement_request/service.py) | `/my/requests` ▸ 通用类 ▸ 全员公告申请 | no | one-shot publish on approval; no ongoing privilege |
| `ai_tool_application_request` | [`domains/ai_tool_application/service.py`](../../server/app/domains/ai_tool_application/service.py) | `/my/requests` ▸ 通用类 ▸ AI 产品/功能引入 | no | approval auto-publishes onto the public `/company/ai-tools` registry |
| `soc2_email_group_grant` | [`domains/soc2_email_group/service.py`](../../server/app/domains/soc2_email_group/service.py) | **none** — submission lives in Feishu (existing org flow), `accept_external_origin=True` | no | one-shot Google Group add; no Helix-side persisted membership row |

**Missed-webhook reconcile** (added 2026-05-28). Feishu's webhook delivery is at-most-once in practice — drops, signature mismatches, mid-flight 5xx, or open-platform outages can leave Helix with `approval_requests.status='pending'` even though Feishu's side is fully terminal (APPROVED / COMPLETED / REJECTED / CANCELLED). Pre-fix recovery was non-existent: the "刷新流程" button only refreshed the timeline cache and the daily `feishu.approval_pending_reaper` explicitly skipped COMPLETED state with the comment "callback should land" — exactly the assumption that fails on a dropped webhook. The fix introduces `ApprovalService.replay_callback_from_feishu(instance_code, actor_user_id, prefetched_instance_detail)` which idempotently re-invokes `handle_feishu_callback` (reusing its `SELECT ... FOR UPDATE` lock + terminal-status guard so the row never double-applies side effects). Two call sites: (a) `get_admin_approval_request_detail` / `get_my_approval_request_detail` — after a successful live Feishu fetch, when the response shows a terminal state but Helix is still `pending`, replay inline so the admin sees the row transition on the same click; only fires when `allow_reconcile=True` (gated on `can_manage_assets` for the admin endpoint, always-True for the owner-scoped endpoint). (b) `feishu.approval_pending_reaper` — every terminal Feishu status now replays through the dispatcher instead of the old direct status-mutation, so per-domain handlers run their proper rejection-side cleanup (revoke pending seat, cancel scope request, …) and child rows (DnsRecordRequest, ScopeRequest, ApiKeyAssignment, AssetEmployeeRequest) are never orphaned. Audit rows use `action="approval.reconcile.replay"` with `trace_id` prefix `feishu.reconcile:` or `feishu.approval_pending_reaper:` so a reconcile is distinguishable from a real webhook in the audit trail. **Bug class also closed**: the original reaper had been reading `instance.get("instance_status")` since PR #164 (May 2026) — but `client.get_instance_detail()` returns Feishu's `data` block with status under the key `status`, so every status-based branch had been a silent no-op for the reaper's entire lifetime. Now reads `status`. **Related mapping fix**: `_map_feishu_status("TIMEOUT")` now maps to `rejected` (was `pending`) — TIMEOUT is a terminal Feishu state (max-duration auto-close) and the old `pending` mapping made both the live callback and the replay path no-op on it. Tests: [`tests/test_approval/test_replay_callback_reconcile.py`](../../server/tests/test_approval/test_replay_callback_reconcile.py) + [`tests/test_approval/test_reaper_replay_completed.py`](../../server/tests/test_approval/test_reaper_replay_completed.py).

**Approval-create intent / idempotency boundary** (migration 442, 2026-08-02). Asset-backed Feishu submissions no longer treat the gap between `POST /approval/v4/instances` and the local `employee_requests` + `approval_requests` transaction as an unobservable best-effort window. [`ApprovalSubmissionIntent`](../../server/app/approval/models.py) persists `(scope, applicant, Idempotency-Key)`, a canonical semantic-input fingerprint, generated local row ids, and Feishu's native `uuid` **before** the outbound call. States are `prepared → submitting → provider_created → completed`, with `unknown` for transport-ambiguous outcomes and `failed` for definite provider rejection. A completed same-key request replays from local state without contacting Feishu; a different payload is 409; a live/unknown same-key request is 409 and never blind-resubmits. Seat allocations and add-on charges use deterministic local IDs derived from `(domain, applicant, key)`, so an uncertain HTTP/MCP retry reuses the row committed with the intent rather than creating a second pending row. Generic HTTP/MCP approval submitters reject both specialized software keys: seats and add-ons must pass through their domain entry points so quota/catalog validation, trusted price snapshots, and local resource binding cannot be bypassed. `provider_created` is durable before local finalization, so a process/database failure is recoverable rather than cancelled best-effort. [`reconcile_submission_intents`](../../server/app/approval/service.py), invoked by the existing `feishu.approval_drift_reconciler`, retries create with the persisted UUID; documented collision 60012 is resolved only through `GET /approval/v4/instances?approval_code&start_time&end_time` in the immutable intent-create window, then `GET /approval/v4/instances/{instance_code}` for bounded candidates until the detail `uuid` matches. UUID is never used as an instance code; list propagation lag, missing pagination tokens, or page-cap exhaustion fail closed and remain `unknown`. HTTP (`Idempotency-Key`), MCP (`idempotency_key`), and internal services share the required-key contract. Audit actions: `approval.submission_intent.unknown`, `.failed`, `.reconciled`, and the normal `approval.asset_request.create`. The table intentionally stores the already-approved form snapshot needed for crash recovery; it stores no Feishu credential/token.

**External-origin flows** (added 2026-05-12, migration 149). `integration_approval_flows.accept_external_origin = TRUE` opts a template into a special webhook path: when Feishu pushes an `approval_instance` event for an `instance_code` Helix has never seen (i.e. the user submitted directly in Feishu, NOT via `instance/create`), `ApprovalService._synthesize_external_origin_request` fetches `instance_detail`, looks the template's `approval_code` up in `integration_approval_flows`, and if the row is opted in synthesizes the parent `approval_requests` row (`status='pending'`, `applicant_id=NULL`, `source_payload.external_origin=True`) before handing to the normal domain dispatcher. Default `accept_external_origin=FALSE` preserves the strict silent-drop contract for the 13 Helix-initiated `flow_key`s. Subscription is kept warm two ways: (a) a lifespan sweep in [`app/main.py`](../../server/app/main.py) at boot, and (b) an opportunistic `ensure_subscribed` call inside the synthesizer itself — so a Feishu-outage-at-boot can't permanently desync the in-process cache (both idempotent on Feishu's side). **Race-safety**: concurrent webhooks for the same unknown `instance_code` are caught at the `UNIQUE(external_id)` constraint — the synthesizer traps `IntegrityError`, rolls back the SAVEPOINT, and re-selects the winning row so the loser continues into the dispatcher exactly like a normal lookup-hit (downstream idempotency guard then short-circuits if the winner already settled the state). First implemented for `soc2_email_group_grant` — Feishu template `9A42C433-6A4B-411C-9B2E-9182BEEBC6BD`; on APPROVED the handler resolves applicant email via `contact/v3/users/{open_id}`, **refuses leavers** (`employment_status in ('leaver_pending', 'left')` → `AppException SOC2_APPLICANT_LEFT 409`, audit `soc2_email_group_grant.refused_leaver`), then calls `GoogleWorkspaceDriver._group_add(group="soc2@pingcap.com", email=...)` (idempotent — driver maps 409 → ok). SOC2 deliberately bypasses the provisioning queue: this is a one-shot membership grant, not a billable seat — no plan, no allocation row, no monthly bill. The audit row + `approval_requests` are the records of truth. Tests: [`tests/test_approval/test_external_origin_callback.py`](../../server/tests/test_approval/test_external_origin_callback.py).

```mermaid
sequenceDiagram
    actor AP as Applicant
    participant FS as Feishu approval
    actor IT as IT approver
    participant HX as Helix
    participant GG as Google Workspace

    AP->>FS: Submit "SOC2 add email" (template 9A42C433-…)
    FS->>IT: Route to IT
    IT->>FS: Approve
    FS-->>HX: webhook approval_instance APPROVED
    Note over HX: _synthesize_external_origin_request<br/>(accept_external_origin=TRUE) + resolve applicant email
    alt Active employee
        HX->>GG: members.insert(soc2@pingcap.com)
        GG-->>HX: 200 OK / 409 already-member (idempotent)
        Note over HX: audit soc2_email_group_grant.member_added
    else leaver_pending / left
        Note over HX: refuse → SOC2_APPLICANT_LEFT 409<br/>audit soc2_email_group_grant.refused_leaver
    end
```

> Same sequence is rendered in the in-app Help Center (IT admin tab → anchor `#soc2-email-group`, localized via [`web/src/pages/help/soc2EmailGroupFlowSource.ts`](../../web/src/pages/help/soc2EmailGroupFlowSource.ts)) and the ops runbook [`docs/ops/soc2-email-group-ops.md`](../ops/soc2-email-group-ops.md). Keep all three in lockstep when the flow changes.

Form helpers all live in their service module (e.g. `_DNS_FIELD_KEYWORDS`, `_SSO_FIELD_KEYWORDS`, `_CLOUD_SCOPE_FIELD_KEYWORDS`, `_DOMAIN_FIELD_KEYWORDS`) and follow the same shape: `dict[field_key, tuple[keyword, ...]]` substring-matched against the Feishu widget's display name. SSO additionally resolves `radioV2` single-choice widgets text → option-value via `_resolve_single_choice_value`.

> **Full-width vs half-width parens (lesson, 2026-05-12)**: production Feishu templates routinely use full-width parens — `云账号（项目）名称`, not `云账号(项目)名称`. Half-width keyword fragments do NOT substring-match full-width widget names, and the required-input fallback that catches unmatched widgets silently writes the *description* into them — producing the "for NA GTM Engineering" appearing inside the project-ID column we saw in instance 202605120025. Every flow's keyword map must list BOTH paren widths for any widget name that contains parens. `cloud_scope_request` is the canonical fixture (see [test_scope_request_form_payload.py](../../server/tests/test_cmdb/test_scope_request_form_payload.py) for the live template snapshot used as a regression baseline). Same rule applies to the DNS / SSO / domain-registration form helpers when their templates evolve.

> **Approval-inbox rule (NON-NEGOTIABLE, 2026-05-12)**: when Helix creates a Feishu approval instance, Feishu natively dispatches its approval card to the approvers configured on the template — Helix MUST NOT additionally push a "去 Helix 审批" / "Open in Helix" admin notification for that same request. Reason: `FeishuApprovalClient` has no `approve_task` endpoint, so an approval clicked in Helix never writes back to the Feishu instance; the Feishu card stays "pending" forever and the approver inbox shows two paths that don't agree. The cloud `_notify_admins_pending` push was removed in PR #256 for exactly this. Post-decision lifecycle notifications (approved/rejected/provisioned/manual_required) stay — they are outcome notifications, not duplicate approval prompts. Same rule applies to every other Feishu-backed self-service flow.

> **Admin-override Feishu-cancel rule (NON-NEGOTIABLE, 2026-05-12)**: every admin endpoint that *bypasses* the Feishu callback to advance a Feishu-backed request locally (today: `cmdb/scope-requests/{id}/approve|reject`, `assets/seat-allocations/{id}/approve` — both legitimate escape hatches when Feishu is unreachable or the approver is unavailable) MUST also best-effort `cancel_instance` the parallel Feishu approval card after committing the local decision. Without that cancel, the Feishu approver's inbox carries the card as "pending" forever — Helix moved on, but the inbox is misleadingly live. Mirrors the cancel pattern in `scope_request_service.withdraw` and `_revoke_pending_seat_on_rejection`. Cancel failure is logged with the instance code (so ops can clean the orphan from the Feishu admin UI) but does NOT block the local decision. Helper: [`_cancel_feishu_instance_on_admin_decision`](../../server/app/domains/cmdb/scope_request_service.py).

Unified cancel: requesters can withdraw any pending Feishu-backed request from their My Records tab (`POST /assets/employee-requests/{id}/cancel` for asset / software / email / api-key, or each flow's `/cancel` / `/withdraw` endpoint). The local row flips to `cancelled` AND the parallel Feishu instance is cancelled in the same call (cancel failures are logged but don't block local state — admins fix orphans in Feishu's UI).

---

## 10. Provisioning framework

### Tables ([`server/app/domains/provisioning/models.py`](../../server/app/domains/provisioning/models.py))

- `provisioning_tasks` — atomic unit. Columns: `user_id`, `software_id`, `action` ∈ {grant, revoke}, `target_type` ∈ {seat, permission, api_key, hardware, sso_grant — the IdP-side removal of a revoked SSO grant, always manual_notify and evidence-gated on completion (migration 603)}, `target_id`, `source` ∈ {onboarding, offboarding, role_change, request, manual, audit}, `execution_mode` ∈ {auto_api, manual_notify, sso_auto}, `status` ∈ {pending, in_progress, notified, completed, failed, cancelled}, `assignee_id` (manual mode), `external_ref` (provider-returned ID for revoke idempotency), `error_message`, `approval_request_id`, `context_data` (JSON, e.g. `successor_email` for Zoom; offboarding-cancel restore reads `context_data['vendor_response']` stashed post-success), `attempt_count`, `not_before` (UTC datetime; NULL = run ASAP, non-NULL = dispatcher skips until then — set by offboarding to `leaver_date + 1 day at 00:00 local TZ → UTC` so revoke fires after the leaver's last working day ends, and by onboarding to `hire_date at 00:00 local TZ → UTC` via `_onboarding_not_before(person)` in [`provisioning/service.py`](../../server/app/domains/provisioning/service.py) so a new hire's seats/accounts come alive at local midnight of their start day rather than the moment IT confirms the sync — mirrors `_leaver_revoke_not_before`; migration 221), `started_at`, `completed_at`. The gate is surfaced in the UI: the onboarding-strategy `自动开通` column copy states the local-midnight timing, and the provisioning-queue table + task-detail drawer show each task's computed 计划开通 (`not_before`) time. The onboarding-strategy page also paints the **full onboarding pipeline** (detect → reserve email → create account → grant seats/permissions → Day-1) and an **account-creation (建号) status panel** that surfaces the two account-minting switches which live outside the seat policies — Google's `google.account_provisioning.auto_create_on_hire` platform setting and the Feishu connector's `binding_config.operation` mode (`user_create` mints; `user_suspend`/`group_member` only unfreeze/add-to-group). Backed by the read-only `GET /provisioning/onboarding-overview` (`ProvisioningService.onboarding_account_overview`), which assembles those live values server-side so the page stays pure-presentation.
- `provision_policies` — auto-provisioning rules. Columns: `department_name`, `match_job_title`, `match_location` (each NULL = wildcard), `target_type`, `target_id`. Multiple rules match → union (deduplicated).
- `software_connectors` — `software_id` (UNIQUE) ↔ `integration_id` binding + `binding_config` (per-driver knobs). Tracks `last_success_at`, `last_error_*`.

### Coordinators ([`server/app/core/coordinators/`](../../server/app/core/coordinators))

- `provisioning_coordinator.py` — generic dispatch. Resolves driver by `software_connectors.integration_id`, calls `grant_seat` / `revoke_seat` / `grant_permission` / `revoke_permission`. Writes result back to task, enqueues outbox + workflow journal entry.
- `jira_seat_coordinator.py`, `slack_seat_coordinator.py`, `zoom_seat_coordinator.py`, `feishu_seat_coordinator.py` — per-product overrides for product-specific orchestration (Zoom transfers meetings, Feishu does binding-driven plan discovery + import-only reconcile, etc.).
- `hr_sync_coordinator.py` — orchestrates HRIS sync → onboarding tasks (matching `provision_policies`) / offboarding tasks. **Post-confirm trigger contract** (`server/app/domains/hr/service.py::confirm_batch`): `provision_for_new_hire` + `send_welcome_notification` fire **only** for `staged.action ∈ {create, reactivate}`. `update` (existing active person, no matching onboard lifecycle row for this `effective_date`) and `skip` are silent — they're back-fills / refreshes, not new arrivals. Including `update` here was a 2026-05-02 prod misfire that re-sent welcome mail every hour to long-tenured staff; tightened by the fix in the same dated PR with regression coverage in `test_hr/test_successfactors_lifecycle.py`.
- `role_change_coordinator.py` (2026-06-01, closes [#623](https://github.com/pingcap-inc/Helix/issues/623)) — fires from `confirm_batch` for `staged.action ∈ {update, reactivate}` when **dept / title / manager** changes vs the prior `_prior_role` snapshot (stashed at `_stage_event` time on `person_data`). Three side effects, all idempotent: (1) writes one `HrLifecycleEvent(event_type='role_change')` with `detail={before, after, signature}` — the forensic timeline (#622) reads this for the in-flight KPI; (2) `UPDATE cmdb_cloud_access_grants SET review_status='pending_review', review_status_changed_at=NOW() WHERE person_id=str(person.id) AND status IN ('active','pending') AND review_status IS NULL` — every active grant flags for human decision (no auto-revoke per #623 legal policy); (3) calls `ProvisioningService.preview_role_change(person_id)` so the new-dept default-policy diff lands on the admin's approval queue (no auto-apply). Same-day re-run is dedup'd on `HrLifecycleEvent.created_at >= today_utc_start()`. Like all coordinators, swallows exceptions with a logger.warning — one person's role-change failure must NOT abort the HRIS confirm batch.

**HRIS hygiene — two-checkpoint domain skip** (`HR_SKIP_EMAIL_DOMAINS`, added 2026-05-07): `is_placeholder_email` only catches synthetic stub strings (`dummy*`, the explicit set, `*@successfactors.local`); real vendor mailboxes (`ECadmin@gaiaworks.cn`, `COMPadmin@partner.gaiaworks.cn` from PingCAP's SAP implementation partner) and SF-side duplicate userIds (where the canonical row already owns the real email and `_resolve_unique_work_email` falls back to `<userId>@successfactors.local`) need a second mechanism. `should_skip_user` checks the SF-returned `User.email` domain against `HR_SKIP_EMAIL_DOMAINS` (drops `gaiaworks.cn` family at preview); `_upsert_person`'s create-path checks the email returned by `_resolve_unique_work_email` (drops the synthetic-fallback duplicate-userId family at upsert). Subdomain match — `gaiaworks.cn` covers `partner.gaiaworks.cn` automatically. `_apply_event` short-circuits with `(False, False, False)` when `_upsert_person` returns `None` so no half-built manager / dept / external-identity / lifecycle rows orphan onto a missing person. Default skip set: `gaiaworks.cn,successfactors.local`. Confirmed 2026-05-07 prod: P01115 + 100113 both reported as `yun.zhu@pingcap.com` for 朱赟 Claire Zhu (one human, two SF userIds — only the canonical P01115 lands in helix).

### Cron

`provisioning.dispatch` polls pending tasks, runs through coordinators. Manual retry via `POST /api/v1/provisioning/tasks/{id}/execute`.

**Per-(user_id, software_id) serialization (added 2026-05-08)**: vendor APIs (Zoom, Google Workspace, Slack) impose per-user rate limits that fire as 429 "concurrent request" cascades when N tasks for the same user hit one tick. `execute_pending_batch` now tracks `(user_id, software_id)` keys per tick for `auto_api` tasks and defers duplicates to the next tick; the leading task fires (oldest by `created_at`), the rest re-enter on the next 30s cadence. `sso_auto` and `manual_notify` tasks bypass the filter — they don't make outbound vendor calls. Caught 2026-05-08 (kevin.lu@pingcap.com — 3 Zoom revokes in one tick: 1×1107 managed-domain + 2×429 cascade).

**Optimistic-revoke failure-revert hook (added 2026-05-08)**: the admin seat-revoke endpoint flips `alloc.status='revoked'` immediately on enqueue (UI feedback). When the dispatcher's auto_api revoke fails (managed-domain 1107, vendor 5xx beyond max retries, etc.), `_post_failure_side_effects` reverts the seat to `active` + appends the failure reason to `seat.reason`, so admin sees the divergence and can retry. Opt-in via `provisioning_tasks.context_data["revert_seat_on_failure"]=True`; the admin endpoint stamps it. Leaver-blocked guard's flip-to-left path explicitly does NOT opt in (its Helix-side revoke is the billing-correctness invariant — vendor cleanup retries via `post_leaver_verify`); change-plan does NOT opt in (its old/new seat pair would diverge under unilateral revert). Mirror of `_post_success_side_effects` for the failure branch — same dual-key allocation/plan_id lookup.

**Transient-error auto-retry (2026-06-10, execution-plane PR3)**: a connector failure whose text matches `_TRANSIENT_ERROR_FRAGMENTS` (429 / rate limit / timeout / 502/503/504 / connection reset — conservative whitelist; unknown error classes still land loud `FAILED`) goes back to `PENDING` with an exponential `not_before` backoff (5/15/45/90/120 min rungs, capped) via `_maybe_schedule_transient_retry`, instead of dying terminal on the first attempt. Prod audit motivating it: every failed task had `attempt_count=1` — the dispatcher only re-picks PENDING, so a Zoom 429 / Google 502 sat failed until a human clicked retry while the model already carried `attempt_count`/`not_before`/the 10-attempt dead-letter cap. Both the exception path and the `result.ok=False` path classify; failure side-effects (admin DM, optimistic seat revert via `_post_failure_side_effects`) fire ONLY on the terminal outcome; connector-health failure stats still record each blip. Audit per reschedule: `provisioning.auto_api_transient_retry` (attempt_count + next_attempt_at). The existing dead-letter gate at the top of `execute_task` bounds the loop (~16 h window). The Feishu `tenant_users` read-only refusal (grant + revoke) is now flagged `capability_unsupported=True` so it routes to the downgrade below instead of FAILED. Tests: [`test_transient_retry_2026_06_10.py`](../../server/tests/test_provisioning/test_transient_retry_2026_06_10.py).

**Plan-gate / capability downgrade (added 2026-06-03)**: a connector can return `ProvisioningResult(capability_unsupported=True)` when the vendor *plan/tier* exposes no API for the operation (Slack Pro has no SCIM → `plus_teams_only`; a GitHub org without Enterprise-Cloud SAML SCIM → 403/404 on the SCIM endpoint). This is distinct from a transient error and from "already gone". `execute_task` reads the flag and **downgrades the task to `manual_notify` (`status=NOTIFIED`, `execution_mode=manual_notify`) + best-effort assignee DM instead of `FAILED`** — a plan-gated revoke no longer hard-fails and pins the owning offboarding blocker at `RESO_PENDING` with no signal; a human completes it via `POST /provisioning/tasks/{id}/complete` (which resolves the blocker the normal way). The create-time complement: `_resolve_execution_mode`'s **seat branch now honours `binding_config['execution_mode']='manual_notify'`** (previously it forced `auto_api` for any non-`manual` provider, ignoring the binding), so a correctly-configured Slack Pro binding never even produces an auto_api seat task. Pairs with the honest `suspend_sso` gate: it now counts a post-`execute_task` `status=FAILED` (the ok=False path, which doesn't raise) toward `failed_task_ids`, so `sso_suspended_at` is no longer stamped — and `complete_process` no longer lies about "vendor sessions terminated" — when a dispatched revoke truly failed.

**Revoke-failure admin DM (added 2026-05-15)**: orthogonal to the revert opt-in — every revoke seat task that transitions to `failed` (dead-letter, connector exception, connector-reported failure) triggers `_dispatch_revoke_failure_dms`, which fans out to the software owner (`provisioning_tasks.assignee_id`) when set, otherwise to every active IT admin. Feishu + email + notification_outbox via `NotificationService.dispatch`, template key `provisioning.revoke_failure`, deep-link to `/admin/software/cancellations`. Per-recipient try/except so one bad address can't sink the fan-out; the DB-state write (status, error_message, optional seat revert) lands first and the DM is best-effort on top. Fires unconditionally on revoke seat failures regardless of `revert_seat_on_failure` — leaver-blocked and change-plan failures also surface to admin since the human attention is what matters, not the seat-state choice.

### Software Seat Cancellations overview (admin route, added 2026-05-15)

[`/admin/software/cancellations`](../../web/src/pages/software/SoftwareCancellationsPage.tsx) is the dedicated admin dashboard for revoke activity. Four segments, each backed by a different query slice — exists because the generic `/admin/provisioning/tasks` queue couldn't cleanly slice "auto-done vs awaiting-manual vs failed vs scheduled" without N round-trips:

- **Scheduled** — `GET /assets/seat-allocations?has_pending_cancel=true&status=active`. Active seats with `cancel_at_period_end IS NOT NULL`, ordered by `cancel_at_period_end ASC` (next-to-fire first). Admin actions: `POST /seat-allocations/{id}/revoke` (force-revoke now, skip the scheduled boundary) + `POST /seat-allocations/{id}/cancel-undo` (clear the pending cancel).
- **Completed** — `GET /provisioning/tasks?action=revoke&status=completed`. Every completed revoke regardless of execution_mode. Includes both `auto_api` / `sso_auto` (platform-finished via SaaS API) AND `manual_notify` rows the software owner closed via `POST /provisioning/tasks/{id}/complete`. The Execution Mode column in the table distinguishes the two. **2026-05-22 fix**: pre-this-fix the segment filtered to `execution_modes=auto_api,sso_auto`, which silently hid every owner-confirmed manual_notify revoke — a real leaver's OpenAI seat (manual_notify, completed) landed in NO segment of the page. Found via prod query: `qiang.wu@pingcap.com` left 2026-05-18, owner clicked Mark complete on 2026-05-20 13:46, task fell through all 4 segments.
- **Awaiting manual** — `GET /provisioning/tasks?action=revoke&execution_modes=manual_notify&statuses=pending,notified`. Owner DM'd, waiting for them to revoke in the vendor console + mark complete via `POST /provisioning/tasks/{id}/complete`.
- **Failed** — `GET /provisioning/tasks?action=revoke&status=failed`. Failed revoke tasks; admin retries via `POST /provisioning/tasks/{id}/retry`. The matching DM helper (above) ensures the software owner / IT admins already received a Feishu + email push when these landed.

**Self-cancel policy — `asset_software_catalog.allow_self_cancel` (BOOLEAN, default TRUE; migration 361)**: a catalog-level gate on whether a seat *holder* may drop their own seat from self-service. IT-managed identity software (Google Workspace, Feishu, …) is set `False` — the account is provisioned + governed by IT and tied to SSO, so the employee must not be able to self-cancel; admins still cancel on their behalf (admin-on-behalf). Enforced in BOTH `request_cancel_seat_next_month` (HTTP) and MCP `cancel_my_seat_next_month`: when the caller is the holder *and* not an asset/global admin, the catalog row is resolved (allocation→plan→catalog) and `allow_self_cancel is False` → 403 FORBIDDEN, checked *before* the `status` gate so a blocked self caller gets the policy reason, not a state oracle. The flag is surfaced per-allocation by `_enrich_seat_allocations` and by `GET /seat-allocations/{id}/cancel-preview`; the frontend [`UserItUniverseTab`](../../web/src/pages/admin/UserItUniverseTab.tsx) disables the holder's own Cancel button (admins keep it) with a "managed by IT" tooltip. **Orthogonal to** `seat_revocable` (connector-binding flag, Atlassian add-on cascade — technical) and `offboarding_policy` (leaver-time disposition). Set via the software-catalog admin form or MCP `update_my_software_catalog_entry` (`allow_self_cancel` param).

Each segment also accepts a free-form **`keyword`** parameter (added 2026-05-22) — `/assets/seat-allocations` already had it, `/provisioning/tasks` was extended in the same PR. Case-insensitive substring match against the target person's `display_name` + `work_email` AND `asset_software_catalog.name`; the join to the software catalog is an `OUTER JOIN` so tasks whose `software_id IS NULL` (e.g. cloud-account grants) still surface when only the person matches. Empty / whitespace-only keyword is ignored at the service layer. The UI wires this to a single 240px `Input.Search` (debounced 250ms; Enter / clear-button commits immediately) sitting next to the segment toggle.

**Structured filter toolbar (added 2026-06-02)** — the keyword search box is supplemented by three structured pickers in the same `JmlSection extra` row, all wired to BE params that already existed:
- **Employee picker** (`UserSelect`, debounced backend-search) → `user_id`. `filterStatus=['active','leaver_pending','left']` because cancellation events are dominated by leavers — the default `UserSelect` status mask would hide most of the rows on this page.
- **Software picker** (preload via `GET /assets/software-catalog?page_size=500&status=active` once per mount; client-side `optionFilterProp=label` because the catalog is 50-300 rows at most) → `software_id`.
- **Execution-mode multi-picker** (`auto_api` / `manual_notify` / `sso_auto`) → `execution_modes` CSV. Rendered ONLY on the auto-done + failed segments — `manual_pending` already pins `execution_modes=manual_notify` and `scheduled` queries `seat-allocations` which has no execution-mode concept. Hidden (not disabled) on those segments so the visible filter set never lies about what's actually applied.

Any structured-filter transition resets `page` to 1 (otherwise picking a software filter while on page 7 of "all completed" would surface an empty page from a smaller result set). KPI tiles are intentionally NOT scoped to the filters — they represent the overall workload regardless of the operator's current slice, so a cleared filter restores the visible total without an extra refresh round-trip.

**`target_label` enrichment (added 2026-06-02)** — `ProvisioningTaskResponse` carries a derived `target_label` alongside `software_name`. Resolution order:
1. Seat / permission tasks: mirrors `software.name`.
2. `target_type='product_access'` tasks (per-product Atlassian revokes — Jira Software / Confluence / JSM, created by `_revoke_via_product_access`): prefers `context_data.product_label` ("Jira Software") which is more precise than the integration name; falls back to `integration.name` when context was stripped (e.g. older audit-job-driven rows).
3. `target_type='account'` tasks (Jira / Atlassian "remove from all groups", created by offboarding `_apply_account_policy`): falls back to `integration.name` resolved via `target_id`.

Pre-this-fix the Software Cancellations table rendered "—" for every Atlassian leaver because both target types legitimately carry `software_id=NULL` and the FE only read `software_name`. The FE column now renders `software_name || target_label` with secondary labels disambiguating the row kind: "全账号 / Full account · all groups" for `account`, "产品撤权 · Atlassian / Product access · Atlassian" for `product_access`. Bulk-load uses one extra `_load_integrations(target_id)` pass scoped to both target types — no N+1. Prod backfill: existing rows resolve via the same JOIN at read-time; verified against prod 2026-06-02 that 33/33 historical NULL-software revokes now resolve a label (0 remaining as "—").

`provisioning_router.list_tasks` gained three multi-value filters in the original 2026-05-15 PR — `action` (single), `execution_modes` (comma-separated CSV, server-side `.strip()` per token), `statuses` (comma-separated CSV, multi-value wins over the singular `status` arg via `elif`) — so a single segment can express `execution_mode IN (auto_api, sso_auto)` or `status IN (pending, notified)` without a fan-out. **MCP parity** (two new tools, count hint 140 → 142): [`list_scheduled_software_cancellations`](../../server/app/mcp/tools.py) (admin-only, mirrors the `has_pending_cancel` HTTP filter) covers the Scheduled segment; [`list_provisioning_tasks`](../../server/app/mcp/tools.py) (admin-only, accepts the same multi-value filters as the HTTP route) covers the other three segments so AI clients can drive the cancellations overview end-to-end (the pre-existing `list_my_provisioning_tasks` forces `assignee_id=self` and isn't suitable for admin queries).

### Quota & eviction

License plans (`software_license_plans`) carry `max_seats` + `over_quota_policy` ∈ {reject, evict_lru}. On grant, if quota would exceed, evict-LRU policy revokes the least-recently-logged-in holder (with admin whitelist) before granting.

### Seat-tier change (admin)

`POST /api/v1/assets/seat-allocations/{id}/change-plan` is the **only** path that changes a seat's tier (e.g. Zoom Basic↔Licensed, Slack Multi-Channel Guest→Full Member). It atomically revokes the existing seat + creates a new active seat on `new_plan_id` under one trace_id, writing a `software_seat.plan_changed` audit row. The grant task carries `context_data.allow_demote=true` so drivers (the Zoom driver in particular) permit the licensed→basic PATCH path that the regular `grant_seat` flow refuses. Constraints: same software, same currency, no duplicate active seat on the target plan; gated to `asset_admin` / `global_admin`. Any other "tier change" must go through this endpoint — silent demote inside `_reconcile_existing_user` is forbidden (DECISIONS 2026-05-05 #3).

### Seat-sync drift safety (cross-cutting, 2026-05-05)

Five seat-sync handlers (`zoom`, `slack`, `google`, `jira`, `feishu`) share `server/app/scheduled_jobs/_seat_sync_helpers.py`:

- `safe_to_drift_revoke(matched, existing, label, min_ratio=0.5)` — refuses drift cleanup when matched-vs-existing ratio is below 50%. Catches both empty-tenant outages and paginator truncation.
- `write_sync_audit(db, job_key, summary)` — emits one `audit_logs` row per sync run. CLAUDE.md non-negotiable #6 was bypassed by every seat-sync handler pre-this-PR; this helper restores it.
- `write_drift_event_audit(db, job_key, seat_id, ...)` — per-seat audit row for sync-driven revokes.
- `is_safe_max_seats_change(current, new)` — refuses auto-tune drops >25% per tick (Zoom `/accounts/me/plans` re-reads).
- `import_dedup_filter(model, *, now, grace_hours=2)` (round 12, 2026-05-08) — SQLAlchemy WHERE-clause that adds the symmetric "freshly-revoked" arm to the existing active+pending dedup scope. Without it, an admin manual revoke that's still inflight on the vendor side races the sync's import branch: vendor still has the user → import creates a new active row → admin sees "I revoked it, why's it back?". The 2h grace mirrors the existing fresh-grant grace on the drift-revoke side. Used by all five handlers (zoom, slack, google, jira, feishu).

`software_seat_allocations.drift_observed_at` is the per-seat grace stamp used by the Feishu drift cleanup. Other vendors revoke immediately on drift (no grace) because their HR semantics don't include the legitimate yank-and-reinsert pattern Feishu has.

**Transaction hardening (2026-06-12, post-incident).** All five seat-sync handlers share two failure-isolation layers after a prod worker restart double-ran `jira.seat_sync` and the rerun died in `PendingRollbackError` from an INSERT deadlock (errno 1213): **(1) per-row SAVEPOINT** — every import-INSERT and drift-revoke mutation is wrapped in `async with db.begin_nested()` + `flush()`, so a row-level failure (FK violation, unique-index collision) rolls back just that row and the run still reaches its reconciliation snapshot + final commit; **(2) deadlock restart** — a 1213 rolls back the WHOLE transaction server-side (no savepoint can save the session), so the per-row excepts re-raise `OperationalError` and the registered handler is a thin shell around [`handlers.run_with_lock_retry`](../../server/app/scheduled_jobs/handlers.py): first attempt on the scheduler-provided session (happy path unchanged), on 1205/1213 it rolls the poisoned session back and re-runs the (idempotent) reconcile on fresh sessions via `_run_in_fresh_session_with_lock_retry`. Reuse `run_with_lock_retry` for any cron whose handler should survive being a deadlock victim without abandoning the framework-injected session in tests. **Both helpers are PEP 695 generic in the work's return type since 2026-08-27** (`[WorkResultT]`, `work: Callable[[AsyncSession], Awaitable[WorkResultT]] -> WorkResultT`) — they are transport, not policy, so a handler's own `dict[str, Any]` survives the wrapper instead of decaying to `Any` at the call site (that decay was 10 baselined `no-any-return` errors across 9 handler files). One consequence for new callers: mypy cannot infer a **lambda with a default-argument binding** against a generic parameter (`[misc] Cannot infer type of lambda`), so the per-iteration binding idiom `lambda session, p=period: work(session, p)` must be written as `functools.partial(work, period=period)` — same eager binding, inferable type.

### Idle software-seat reclaim (`software_seat.idle_reclaim`, added 2026-05-27)

Daily cron that recovers paid Atlassian (Jira Software / JSM / Confluence) seats that go unused. Handler [`scheduled_jobs/idle_reclaim_handlers.py`](../../server/app/scheduled_jobs/idle_reclaim_handlers.py); cron `0 2 * * *` UTC. **Seeded `enabled=0`** by migration **204** — an operator enables it after a one-time prod-worker probe confirms the Atlassian Org Admin API `product_access` JSON shape (warnings are user-facing; must not fire against an unverified shape). Lifecycle per seat: `idle_days > threshold` → **warn** (stamp `idle_warned_at`, push to holder with a 保留坐席 CTA) → **grace buffer** (default 14d, holder-local timezone) → if still idle and not kept → **reclaim**: optimistic seat flip + a `target_type='product_access'` provisioning task via the coordinator's `enqueue_revoke_for_seat` (rewired 2026-06-11, migration 275 corrects the job description) — the SAME pipeline self-cancel / admin-revoke / bounce-audit use. `revoke_product_access` strips the holder from EVERY group conferring the product (applicationrole ∪ admin-declared), so multi-group holders (`jira-dev-engineers` / `RD Leader`) are genuinely reclaimed; sibling add-on cascade, in-flight dedup, retries and `revert_seat_on_failure` (failed vendor revoke flips the seat back to active + DMs admins) come from the pipeline.

- **Idle signal** comes from the reusable [`integrations/atlassian_activity.py`](../../server/app/integrations/atlassian_activity.py) helper — `fetch_atlassian_activity(db)` returns a directory keyed by email; per (person, product) it computes the MAX `last_active` across the **paid production sites only** (Jira/JSM → `tidb.atlassian.net`; Confluence → `tidb.atlassian.net` + `pingcap-us.atlassian.net`, configurable via `software_seat.idle_reclaim.production_sites`), ignoring `tidb-sandbox` + external personal sites; parses the nanosecond UTC `last_active` by its 10-char date prefix; blank `last_active` → `None` ("undetermined", never idle). Reusable by any future license-utilisation report. Backed by two new `JiraSoftwareDriver` methods: `list_org_users_with_activity()` + `get_user_groups(account_id)`.
- **The SAME directory is the seat-COUNT truth source** (added 2026-05-27): `AtlassianAccountActivity.access_sites_by_product` records per-product presence regardless of `last_active`, and `AtlassianActivityDirectory.license_holders(family, sites)` returns the active accounts holding a product on given sites. This is what `jira.seat_sync` now reconciles `software_seat_allocations` against (who really holds a license), and what the offboarding line consults for "who still has a license". Note the deliberate asymmetry vs the idle signal: counting MUST include holders with a blank `last_active` (admins / API-only users hold a paid seat), whereas the idle signal treats blank as "undetermined".
- **Hard guardrails (never auto-reclaim):** Atlassian admins (members of `jira-administrators` / org-admin / site-admin — `fetch_admin_account_ids`), non-`active` employees (leavers go through offboarding), blank `last_active`, inactive Atlassian accounts. (The pre-2026-06-11 **multi-group manual-review punt** is gone — the product_access route removes ALL conferring groups, so multi-group holders are reclaimed automatically; legacy `idle_manual_review_at` stamps are cleared on reclaim.)
- **`seat_revocable=false` plan skip (added 2026-05-27, footgun fix):** the per-head-billed Marketplace add-on catalogs (Jira eazyBI / ScriptRunner / Tempo Timesheets — **prod-only `software_connectors` rows, not in any migration**) reuse `group_name='jira-software-users'` + `product_key='jira-software'` purely as a billing proxy (each is its own ledger sharing the Jira Software holder set). `get_jira_plans()` (matches catalog `like 'jira%'`) pulls them in, and a per-seat "reclaim" on them would call `revoke_seat(group_name='jira-software-users')` → **strip the holder's real Jira Software access** (and a tier-licensed app has no per-person seat anyway). Such bindings carry `seat_revocable: false`; idle_reclaim skips the whole plan (→ `summary['skipped']`, never `errors`, no false-page). Helper `_seat_revocable(binding)` (default `True`; tolerates bool / `"false"`/`"0"` string / `JSON_SET(...,false)` int). Driver `binding_schema` exposes `seat_revocable` (select true/false, default true, optional). **`jira.seat_sync` never reads this field** — counting stays by `product_key`, so the statistics口径 is independent of the idle-reclaim opt-out. **Prod data fix (no migration)**: set `seat_revocable=false` on the 3 add-on connectors, targeted by `group_name='jira-software-users' ∧ product_key='jira-software' ∧ catalog.name<>'Jira Software'`:
  ```sql
  UPDATE software_connectors sc
  JOIN asset_software_catalog c ON c.id = sc.software_id
  JOIN integrations i ON i.id = sc.integration_id AND i.provider = 'jira'
  SET sc.binding_config = JSON_SET(sc.binding_config, '$.seat_revocable', CAST('false' AS JSON))
  WHERE sc.is_active = 1
    AND JSON_UNQUOTE(JSON_EXTRACT(sc.binding_config, '$.group_name')) = 'jira-software-users'
    AND JSON_UNQUOTE(JSON_EXTRACT(sc.binding_config, '$.product_key')) = 'jira-software'
    AND c.name <> 'Jira Software';
  ```
- **Unmatched-holder visibility (added 2026-05-27):** `jira.seat_sync` per-plan summary carries `holders_external_unmatched` (count) + `holders_external_unmatched_samples` (the actual emails, capped 50) + `holders_without_email`. License holders that match no `identity_persons` row (external partners, service/bot accounts) are the gap between the vendor bill (e.g. 366) and the Helix employee-seat count — the `ScheduledJobRunsPage` renders `result_summary` verbatim so the delta is explainable rather than an unexplained "数据对不上".
- **Reclaim ≠ account disable.** The product_access revoke removes only the product's conferring groups — identity/org groups, other products, and the Atlassian account itself are untouched. Disabling the whole account stays the offboarding line.
- **Self-service keep:** `POST /assets/seat-allocations/{id}/keep` (self or admin) sets `idle_kept_until = now + keep_days` (default 180) and clears `idle_warned_at` so a future idle stretch re-warns with a full buffer. 1:1 MCP twin `keep_my_idle_seat`. Surfaced on the IT 宇宙 seats tab (`UserItUniverseTab`, which serves `/me`) as an idle-warning tag + 保留坐席 button.
- Thresholds/buffer/keep/admin-groups/prod-sites are `software_seat.idle_reclaim.*` platform_settings (seeded by 204, with hard-coded handler fallbacks). Idempotent; raises (not silent `status='failed'`) only when every unit failed.

### Currency & billing aggregation

`SoftwareLicensePlan.currency` (default `'CNY'`) declares the plan's currency. Aggregation surfaces (`compute_billing`, `_build_lines_for_period`, `list_period_bills`, and — since 2026-05-27 — the live `_compute_cost_stats`) refuse cross-currency totals: result is `{by_currency: {CNY: ..., USD: ...}, total_amount/total_cost: null}` when multiple currencies are present, single-currency keeps the single total populated for back-compat. Every row carries `currency` so clients group per currency rather than summing across; `SoftwareCostPage` and the two MCP cost-summary tools both consume it.

### Reopen-month snapshot

`software_billing/reopen` no longer hard-deletes finalised rows. Before clearing, every row is copied into `software_monthly_bill_history` with `supersedes_seq` (1, 2, 3, …) + `superseded_at` + `superseded_by` so any prior version of any closed period stays reconstructible. Both endpoints (`close` + `reopen`) are gated to `global_admin` only (was `asset_admin`).

**MCP parity (2026-05-16, hint 146 → 148)** — [`software_billing_preview_period(period)`](../../server/app/mcp/tools.py) + [`list_software_billing_periods(months=12)`](../../server/app/mcp/tools.py) mirror the HTTP preview / list / known-periods routes per the MCP-sync rule. Both admin-only via `_require_asset_admin`. Preview returns the full aggregate (`total_amount`, `by_currency`, `active_seats`) plus the first 200 line items with `items_truncated` flag so AI clients can fall back to the HTTP route for full pagination on a 5000-seat fleet. The new [`test_billing_revoke_mid_month.py`](../../server/tests/test_asset/test_billing_revoke_mid_month.py) suite (8 cases) pins the revoke-mid-month-still-billed invariant — first service-layer tests for `billing_service`; the whole module was previously untested at the unit level (a 2026-05-16 audit grep confirmed zero hits across `server/tests`).

### Live cost-stats == monthly bill (aligned 2026-05-27)

The live "当月(实时)" view (`asset.router._compute_cost_stats`, behind `/cost-stats/{my,subordinates,department,all}` + MCP `my_software_cost_summary` / `subordinates_software_cost_summary`) and the frozen monthly bill (`billing_service`) now select the **same billable seat set** via one shared predicate, [`billing_service.period_overlap_clauses(period_start, period_end_excl)`](../../server/app/domains/asset/billing_service.py). Both `_fetch_allocations_overlapping` (close/preview) and `_compute_cost_stats` (live) call it — the two surfaces differ **only on price** (live reads the current plan price so admin edits show before close; the snapshot freezes price at close). `_compute_cost_stats` computes each row's this-period `amount` with the same `billing_service._compute_amount` (monthly=price, yearly=price/12, one_time=price only in its allocation month) and drops 0-contribution rows, so a live row lines up 1:1 with a `software_monthly_bill` snapshot row. `SeatAllocationResponse.amount` (optional) carries it; the frontend passes it through (no client-side re-derivation). `active_seats` counts only currently-active seats; a seat cancelled / tier-changed mid-month is **billed** (amount included) but not counted active — same split as the snapshot. **Bug this fixed**: the live view previously filtered `status == 'active'` and re-derived amount client-side, silently dropping mid-month-revoked / tier-changed seats and over-charging one_time every month, so "本期合计" disagreed with the eventual bill ("账对不上"). Regression: [`test_cost_stats_live_matches_bill.py`](../../server/tests/test_asset/test_cost_stats_live_matches_bill.py) (8 cases) pins live==bill. `ai_spending` inlines the same overlap predicate independently (correct); dashboard-insights `total_monthly_cost` is a forward run-rate (active-only is correct there) — neither was a bug.

**Seat count = vendor truth, not bill lines (refined 2026-05-30)**: `active_seats` on the workbench now reads `software_seat_sync_stat.vendor_license_holders` (the same number the drill-down drawer's "厂商许可" stat shows — Atlassian's `72/100` etc.), summed per software, returned in the new `vendor_seats_by_software: dict[str, int]` field on [`CostSummaryResponse`](../../server/app/domains/asset/schemas.py) + [`MonthlyBillSummaryResponse`](../../server/app/domains/asset/schemas.py). Helpers in [`billing_service`](../../server/app/domains/asset/billing_service.py) — `_vendor_seats_by_software` (per-software, **deduped by product family**, returns `{software: {product_key: count}}`), `_flatten_vendor_seats` (collapse to the flat `vendor_seats_by_software: dict[str, int]` response field), and `_seat_count_from_lines` (vendor-first / distinct-user-fallback, **dedups the family ACROSS software** so each contributes once) — shared by all three response paths so the live view, draft preview, and finalized period agree exactly. **Family-dedup (2026-05-30, separate PR)**: `vendor_license_holders` is org-wide & `product_key`-keyed (`all_license_holders(family)`, site-agnostic), so SUMming it across plans/software counted a family once per catalog row — prod top "生效坐席" read 3241 vs real 2119 (the `jira-software` family on 4 marketplace catalogs × 364, plus Confluence per-site). Now collapsed by `product_key` (representative = MAX; NULL key → per-plan so unkeyed products still sum); per-software cells still show their own family count (matches the drawer). **Bug this fixed**: PR #579 had briefly set `active_seats = len(billable_rows)` so the seat number would equal the bill-receipt line count, but multi-tier products (JSM Service Agent / Reporter / External; Confluence multi-site) spawn one billable line per (user, plan) AND mid-month tier swaps create revoke→active line pairs, so the count inflated to ~2.4× the real vendor seat count (JSM observed 175 vs vendor's 72). The workbench cell and the drawer stat disagreed openly — impossible to reconcile against the vendor's bill. Seat count and cost are now **independent axes**, each true on its own terms: vendor-aligned seat count answers "how many seats does the vendor charge us for"; `total_cost` answers "how much will Helix's bill be" (still a faithful sum of every billable line's amount). Software with no seat-sync row (catalog-only entries) falls back to distinct `user_id` count of its billable rows. The historical period-index path (`list_known_periods`, 12-month tooltip aggregate) uses distinct `(user_id, software_id)` pairs from the frozen bill since the seat-sync table only holds **current** vendor state — a close-time snapshot was never captured. Regression: 2 new cases in [`test_cost_stats_live_matches_bill.py`](../../server/tests/test_asset/test_cost_stats_live_matches_bill.py) pin the vendor-overrides-distinct-users and distinct-users-fallback paths; one existing tier-change case flipped from 2 → 1 to reflect the new semantics.

**Multi-currency parity (resolved 2026-05-27)**: `_compute_cost_stats` now mirrors the snapshot's currency handling end-to-end. `SeatAllocationResponse.currency` (plan-level, upper-cased, default `CNY`) is stamped on every row; the summary aggregates via `billing_service._aggregate_by_currency`, so `CostSummaryResponse` gains `by_currency` / `total_currency_count` / `currency_warning`, and the per-cycle totals (`total_monthly_cost` / `total_yearly_cost` / `total_one_time_cost` / `total_cost`) become `Decimal | None` — populated single-currency, `None` on a mix (same rule as `preview_current_or_draft.total_amount`). The two MCP cost-summary tools surface `by_currency` + `currency_warning` too (no tool-count change). Frontend [`SoftwareCostPage`](../../web/src/pages/asset/SoftwareCostPage.tsx) groups every amount per currency: the summary cards, per-software and per-person totals, and per-row unit-price / amount all render with `fmtCurrency(value, currency)` (glyph from the shared `currencySymbol` map — ¥ for a CNY fleet, $ for USD), never summing CNY + USD into one figure; a `currency_warning` info banner explains the split (no FX conversion). New regression cases pin the mixed-currency split + single-currency back-compat.

### Email-search on cost responses (added 2026-05-08)

`SeatAllocationResponse` (live `/cost-stats/*` path) and `MonthlyBillLine` (frozen `/software-billing/*` path) both expose `user_email` so the `SoftwareCostPage` keyword filter blob can match on email. Live preview reads it from the already-loaded `IdentityPerson`; the finalized-snapshot path joins via a chunked PK lookup (`IdentityPerson.id IN (bill_user_ids)`, chunked at 1000 to keep TiDB's IndexLookup plan clean even on a 5k+ row period). Email is **not** snapshotted on `software_monthly_bill` — it's looked up live so a 6-month-old closed bill is searchable by the user's *current* email even after a name/email change. Hard-deleted persons fall through to `user_email=None`; the row still renders with the frozen name/department.

---

## 10.5 Identity Sync Center (outbound employee-data hub)

**Domain**: [`server/app/domains/identity_sync/`](../../server/app/domains/identity_sync/) — separate concern from Provisioning.

| Surface | Provisioning (§10) | Identity Sync Center (§10.5) |
|---|---|---|
| Granularity | per-software, per-grant | per-(target, person) projection |
| Trigger | one-shot grant/revoke task | continuous re-projection (cron + event + manual) |
| State model | task queue (`provisioning_tasks`) | rolling `identity_sync_state` (last_payload_hash, external_id) |
| Idempotency | `external_ref` per task | sha256 of rendered payload — skip when hash matches |
| Driver shape | per-software seat ops (grant/revoke) | per-system field projection (upsert/deactivate/delete) |

The two were intentionally not merged: a "give Alice the Zoom seat" task and "Alice's department in Navan should reflect HRIS" are different problems. Modeling continuous projection as a queue would explode the row count and lose the "current state" view admins want; modeling grants as projections would mean every Zoom seat write becomes a sweep.

### Tables (migration 121 + 202 + 213)

| Table | Purpose |
|---|---|
| `identity_sync_targets` | one row per outbound system instance — name, slug, kind, integration_id FK, enabled, on_offboard ∈ {deactivate,delete,keep}, **write_mode ∈ {create_update,create_only,update_only}** (migration 371 — the sync-template matrix operation dimension), mapping_config JSON, filter_config JSON, schedule_config JSON, runner_options JSON, last_run_*, next_run_at |
| `identity_sync_state` | per-(target, person) projection state — external_id, last_payload_hash (sha256), last_status ∈ {pending, success, skipped, failed, deactivated, deleted, excluded}, last_synced_at, last_error, retry_count, **auto_included_reason / notified_at** (migration 202) |
| `identity_sync_department_state` (migration 214, added 2026-05-29) | per-(target, dept_code) projection state for dept-keyed targets (Feishu Contact dept). Same idempotency shape (sha256 payload hash + external_id + last_status + retry_count) but keyed on `dept_code = identity_org_nodes.org_node_source_id`. `external_id` holds the Feishu-side opaque id (auto-generated `od-XXX` open_department_id, captured at adopt-by-name time) — Helix does NOT pre-pick it. Status vocabulary adds `soft_deleted` (dept-only state — user table never uses it). |
| `identity_sync_runs` | per-target run history — manual / scheduler / event sources all unified here so the admin doesn't look in three tables. Runs for dept-keyed targets reuse the same table; `persons_in_scope` is repurposed as "depts_in_scope" via the shared `RunSummary` shape. |
| `identity_sync_run_items` (migration 273, added 2026-06-11) | per-run ledger — ONE row per vendor-touching operation (person upsert / deactivate / delete / excluded-transition, dept upsert / 失效, SCIM group write) with `item_type ∈ {person, department, group}`, the outcome (`created/updated/skipped/failed/deactivated/deleted/excluded/soft_deleted`), the rendered `request_payload` exactly as sent, the runner's structured `response_detail`, scrubbed `error`, and `duration_ms`. Volume control: cron sweeps do NOT record hash-skip rows; manual/event runs do (`record_skipped` flag from trigger_source). Retention: `identity_sync.run_items_cleanup` cron (daily 04:30 UTC, `config.retention_days` default 90). HTTP: `GET /identity-sync/runs/{id}/items` (per-run drill-down) + `GET /identity-sync/items?person_id=&target_id=` (cross-run person history; at least one filter required). MCP twin `list_identity_sync_run_items`. The single-person push response also echoes `request_payload`/`response_detail` inline — the person-by-person cutover verification surface. |

`identity_persons.cost_center` (migration 122) — added so SF EmpJob.costCenter can populate it; rendered into mappings as `{cost_center}`.

### 监控看板 — time-windowed analytics (added 2026-07-01, Authing-parity)

The 总览 tab (`/overview`) answers *current posture* (one health card per target). The **监控看板** tab answers *trend over time* — the Helix equivalent of Authing 身份自动化's 监控看板, but computed from the run ledger Helix already records (exact, not sampled). Read-only aggregation endpoint `GET /api/v1/identity-sync/metrics?window=today|7d|30d|90d[&target_id=]` ([`IdentitySyncService.metrics`](../../server/app/domains/identity_sync/service.py)) rolls up `identity_sync_runs` + `identity_sync_run_items` over a window anchored to `Asia/Shanghai` day boundaries: KPI strip (runs / success-rate=clean-terminal-share·running excluded / failed runs / failed people / avg+p95 duration / created·updated·deactivated person totals), daily status+volume trend, per-target breakdown, trigger-source mix (`manual/scheduled/event`), the upstream→downstream **data-volume overview** (golden-record headcount + active org nodes vs. covered persons + vendor records — Authing's 同步组织目录数据概览), and recent failed runs + failed items. Runs are fetched and bucketed in Python (bounded volume; sidesteps MySQL `DATE()`-tz traps — naive DATETIME treated as UTC before tz-convert). MCP twin `get_identity_sync_metrics`. Frontend: [`MonitorPanel.tsx`](../../web/src/pages/identitySync/MonitorPanel.tsx) (ECharts via `reportsShared` primitives).

The **数据流向图** tab (added 2026-07-01, Authing-parity PR2) renders the hub-and-spoke identity-data-flow as Authing's 同步中心 does — **HRIS (SuccessFactors) → Helix golden record → each downstream target** — but enriched with real volumes from the same `/overview` snapshot (no new endpoint): per-node health dot + headcount/coverage metrics, edge labels (headcount on HRIS→Helix, covered count on Helix→target), and a status legend. Feishu targets still fan out through the BU + Office dimension nodes. The shared [`FlowDiagram`](../../web/src/components/FlowDiagram.tsx) component gained optional `metrics`/`healthDot`/edge-`label`/`legend` props (backward-compatible; `SsoAppsPage` reuses it unchanged).

### Kinds + runners

Two runner shapes coexist:

- **Person-keyed (`SyncRunner`)** — `upsert(person_email, payload, prior_external_id)` + `deactivate` + `delete`. Walked by `_sweep`. State persisted to `identity_sync_state`.
- **Dept-keyed (`DeptSyncRunner`)** — `upsert_dept(dept_code, payload, prior_external_id)` + `soft_delete`. Walked by `_sweep_departments`. State persisted to `identity_sync_department_state`. Frozen set `DEPT_KINDS` in `runners/__init__.py` is the authoritative dispatch switch (only `feishu_department` today).

`scim` — generic SCIM 2.0 client ([`runners/scim.py`](../../server/app/domains/identity_sync/runners/scim.py)). Bearer or Basic auth. POST-first / 409-fallback flow (we deliberately do NOT call `GET /Users?filter=` on first sync — it saves a round-trip per first-time sync AND avoids vendor-specific filter-handling quirks; per RFC 7644 §3.3 every compliant server MUST emit 409 on duplicate userName, so the fallback is well-defined). **Relink-on-409 covers BOTH write paths (fixed 2026-06-30)** — a steady-state PUT to a stored `external_id` that returns 409 "Duplicate UserName" (or 400+uniqueness) means the stored id is a stale/orphan record while the userName's real owner has a DIFFERENT id (e.g. AWS IdC auto-provisioned its own user via federation). Both the POST-409 and the PUT-409 branches now run the shared `_reconcile_by_email` (GET-by-userName → PUT the real id) and return the corrected id so `identity_sync_state` self-heals; the next sweep PUTs the right record as a no-op. Previously only POST relinked, so a stale-id PUT failed every sweep forever. Retries 429/5xx with `Retry-After` honor + exponential backoff. Rate-limit token bucket (default 60/min, configurable per target). **Terminal-state handling for deactivate/upsert** — two non-retryable vendor conditions are recognized so the run doesn't go `partial` forever:
- **User absent (added 2026-05-27)**: the goal of `deactivate` is "user not active on the target", so the PATCH treats a clean `404` AND a `400` whose SCIM `detail` says the user is absent (`_scim_says_user_absent`: matches `does not exist / not found / no such user / unknown user`) as success.
- **Email domain not allowed (added 2026-06-08, fixes the recurring Navan-prod `partial`)**: Navan's Universal SCIM tenant has an allowed-email-domain list and rejects EVERY write — create, update, and even a no-op `active=false` — to a user whose domain isn't on it, with `400 invalidValue "User email <x> does not match any allowed company domains: [pingcap.com]"`. `_scim_says_domain_not_allowed` matches this; `deactivate` treats it as terminal success (the user is outside the vendor's managed population — in the prod case `selva.ganesh@pingcap-c.com`, already `active:false` on Navan) and `upsert` returns a non-failing `skipped` with the reason preserved (`_domain_reject_skip`, wired into the PUT / POST / PUT-after-409 failure sites). **Correction of a doc/code drift**: PR #528 guessed this message was `does not exist` from a truncated (`raw[:200]`) prod log and added it to `_USER_ABSENT_MARKERS`; the guess never matched the live wording, so the run kept failing (`retry_count` climbed 9 → 22). The 2026-06-08 marker is the verified string from a live read-only prod probe — the `@pingcap-c.com` user does exist on Navan, was not a "never-provisioned contractor". Genuine 400s (rate-limit, invalid attribute) still fail loud.

`navan` — same code as `scim`, different default base_url (`https://app.navan.com/universal/scim/v2`) and label. Navan exposes Universal SCIM 2.0 under that base URL — the `api.navan.com/scim/v2` path that older docs reference returns generic 500. Default mapping covers SCIM core + enterprise extension (employeeNumber, department=BU, costCenter, manager, division=company_name) + Navan extension (region via APAC/EMEA/JP/NA value_map; companyOffice via curated location value_map). The `enterprise.manager.value` rule carries a single delegate override (added 2026-06-24): reports whose HRIS manager is `liuqi@pingcap.com` are pushed under `qiufang@pingcap.com` via `value_map` + `value_map_passthrough` (every other manager passes through verbatim).

**Alicloud CloudSSO instance (added 2026-07-09, prod config only — zero code)** — third `scim`-kind prod target `alicloud-sso` (integration `identity_sync_endpoint`/`scim_generic`, base_url `https://cloudsso-scim-cn-shanghai.aliyun.com/scim/v2`, directory `d-00ts1z0rjv32`, SCIM credential generated in the CloudSSO console). Mapping = the AWS IdC 8 rules **plus a top-level `externalId={employee_id}` rule — CloudSSO hard-requires `externalId` on POST /Users** (400 `"miss externalId for user"`, discovered on the scoped prod smoke test; AWS IdC does not). Vendor quirks verified live: bare GETs without a SCIM `Accept`/`Content-Type` media type get 415; the enterprise-extension `employeeNumber` is accepted on write but NOT persisted (reads back without it); an idempotent PUT is a server-side no-op that preserves the pre-existing `externalId` (the 552 pre-existing users carry Authing UUIDs there — harmless, matching is by `userName`=email and the stored `u-…` id); **uniqueness on BOTH `userName` AND the email attribute is case-INSENSITIVE, the `filter=userName eq` lookup is case-SENSITIVE, and neither uniqueness check excludes the resource being written** — so a case-only normalization of either attribute (vendor `Alex.Quan@…` → mapped lowercase) 409s against itself on both PUT and PATCH (`"User.UserName already exists"` / `"User.Email already exists"`), and the 409→reconcile-by-email path finds nothing. Workaround (manual, one-off, per attribute): two-step PATCH via a temp value (`x.rename-tmp@…` → final lowercase), after which normal PUTs converge. `push_groups=false` deliberately: the directory's 18 groups (ADSyncAdmins etc.) are hand-managed, not a dept tree. Nightly full reconcile 02:00 UTC like the AWS pair; `on_offboard=deactivate`.

`google_schema` — writes `customSchemas[<schema_id>]` slot via the existing [`GoogleWorkspaceDriver.patch_user_department`](../../server/app/integrations/drivers/google_workspace.py). Does NOT create / suspend / delete Google accounts (that's `google_workspace.account_provisioner`'s job). `supports_deactivate=False` — when on_offboard=deactivate, the orchestrator marks state `excluded` without an API call. **TWO live targets**: (1) `Google Workspace · 部门字段` → `Department_Information.L1..L4` from org-path levels; (2) `Google Workspace · 员工字段` → `Employee_Details` (preset `google_employee_details_schema`, seeded migration 363). The Employee_Details schema's fields drive **~40 Cloud Identity dynamic groups** — 10 office groups match an office-label string (currently the `Location` field, e.g. `Location=='SH Office'`), 3 country groups match `Country` (ISO-2, residence-based — verified 99% of current Google Country == residence not legal entity), 5 job groups match `JobFunction`. So Helix becoming the authoritative writer of this schema is a staged migration: **PR1 (migration 363) writes ONLY a new inert `Office` field** (`office_code` → 'SH Office' label via `OFFICE_CODE_TO_FEISHU_LOCATION`; no group references it yet → zero membership churn); a later step re-points the 10 office group queries `Location`→`Office` (scripted via the `cloud-identity.groups` write scope); only THEN does PR2 repoint `Location` to the FOLocation city + write `Country`(residence). JobFunction (PR3) waits on a confirmed HRIS/Authing source and is deliberately left unmapped so the runner never wipes the 5 job groups' values (the runner only writes fields present in the payload). **Schema-field add gotcha**: Google's `schemas.update` replaces the whole `fields` array — add a field by read-existing→append→write-all (sending only the new field deletes the rest).

`feishu_contact` (added 2026-05-28, restructured 2026-05-29 to be the single combined Feishu kind, extended 2026-05-29 with office dimension) — Feishu open-platform `/contact/v3` directory sync, ONE target with TWO internal phases: **Phase A** reconciles the dept tree (`FeishuDepartmentRunner` adopted from the same module, NOT a separate kind); **Phase B** is the user sweep this kind's name refers to ([`runners/feishu_contact.py`](../../server/app/domains/identity_sync/runners/feishu_contact.py)). The orchestrator (`service._run_feishu_dept_phase` then `_sweep`) chains the two within one IdentitySyncRun row so admins see ONE target, ONE enable switch, ONE cron. Phase A doesn't run on per-person event triggers; cron + manual full sweep run both phases. **TWO DEPT DIMENSIONS on every user push**: (1) **BU dimension** = HRIS BU → Feishu BU dept by name-adopt (Phase A); (2) **Office dimension** = HRIS `work_location_name` → Feishu office dept name via the target's `office_dept_name` mapping rule's value_map (defaults `OFFICE_LOCATION_TO_FEISHU_DEPT`), then resolved to od-XXX via `service._build_office_name_index` against the live Feishu catalog. The user runner appends BOTH od-XXX into `department_ids`; both slices are tracked in `_helix_external_set` so the slice-replace merge moves office assignments on relocation (Tokyo → Singapore drops od-tokyo, adds od-sg) while preserving non-Helix Authing/admin depts verbatim. Office mapping is admin-extensible per target via the value_map. **Not SCIM** — distinct auth (`tenant_access_token` via cached `FeishuApprovalClient`), distinct field shape, Feishu's 4-ID world (open_id/union_id/user_id/employee_id). `external_id` is the Feishu `user_id`. **Account lookup chain** (current, post-2026-07-14): `prior_external_id` → `batch_get_id` by **email** → `batch_get_id` by **mobile** (globally unique; catches an existing account whose registered email ≠ work_email, then PATCHes `email=work_email` to self-correct) → **employee_no directory index** (last resort: Feishu has NO query-by-employee_no — `user_id_type` rejects it 99992402 — so `_employee_no_index` lazily enumerates the whole tenant once per sweep via `fetch_feishu_directory` and matches the HRIS natural key, closing the residual dup gap for a no-mobile + mismatched-email person) → **POST** only on genuine miss. employee_no is stamped into the POST body but was never a *query* key; the index makes it a *match* key. **`department_ids` slice-replace merge** — PingCAP's Feishu carries multi-dept assignments where Helix manages the HRIS-BU slice (Engineering Group, HR, …) and Authing/admin manages the geographic slice (Tokyo Office, SG Office, SV Office, MY Office, etc.). Runner GETs the user's current `department_ids` before PATCH, replaces ONLY the Helix-managed slice (identified by membership in `helix_dept_to_external` values from `identity_sync_department_state`), keeps everything else verbatim. GET failure → omit `department_ids` slot from PATCH (Feishu leaves it untouched). **`custom_attrs` catalog translation** — runner build calls `prime_custom_attrs()` reading `/contact/v3/custom_attrs` and building `normalised_name → {id: C-XXX, type}` (the catalog's `key` field is NULL on PingCAP; `i18n_name` locale values are the human-readable key). Payload's `custom_attrs.<normalized_name>` dict gets translated to Feishu wire format `[{type, id, value: {text}}]`. Schemas not in the catalog are silently dropped (info-log) — admin creates the missing schema in Feishu admin console + next sync picks it up, zero ops overhead. The TEXT path drops non-TEXT schemas. **HRBP push (added 2026-06-16)**: HRBP is a Feishu **GENERIC_USER** custom attribute (verified live — schema type `GENERIC_USER`, wire `value: {generic_user: {id: <user_id>, type: 1}}`), so it can't go through the TEXT translation. Resolved separately: `hrbp_email` render-context field (from `IdentityPerson.hris_hrbp_person_id`, populated by the SF sync from `EmpJobRelationships` 2448) → preset rule `hrbp_user_id_email` → runner resolves email→Feishu user_id (same resolver + systemic-vs-transient policy as the leader) → `_build_hrbp_custom_attr` looks up the HRBP schema id from the live catalog by its `HRBP` i18n_name (no hardcoded `C-...` id) and appends the GENERIC_USER entry alongside the TEXT attrs. Omitted (Feishu keeps its existing value) when no HRBP in SF / unresolvable / catalog probe failed / tenant lacks the GENERIC_USER schema. NOTE: the read-side `feishu_hrbp.py` (leaver-SLA) used to read only `value.user.user_id` while the live shape is `value.generic_user.id` — a pre-existing read bug flagged here, now FIXED (fix(leaver) 2026-06-16): `_extract_hrbp_user_id` accepts BOTH USER (`value.user.user_id`) and GENERIC_USER (`value.generic_user.id`) shapes, and `resolve_hrbp_for_person` now prefers the SF-authoritative `hris_hrbp_person_id` column before any Feishu round-trip. Manager `?user_id_type=user_id` makes Feishu accept user_id format on `leader_user_id`. Freeze (`deactivate`) PATCHes `status.is_frozen=true`; codes 230002 (already_frozen) and 99991663/230003 (user_gone) → idempotent success. `supports_delete=False` — hard delete lives in `feishu_software._user_delete` (offboarding flow); the sync layer never escalates beyond freeze. Redundant freeze calls (offboarding + sync both fire on a leaver) are idempotent and expected.

`FeishuDepartmentRunner` (internal helper, NOT a registered kind — restructured 2026-05-29 from being a public kind to Phase-A-only) — Feishu `/contact/v3/departments` dept upsert + soft delete ([`runners/feishu_department.py`](../../server/app/domains/identity_sync/runners/feishu_department.py)). Implements `DeptSyncRunner`, NOT `SyncRunner`. **Adopt-by-name design**: Feishu auto-assigns `open_department_id` as opaque `od-XXX` strings — admins cannot pre-pick them, and PingCAP's existing 20 top-level Feishu depts (Engineering Group, HR, Legal, AI Innovation, Finance, …) already match HRIS's 13 BUs 1:1 by exact name. Runner `prime_catalog()` BFS walks `/contact/v3/departments` building `name → od-XXX` index; `upsert_dept` PATCH-by-`prior_external_id` (steady-state) → name-lookup-adopt → POST create. Adopted `od-XXX` lands in `identity_sync_department_state.external_id`. The 7 geographic-office / intern / contractor depts (Tokyo Office, SG Office, SV Office, MY Office, China Business Group, PingCAP Intern, Consultants) are NOT HRIS BUs → never enter Helix state → never soft-deleted. Topological order push: orchestrator's `_topological_sort_depts` sorts parent-before-child; `parent_department_id` resolves through `helix_dept_to_external` (from dept_state) with Feishu root `"0"` as fallback. HRIS `dept_code` is written to Feishu's admin-supplied `code` field (Authing left it NULL) for admin cross-reference, but `open_department_id` remains Feishu-controlled. Leader translation `?user_id_type=user_id`. **No hard delete** — `soft_delete` PATCHes `status.is_deleted=true`; admins delete dead depts manually in Feishu after re-org. Sweep brake: `_sweep_departments` refuses to soft-delete more than 10 depts in one tick.

**SCIM group push (`push_groups`, added 2026-06-10)** — `scim`/`navan` targets with `runner_options.push_groups=true` run a group phase AFTER the user sweep (`service._run_scim_group_phase`): one SCIM Group per in-scope HRIS dept (externalId=dept code matched first, displayName fallback), members = the dept's DIRECT in-scope members' SCIM user ids from the same target's `identity_sync_state` rows, replace-all writes with a member-set hash skip. **Duplicate-displayName disambiguation (2026-06-30, was: fail loudly)** — real org trees legitimately repeat a leaf name across business groups (Sales / PreSales / Professional Services each live under several BGs), but AWS IdC group displayName is a FLAT unique namespace that can't hold two "Sales". So a colliding name is qualified by its **immediate parent** ("Sales (Japan Business Group)" vs "Sales (EMEA)"); a two-pass resolve guarantees the qualified set is itself globally unique (same name AND parent → append the HRIS dept code). Non-colliding groups keep their plain name. ⚠️ An existing single unqualified group that already carries AWS permission-set assignments becomes orphaned when the qualified groups are created — the admin must re-bind permission sets to the new group(s). Out-of-scope groups are NEVER deleted (AWS permission-set assignments hang off them) — state flips to `excluded` for manual retirement. AWS IdC quirks honored: GET /Groups omits members (hence replace-all), no PUT, 409 duplicate-add = idempotent success, PATCH 404 = `group_absent` sentinel that clears the stale id for next-sweep recreation. The hire-event single-person path best-effort PATCHes the new hire into their dept group (nightly sweep reconciles). Preset `aws_identity_center` ships the IdC-required field mapping + `push_groups=true`; the AWS SSO and AWS AI IdC instances = two `identity_sync_endpoint` integration rows + two targets. Group state rides `identity_sync_department_state` (dept_code → group id).

`huilianyi` (added 2026-06-10, ships disabled — cutover gated on dept-code alignment) — 汇联易 (Huilianyi expense) open-API push, structurally a sibling of `feishu_contact`: ONE target, TWO internal phases (`service._run_huilianyi_dept_phase` then `_sweep`), chained in one IdentitySyncRun row. **Simpler than Feishu in one decisive way**: 汇联易 keys departments by admin-supplied `custDeptNumber` — Helix's HRIS dept code IS the vendor key, so there is no opaque-id adoption catalog, no `prime_catalog`, no name-matching. Phase A walks active `IdentityOrgNode` rows (scoped by `_compute_inscope_dept_ids`, topo-sorted, hash-skipped, batch-committed) through `HuilianyiDepartmentRunner`: POST `/api/open/department/v2` → on 120107 (code exists) falls back to PUT; PUT-first when `prior_external_id` set → on 120110 (code gone) recreates via POST; out-of-scope depts disabled via `DELETE /api/open/department/delete/v2/{code}` (logical 失效; 120102 = has children/members → fail with operator hint; brake at 10/sweep). Parents outside the sweep scope are None'd out of the payload. **PingCAP root re-parent (added 2026-06-16)**: SF has NO "PingCAP" department — it models 13 top-level BUs (Engineering Group / Business Operations / …) with "PingCAP" only as the `cust_L0` company label, so Helix's `IdentityOrgNode` tree has those BUs as roots (parent=None). The 汇联易 tenant DOES have a real top-of-tree dept `custDeptNumber=RootOrg` named "PingCAP" (verified live). So a Helix-root BU (no in-scope parent) is re-parented under that vendor root code instead of being pushed parent=None — keeping the vendor tree single-rooted and mirroring the legacy structure the 费控 config hangs off of. Code = `HUILIANYI_DEFAULT_ROOT_DEPT_CODE="RootOrg"`, overridable via `runner_options.huilianyi_root_dept_code` (empty string opts out → BUs stay vendor-top-level). **Reference-only**: Helix never creates/updates the RootOrg node itself (it pre-exists; an update could blank its config), and a self-parent guard skips the case where the root code is itself a synced node. The re-parented value is folded into the payload hash so the 13 BUs re-push once under PingCAP. **Dept enrichment (added 2026-06-15, four-field completion 2026-06-16)**: beyond code/name/parent the dept push carries four 费控 attributes, all derived by `service._huilianyi_dept_attributes` (a `HuilianyiDeptAttrs` NamedTuple) from the HRIS golden record, folded into the Phase A payload hash (value change re-pushes), and OMITTED when unresolved (vendor keeps its existing value — never blanks): **部门负责人 / HRBP / Budget Owner** → `departmentPositionList` `[{positionCode, employeeId}]` positionCodes **6103 / 6105 / 6102** (read live from a real dept's `departmentPositionDTOList` 2026-06-16); **成本中心** → `customFormValues[{fieldCode:"CC"}]` (FULL label via `HUILIANYI_COST_CENTER_LABEL_MAP`; vendor wants `C0906 BO-Business Operation`, Helix/SF stores only `C0906`; 67-entry map, in-use codes 100% covered). **The leader goes via the 6103 ROLE, NOT top-level `managerEmployeeId`** — real depts carry the leader in departmentPositionList (managerEmployeeNumber null), so the role list is the contract the vendor's UI/Authing actually use; an earlier managerEmployeeId attempt silently no-op'd. Derivation: leader = the DIRECT member whose manager reports OUTSIDE the dept, tie-broken toward whoever manages the most people in the dept's **SUBTREE** (flat/headless → blank); HRBP = the member-shared `hris_hrbp_person_id` (subtree plurality, per-BU like cost center); **Budget Owner = the head of the dept's top-level BG (2026-09-04 — it is NOT `hris_budget_owner_person_id`, and not the dept's own head either; see below)**. Those two columns (migration 291, plain CHAR(36), NO FK — dodges TiDB 3780) are populated by the **SF sync** from `EmpJobRelationships` (工作关系): relationshipType **2448=HRBP**, **2447=调薪负责人** (sf-cn-prod verified live 2026-06-16; `SuccessFactorsClient.list_job_relationships`, **double fail-safe** — a missing/denied EmpJobRelationships entity never breaks the core EmpJob roster sync). **Resolution path (corrected 2026-06-16)**: the live admin/cron sync runs through the `hr` domain `SuccessFactorsLifecycleService.confirm_batch`, which after its per-event loop runs a **whole-roster tail pass** — `successfactors_sync.apply_hris_role_relationships` over EVERY active SF person (mirroring the manager-backfill pass right above it, best-effort + own transaction). Relationship data is org-wide and independent of which person changed in the batch, so a per-event scope would leave unchanged members' columns NULL forever. The same shared resolver is ALSO called by the dormant full-roster importer (`SuccessFactorsSyncService.sync_users` → `_resolve_hris_roles`) so the two paths can't drift. **Earlier bug**: the resolution originally lived ONLY in the dormant `sync_users` path (which nothing in prod calls), so the columns stayed NULL after every real admin sync and the 汇联易 HRBP/Budget-Owner roles never populated — fixed by extracting the shared helper and wiring it into confirm_batch. **Apply-only, never clear-on-absence (2026-06-16 drain-bug fix)**: `apply_hris_role_relationships` writes ONLY the relationship types the pull actually returned for a person and iterates the pull (not the roster) — a person (or a relationship type) absent from the pull is LEFT UNTOUCHED, and an empty pull is a no-op. The first version cleared every column not present in the pull; on the whole-roster pass a partial / intermittently-empty SF `EmpJobRelationships` feed therefore wiped HRBP/Budget-Owner for hundreds of people (prod: 382 → 2 populated over a few syncs, surfacing as empty 汇联易 Budget Owner). Apply-only converges to fully-populated as good pulls land and can never wipe on a bad one; a relUserId present-in-pull but no longer resolving to a live person still clears that one pointer (resolution miss ≠ absence). **Budget Owner (6102) = the 一级 BG's head, not SF 2447 and not the dept's own head (2026-09-04, two rounds)**: 6102 originally carried the member-plurality `hris_budget_owner_person_id` (SF 调薪负责人 2447) — a **BU-level comp-adjustment approver, not a budget holder** (prod read-only: the 91 depts with active members resolved to only **23 distinct** 2447 holders, Mark Xu on 15 depts and Yu Dong on 13; **49 of 91** carried someone who was not their own head). Reported case: `Intelligent Support Enablement` showed Yu Dong P0619 where 汇联易 expected 朱明 P0809 — all five members, the head included, carry 2447=Yu Dong, so the plurality could never reach the head. A first fix derived 6102 from each dept's OWN head; that surfaced the real shape problem — `Product` got 刘奇 P0001 instead of its head Liya Du P01120, because Product's ONLY member is Liya Du and all 8 of her reports sit in CHILD depts, so the "manages ≥1 member of this dept" guard scored her 0. Same shape on `HR` (唐小丽 alone on the node, 8 reports in HR Operations / HRBP / C&B / Talent Acquisition), `APAC Business Group`, `Central Enabling Group`, `EMEA Business Group` — which is also why 汇联易 held NO 部门负责人 for several of those BGs (Helix never pushed one, and 北森 never filled it). **Two changes**: (1) `_leader_emp_no` counts a candidate's reports over the dept's **SUBTREE** instead of the node — a leaf's subtree is itself, so leaf depts are untouched by construction; prod dry-run resolves 5 more depts (61 → 66 of 96) and changes no existing answer. (2) `_budget_owner_emp_no` = `_leader_emp_no(BG)` where BG = the dept's top-level ancestor (`_bg_code`), falling back to the BG subtree's most-common external manager for a BG with no internal management at all (`AI Innovation`, `Strategic Consultant` → 刘奇 P0001, HR-confirmed). HR/finance confirmed all 14 BG heads and explicitly accepted that Intelligent Support Enablement moves from 朱明 to Engineering Group's Mark Xu. Prod dry-run after the change: **all 91 depts resolve, 12 distinct owners** (Mark Xu×30, 唐刘×14, Ben Yang×8, Liya Du×7, …); 74 depts change 6102, and 6103 changes on exactly 4 (HR / APAC BG / Central Enabling Group filled from blank, EMEA BG Ben Yang → Lijia Xu, HR-confirmed). `hris_budget_owner_person_id` is still populated by the SF sync and still surfaced on the person profile / HR-relations card; it just no longer feeds this vendor role. **Membership = the dept's FULL active roster**: `_huilianyi_dept_attributes` clears this run's `person_ids`/`departments` scope before deriving, so a single-person test / dept-scoped sweep still gives ancestor depts their own real leader/cost-center/roles (a prior version scoped to the run's people and left ancestors blank). A consecutive-failure circuit breaker (5) aborts the phase on systemic failures (dead gateway / revoked credential) — the 汇联易 runner converts vendor errors to failed outcomes so there is no `is_systemic_feishu_failure` equivalent to raise. Phase B (`HuilianyiRunner`): **lookup-first** `GET /api/open/user/detail/{employeeID}` (by-工号 endpoint; `/api/open/user/{oid}` is the by-OID sibling — do not confuse) → exists ⇒ PUT `/api/open/user/v2` (120207 mid-flight ⇒ POST recreate) / absent ⇒ POST `/api/open/user/create/v2` (duplicate-message collision ⇒ PUT converge). A vendor row already in 离职 state (status 1003) is NEVER auto-rehired — surfaced as failed with the 离职员工恢复 manual step. `deactivate` = `DELETE /api/open/user/delete/v2/{employeeID}/` (= 停用, matches vendor semantics; 120207 ⇒ idempotent success); `supports_delete=False` (汇联易 has no hard delete). `external_id` = the vendor-unique `employeeID` (工号, P-shaped) — every endpoint addresses by it, so deactivate needs no lookup. Mutation success = HTTP 2xx + `errorCode "0000"`. `HuilianyiClient`: per-sweep token cache (refresh 60s pre-expiry + once on 401), 429/5xx bounded retries with Retry-After honor, 60/min token bucket, trust_env=False. Preset maps employeeID/fullName/email/custDeptNumber/companyCode + the `^P\d+$` employee_no gate. **Read-only cutover reconciliation** (`GET /targets/{id}/dry-run-huilianyi`, FE 「切换前对账」 panel, MCP `dry_run_identity_sync_target` dispatch): pulls the full vendor roster (增量查询员工/部门, full-history window, bare-array pagination with natural-key dedup; partial-page failure raises — a truncated roster would misread as "safe to create") and classifies the cutover risk classes: email-uniqueness conflicts, vendor-resigned rows, unmapped companyCode, vendor-only actives (the disable blind spot to clear before killing the Authing pull), and the legacy-tree gauge (`untouched_vendor_count` → ~0 once the in-place dept recode is done). **Cutover blockers documented in the preset description**: 汇联易's existing custDeptNumber values are Authing/北森-era short codes (Finance=12) vs Helix SF codes (Finance=1809819) — enabling the target grows a parallel SF-coded dept tree and migrates employees to it (correct end-state per the no-Beisen/no-Authing direction, but dept-bound 费控 config — budgets/approval matrices/cost-center extension fields — does NOT follow automatically); companyCode needs a value_map (汇联易 numeric 001/00101 codes vs Helix DP00001/company_name). **In-place recode (added 2026-06-16, mechanism only — map default empty)**: the chosen cutover is to UPDATE the existing legacy depts in place rather than grow an SF-coded parallel. `runner_options.huilianyi_dept_code_map` ({Helix SF code → existing 汇联易 custDeptNumber}) is applied at the runner: `HuilianyiDepartmentRunner` translates both the dept's own code and its parent (upsert_dept + soft_delete URL); `HuilianyiRunner` translates the user's `custDeptNumber`. Empty map = passthrough (current behaviour). The map is produced by `IdentitySyncService.generate_huilianyi_dept_code_map` (READ-ONLY): matches Helix `org_path` ↔ vendor `departmentPath` by FULL normalized name-path (SF and Authing share no code, and names repeat across BUs — Sales/Security/PreSales — so name-only is unsafe; the full path is unique), EXCLUDING Helix's own SF-coded parallels from the vendor index (else a recoded dept self-collides on path). Live prod (read-only): 121 Helix depts → 119 path-mapped 1:1, 0 ambiguous, 2 unmatched (genuinely-new depts → created fresh with SF code). **Why the re-parent + 120101 history**: before in-place recode, the PingCAP root re-parent moved SF-coded BUs under RootOrg where same-named legacy Authing BUs already sit → sibling-name collision 120101 (汇联易 enforces per-parent name uniqueness). In-place recode resolves this — the SF BU maps to the legacy code (already under RootOrg) and UPDATEs it. **Cutover steps (operational, not auto)**: generate map → human review → write to `runner_options.huilianyi_dept_code_map` → reset the target's dept/user state hashes to force a full re-push (in-place update) → clean up the SF-coded parallels Helix already created.

`netsuite` (added 2026-06-23) — Oracle NetSuite REST record API push, **single-phase** (employees only; NetSuite subsidiaries/departments are NetSuite-managed reference data, not Helix-projected). Driver = catalog + `test_connection`; runner [`runners/netsuite.py`](../../server/app/domains/identity_sync/runners/netsuite.py). **Auth = Token-Based Authentication (OAuth 1.0a / HMAC-SHA256)** — unlike the SCIM/汇联易 token-exchange clients, `NetSuiteClient` has NO token cache: it signs EVERY request from the four static credentials (Consumer Key/Secret + Token ID/Secret), `realm` = Account ID, host derived from the Account ID and format-validated (`^[A-Za-z0-9_-]+$`) so it can't inject a non-`suitetalk.api.netsuite.com` host (no admin-entered base_url to SSRF-guard). **Idempotency = external id**: `externalId` is mapped from the HRIS 工号 and the runner addresses the record by `employee/eid:{工号}` — `PUT eid:` is NetSuite's documented UPSERT (create-if-absent / update-if-present), so no lookup-first round-trip; a 2xx returns `Location: …/employee/{internalId}` which the runner parses for `state.external_id` (falls back to `eid:{工号}`, also a valid PATCH/GET path segment). `deactivate` = `PATCH …{"isInactive": true}` (离职 = 停用; 404 = idempotent success; no prior external id = no-op success); `supports_delete=False` (NetSuite rejects deleting an employee with transaction history anyway). **OneWorld**: `subsidiary` is required on employee create — the preset maps `subsidiary.id` from `company_code` via `NETSUITE_SUBSIDIARY_ID_MAP` (starts EMPTY — fill from the account's subsidiary internal ids), and when that map misses the runner injects `runner_options.netsuite_default_subsidiary_id` so a single-person test push succeeds before the full map is filled. Preset `netsuite` maps externalId/firstName/lastName/email/subsidiary.id/title/hireDate/mobilePhone + the `^P\d+$` employee_no gate; ships disabled (admin enables target + integration after a single-person test). **NetSuite setup note**: the access token MUST sit on a dedicated role with REST Web Services + Log in using Access Tokens + employee-record permissions — NetSuite forbids the Administrator role with TBA.

`zoom` (added 2026-07-24, seeded disabled — migration **428** seeds target `zoom-account-lifecycle`) — the **account-lifecycle** half of the Zoom integration: give every in-scope employee a baseline Zoom account on hire, close it on leave. **Single-phase (users only).** Runner [`runners/zoom.py`](../../server/app/domains/identity_sync/runners/zoom.py) delegates ALL HTTP to the existing seat-flow driver `ZoomSoftwareDriver` (same `zoom-primary` Server-to-Server OAuth app / process-global token cache — no new integration, no new credentials), so there is exactly one place that talks to Zoom's API. `_build_runner`'s `zoom` branch resolves the `provider='zoom'` integration and instantiates the driver + `ZoomRunner(plan_type=runner_options.zoom_plan_type or "no_meetings", max_creates_per_run=runner_options.zoom_max_creates_per_run or 25)`. **This is deliberately SEPARATE from the seat/license flow** (`grant_seat`/`revoke_seat` + `zoom_seat_coordinator` + `zoom.seat_sync` — paid Licensed seat on approved request, LRU eviction): the two compose without fighting. `upsert` does GET-first → **create the baseline tier when the user is missing**; when the user already exists it is a **no-op that NEVER touches the tier** (a Licensed seat granted by the seat flow survives a sweep) except to **reactivate** a previously-deactivated (rejoiner) account; display name is not re-pushed for existing users (Zoom syncs it from SSO). `deactivate` = new driver primitive `set_user_status(activate=False)` → `PUT /users/{id}/status {action:deactivate}` (离职 = 停用 — keeps the user in the tenant so SSO can't silently re-create them, retains recordings/meetings, frees any paid seat, and works on managed-domain tenants where `disassociate` is refused with 1107); 404 = idempotent success; no prior external id = no-op success. `supports_delete=False` — content-preserving hard delete with successor transfer stays the seat flow's `revoke_seat action=delete` job, and `_validate_on_offboard` rejects a `zoom` target configured `on_offboard=delete`. Preset `zoom` maps email/first_name/last_name + the `^P\d+$` employee_no gate, `filter_config=_DEFAULT_FILTER` (baseline for every real employee — the tier costs nothing so the scope is intentionally wide), `on_offboard=deactivate`, `write_mode=create_update`.

> **⚠️ The baseline tier is `no_meetings` (Zoom `type=4`), NOT Basic (`type=1`) — corrected 2026-07-30 after the create path shipped one account in a month.** Basic *looks* like the free tier and is in fact **capped per tenant** (20 here); the 21st create is refused with `400 / code 3412`. The first real create sweep (2026-07-24) therefore produced 1 success + 16 cap refusals, the target was set to `write_mode=update_only` to stop the noise, and all 411 in-scope people were skipped until a new hire reported having no Zoom account. `type=4` (a Zoom Workplace account with no Meetings license — the shape Zoom's 2024 permissions-based relicensing leaves users in) is uncapped and is where 397 of the tenant's 612 users already sit, so it, not Basic, is what "a normal employee's baseline account" means. `_TYPE_BY_PLAN` gained `no_meetings: 4` (this also fixes the vendor-snapshot tier column, which used to render a bare `"4"` because the reverse map had no entry) and `_TYPE_RANK` ranks `4` alongside `none` at 0, so the seat flow's never-silently-demote guard still refuses Licensed→4. An unknown `zoom_plan_type` no longer falls back to Basic: `create_user` returns a config error and `ZoomRunner.__init__` rejects it at build time, because a typo silently landing everyone on the one capped tier is how this failure reproduces.
>
> **Capacity refusals are `blocked`, not `failed`, and trip a per-run breaker.** `_classify_zoom_user_error` recognises the whole `_ZOOM_CODES_BASIC_CAP = {3412, 3413}` set (it previously matched only 3413 for create-time cap, while real Zoom returns **3412** — so every genuine refusal came back untagged, hintless, and retry-worthy). The runner maps a cap refusal to `blocked` + `blocked_reason=zoom_basic_pool_full` + the live Basic headcount, and sets an instance flag so the remaining unlinked people in that sweep are blocked **without** a vendor call (07-24 issued 16 identical doomed POSTs, each also triggering a full user enumeration for error enrichment). Both create call sites — seat-flow `grant_seat` and identity-sync `create_user` — now share `ZoomSoftwareDriver.build_create_failure_result`, and `zoom_software.is_basic_cap_error` is the single detection predicate (the seat coordinator's `_result_has_basic_cap_tag` delegates to it).
>
> **Both knobs were unreachable until 2026-07-30.** `_validate_runner_options` had no `zoom` branch, so it fell through to the terminal `unsupported kind 'zoom'` 400 — `POST /targets` could never create a zoom target (the shipped `zoom` preset was unusable through the API; the one prod target came from migration 428's raw seed) and `PATCH .../targets/{id}` could never edit one, which is why the wrong tier could not be corrected from the UI by anyone who knew it was wrong. The branch now validates `zoom_plan_type` against the driver's own `_TYPE_BY_PLAN` (unknown tier ⇒ 400 **at save time**, naming the valid set and warning that `basic` is capped) and bounds `zoom_max_creates_per_run` to 0–1000 (0 = no ceiling), rejecting unknown keys like the scim/navan branch rather than swallowing a typo.
>
> **`zoom_max_creates_per_run` (default 25) rate-limits backlog creation.** A create makes Zoom email the person an invitation, so opening the create gate on a ~400-person backlog would fan out hundreds of invitations in one tick. Excess people are `blocked` with `zoom_create_budget_exhausted` and picked up by the next sweep. A 409 create race does **not** consume budget (nothing was created, nobody was emailed), and maintenance of existing users (rejoiner reactivation / no-op) is never budget-gated. **Replaces the decommissioned Authing → Zoom account-provisioning link** (Authing deprecated 2026-05-07): Helix now directly owns Zoom create-on-hire / deactivate-on-leave; **Zoom login is untouched — it continues via Google SSO** (this target never modifies Zoom's SAML/SSO config). Also added `PUT` support to the driver's shared `_zoom_request` helper (was GET/POST/DELETE/PATCH only). Seeded `enabled=0`: the first sweep would create a baseline account for every in-scope employee (~400) — free, but externally visible (Zoom emails each person an invitation), so the admin arms it deliberately in the UI (and can narrow the filter first); since 2026-07-30 `zoom_max_creates_per_run` additionally spreads that first wave over several sweeps rather than one tick. **Leaver seat reclaim**: a `_collect_zoom_records` vendor-snapshot collector (`ZoomRunner.list_users`) plugs Zoom into the existing snapshot + `leaver_revocation_reconcile` machinery — so departed employees still holding a (paid) Zoom seat are caught and **deactivated** (frees the license; deactivate ≠ delete), by the vendor's own key, with the same safeguards as every other target (`left`-only, retention-registry exemption, snapshot-post-dates-departure, per-row commit). This works via `revoke_leavers_on_target` (has a `dry_run` preview) **regardless of the target's enabled flag**, so the historical backlog can be cleaned WITHOUT arming the create-on-hire sweep. Tests: [`test_zoom_runner.py`](../../server/tests/test_identity_sync/test_zoom_runner.py).

**`feishu_contact` production-grade hardening (2026-06-08, pre-enable; target stays `enabled=false` until manual flip).** A read-only prod audit (decrypt feishu creds from SSM `/helix/prod/CREDENTIAL_ENCRYPTION_KEY`, batch_get_id over all active people, full dept-tree walk) established the real duplicate-risk picture and drove three changes: **(1) Phase-A dept scoping** — `service._compute_inscope_dept_ids` now projects ONLY depts whose subtree holds ≥1 in-scope (post-filter) member, expanded up the ancestor chain; `_run_feishu_dept_phase` filters `sorted_nodes` to that set and counts the rest as `dept_skipped` (new `RunSummary` counter). This kills the 10 empty English shell-depts the audit found, which collide semantically with existing Chinese-named Feishu depts (e.g. HRIS `Content Marketing` vs Feishu `内容营销中心`) — Feishu `code` is 100% empty so name is the only adopt key and a shell POST would dup. **(2) Non-employee gate** — `FilterConfig.employee_no_pattern` (regex, validated at save; the feishu preset sets `^P\d+$`) is an ABSOLUTE exclusion that runs before `person_ids` and the and/or pool, keeping service accounts (`TimeAdmin`/`LifecyleAdmin`, employee_no not P-shaped) out. `CandidatePerson` gained `employee_no`. **(3) Pre-sync preview** — `service.dry_run_feishu` + `GET /targets/{id}/dry-run-feishu` + MCP `dry_run_identity_sync_target` (read-only) return the full plan: users (would-create / would-update / excluded-non-employee, classified by LIVE batch_get_id) + departments (would-create / would-adopt-by-name / skipped-empty / soft-delete, classified by a live read-only catalog prime) + warnings (soft-delete brake). Frontend: a feishu-only "同步前预览" `FeishuPlanModal` + dept counters surfaced in the Runs table (they were buried in `result_summary` JSON). **Audit also corrected a doc/code drift**: the "lookup-first by employee_no" claim above is aspirational — the runner's `upsert` dedups by EMAIL only (`resolve_user_id_by_email` → batch_get_id + a strict `user.email==work_email` post-filter); employee_no is stamped on POST but Feishu's batch_get_id takes only emails/mobiles, so there is no employee_no lookup path. Prod verified 424/432 match clean by email, 0 hard-dups → user duplication risk ~0.

### Vendor snapshot — 对端数据 (added 2026-06-11, migration 274)

`identity_sync_vendor_records` mirrors what the downstream system holds RIGHT NOW: one row per vendor-side user / department / group (unique on (target, record_type, external_key)), normalized display_name / email / is_active + the trimmed raw vendor record for the detail view. Refreshed via `POST /identity-sync/targets/{id}/vendor-snapshot/refresh` (admin; per-target lock shared with the dispatcher; **read-only on the vendor side**; works on disabled targets — "look before you flip") which wipe-and-replaces the kind's record types and matches every user row against `identity_persons` (email → employee_no fallback); `matched_person_id IS NULL` = the vendor-only population to clear before any cutover. Collectors per kind: huilianyi (full-history 增量查询 helpers), scim/navan (`ScimRunner.list_users/list_groups`, RFC 7644 startIndex paging, fail-loud on non-200 pages **and on a server that proves it ignores `startIndex` — `totalResults` is not trusted on a full page, see the 2026-08-14 callout; AWS IdC cannot be enumerated over SCIM at all**, /Groups absence degrades to a warning), feishu_contact (`fetch_feishu_directory` — dept BFS + per-dept find_by_department, raw trimmed of mobile/avatar PII; the two read GETs go through `_request_directory_page` which does **bounded retry-with-backoff on transient Feishu errors** — 4 attempts, ~0.5/1.5/3.5s, codes `{40003 Internal Error, 99991671/99991672 rate/frequency limit}` + `httpx.HTTPError` transport blips — because the walk is hundreds of round-trips per sweep and `FeishuApprovalClient._request` has no retry of its own; non-transient codes (auth/scope/not-found) still fail loud immediately, and the write path is untouched. Added 2026-07-18 after a single 40003 failed the whole `vendor_snapshot_refresh` job), google_schema (`list_all_users(projection="full")` for customSchemas; raw trimmed to 5 fields), zoom (`ZoomRunner.list_users` — active+pending+inactive tagged, `external_key`=Zoom user id, `vendor_status` surfaces `{status}/{tier}` so a licensed leaver is visible; added 2026-07-24). Query via `GET /targets/{id}/vendor-records` (record_type / q / matched / is_active). The daily `identity_sync.vendor_snapshot_refresh` cron runs each target's refresh through the shared `run_with_lock_retry` primitive (attempt 1 on the framework session, then fresh sessions with `innodb_lock_wait_timeout=120`): the snapshot INSERT's `matched_person_id` FK makes TiDB take a **pessimistic lock on every matched `identity_persons` row**, and the 03:00 seat-sync fleet holds those same parent rows for 130–160s via `software_seat_allocations.user_id` — on 2026-08-08 the zoom target (621 rows, first in the loop) 1205'd out at the default 50s and lost the whole day's snapshot. Any new bulk writer of an `identity_persons`-FK child table on the 03:00 tick faces the same wall. MCP twins `refresh_identity_sync_vendor_snapshot` (mutates) + `list_identity_sync_vendor_records`. FE: 「对端数据」 tab on the 数据同步 page. The snapshot also feeds the PR4 drift diff.

### Drift diff (added 2026-06-11)

`GET /identity-sync/targets/{id}/drift` (MCP twin `get_identity_sync_drift`) renders every in-scope person's mapping payload live (zero vendor calls) and compares it field-by-field against the stored vendor snapshot. Verdict semantics: `drift` ONLY on a value-vs-value mismatch; fields the vendor read API doesn't echo — or kind-untranslatable fields (feishu dept codes / custom_attrs, translated by the runner pre-wire) — are `not_comparable`, never drift (read-API shape ≠ divergence). google_schema lookups remap under `customSchemas.<schema_id>`. Person buckets: clean / drift / vendor_missing / render_failed; vendor-record location order state.external_id → matched_person → email; normalization = case-insensitive emails, scalar→str, lists as multisets (SCIM `emails[].value`). `needs_snapshot=true` when the target has no snapshot rows. FE: 「数据对比」 tab (issues-only by default, side-by-side field modal). This + the vendor snapshot's vendor-only filter give two-sided coverage: Helix-has/vendor-missing and vendor-has/Helix-missing.

### Overview cockpit (added 2026-06-11)

`GET /identity-sync/overview` (admin-read; MCP twin `identity_sync_overview`) is the 数据同步 page's landing aggregation — one round-trip returning upstream posture (people by lifecycle status, HRIS integrations health, data-quality headline via `IdentityDataQualityService.summary()`) plus one card per target: bound-integration health, per-status `identity_sync_state` / `identity_sync_department_state` grouped counts, the filter-evaluated in-scope count (candidates loaded ONCE, per-target in-memory evaluation), last/next run, and a 10-run sparkline. Pure reads, zero downstream API calls. Per-target isolation: a broken `filter_config` degrades that card (`in_scope_count=null` + `filter_error`) instead of failing the endpoint. FE: `OverviewPanel` is the default tab of `IdentitySyncCenterPage`; presets whose kind has no configured target render as dashed 未接入 ghost cards wired to the preset-create flow.

### Mapping engine ([`mapping.py`](../../server/app/domains/identity_sync/mapping.py))

Pure data transformation, no `eval`, no Jinja. Each rule:

```jsonc
{
  "source_template": "{first_name}.{last_name}",   // {token} substitution
  "transform": ["lower", "trim"],                  // chainable: lower/upper/trim/title_case/email_local/email_domain/iso_date
  "value_map": {"CHN": "APAC"},                    // STRICT — unknown source → empty render
  "value_map_passthrough": false,                  // true = OVERRIDE mode: unmapped source flows through unchanged
  "default": "fallback@example.com",               // used only when render is empty
  "target_field": "emails.0.value"                 // dot path, supports SCIM URN prefix + numeric array index
}
```

Source-field whitelist (parser raises `MappingError` on unknown tokens at save time): `work_email, employee_id, first_name, last_name, full_name, name_pinyin, display_name, job_title, country, city, manager_email, manager_name, employment_status, hire_date, termination_date, org_path, org_path_level_0..9, department_l1..l4, department_combined, business_unit, location, work_location_code, company_name, company_code, cost_center`.

`department_combined` (added 2026-05-26) is `L2 / L3 / L4` joined by `" / "` with empty segments dropped — Navan's `enterprise.department` slot wants the full sub-BU path, not the top-level BU alone.

**`value_map_passthrough`** (added 2026-06-24) flips `value_map` from STRICT translation (unmapped source → empty) to OVERRIDE semantics (unmapped source → unchanged). Only valid alongside a `value_map` (save-time `MappingError` otherwise). Use it for rules that rewrite a handful of values and must leave the rest alone — e.g. the Navan `enterprise.manager.value` rule remaps `liuqi@pingcap.com` → `qiufang@pingcap.com` (PingCAP T&E delegation) while every other manager email flows through verbatim; an empty `manager_email` still renders empty → field dropped (Navan keeps its current value). To apply a new override to an EXISTING target you must update that target's persisted `mapping_config` (the preset only seeds new targets) AND deploy the passthrough-aware code FIRST — the older STRICT-only renderer would blank every unmapped value.

Transforms: `lower / upper / trim / title_case / email_local / email_domain / iso_date / strip_company_prefix`. **`strip_company_prefix`** (added 2026-05-26) drops the leading `^\s*\d+\s*[_\-\.]\s*` prefix that PingCAP's HRIS bakes into FOCompany.name (so `01_PingCAP(US),Inc.` renders as `PingCAP(US),Inc.` in Navan's Subsidiary picker). Left-anchored, single-pass — `01_2_PingCAP` becomes `2_PingCAP` not `PingCAP`, intentionally loud-failure on malformed data.

Path semantics: `name.familyName` → nested dict; `emails.0.value` → array index (materialised as JSON list, not `{"0": ...}`); `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.x` → URN treated as one atomic segment via `:User` boundary detection so the embedded `2.0` doesn't conflict with the `.` separator. Booleans: `primary` / `active` slots auto-coerce string `"true"`/`"false"` to JSON booleans (some SCIM servers reject string types here).

**Mapping metadata catalog + config builder (added 2026-06-09)**: `mapping_metadata()` projects the source-field whitelist (grouped by category) + transform list into an authoritative catalog served at `GET /identity-sync/mapping-metadata` (`require_admin_read`). The admin `MappingEditor` renders its `transform` multi-select and per-row "insert field" picker from this catalog instead of a hard-coded FE list — the old hard-coded list had silently dropped `strip_company_prefix` (backend-supported, un-pickable in the UI), exactly the drift the catalog endpoint prevents. The mapping preview (`trace_render`) is now reachable **on the unsaved draft rules** too: `MappingPreviewDrawerBody` accepts a `draftMapping` and renders via the `POST /targets/{id}/preview` draft path (was previously only the saved-config `previewSaved`), eliminating the save→run→check→fix loop. MCP twin `preview_identity_sync_mapping(target_id, person_id)` (global_admin, read-only) gives AI clients the same per-rule render-debug.

**MappingEditor production upgrade (added 2026-07-01, Authing-parity PR3, FE-only)**: the per-rule `value_map` (and `value_map_passthrough`) — previously editable only via presets / raw JSON — now has a UI: a per-row **值映射** button (showing entry count) opens `ValueMapModal` to edit source→downstream pairs + the passthrough toggle (the modal forces `passthrough=false` when the map is empty, matching the backend `from_dict` invariant that passthrough only applies alongside a `value_map`; the FE `MappingRule` type gained `value_map_passthrough`). Round-trips through `MappingConfig.to_dict()` (which already emitted the flag). Plus **inline rule validation** (empty target / duplicate target_field / unknown `{token}` checked against the `mapping-metadata` source-field catalog → warning-state input + ⚠ tooltip) and **↑/↓ row reorder**. Sibling robustness fix shipped alongside: `PersonFilterEditor` guards `value.person_ids ?? []` (a `filter_config` missing the `person_ids` key previously white-screened the edit drawer via a `...undefined` spread).

**Sync-template matrix — write_mode (added 2026-07-01, Authing-parity PR4)**: the operation dimension of Authing's sync-template matrix, ported as the one axis meaningful for Helix (which is downstream-only, so the 上游/下游 axis doesn't apply). `identity_sync_targets.write_mode` (migration 371, `String(16)` server_default `create_update`): **create_update** (upsert everyone in scope — historical behaviour) / **create_only** (create once, never re-touch an already-linked person) / **update_only** (only maintain already-linked people, never create). Enforced centrally in `_sweep` on the per-(target,person) `identity_sync_state.external_id` link state, evaluated BEFORE any runner call (no runner internals change) and without writing `last_payload_hash` (cheap re-evaluation each sweep, no vendor call). Validated by `_validate_write_mode`; carried on `TargetPreset.write_mode` (default create_update — the mode is chosen per-target at create time rather than exploded into 3× preset rows). FE: a write-mode Select in the target edit drawer + a purple mode tag in the targets list.

Naming derivation cascade (so PingCAP's mixed Chinese-name / English-name / "张利扬 Lian Zhang" display formats yield correct SCIM `name.givenName` / `name.familyName`): email-dot-pattern (`given.family@`) → `name_pinyin` space-split → `display_name` with leading CJK token stripped → concatenated `name_pinyin` fallback → **final non-empty guard** (added 2026-05-27): if first/last are still empty after all of the above, fill from `full_name` (multi-token split; single-token records like a person whose only name is "Hice" duplicate the one token into both halves). Navan hard-rejects a create when either half is blank (`400 invalidPath "User first name and last name are mandatory"`), so the cascade now mathematically cannot emit an empty givenName/familyName — admins still fix the HRIS source, but the directory sync no longer hard-fails per degenerate record.

### Filter engine ([`app/core/person_filters.py`](../../server/app/core/person_filters.py); `identity_sync/filters.py` is a re-export shim since 2026-06-11 — the engine is shared with the identity-consumer pull surface §10.6, so it lives in core where both layers may import it)

`departments` (HRIS source-id, with `include_subtree` walking the org node tree) / `countries` (residence country) / `company_codes` (legal-entity `company_code`, the "by employing entity" lever — added 2026-07-07) / `employment_statuses` / `job_title_includes` / `email_suffixes` / `employee_no_pattern` (absolute exclusion gate, runs before the `person_ids` override) / `person_ids` (explicit allowlist that overrides every other dimension — pilot rollout pattern) / `mode: and|or`.

**`company_codes` = by-employing-entity dimension (added 2026-07-07).** Exact-match whitelist of HRIS `company_code` (e.g. `DP00001`=PingCAP (US), `DP00002`=PINGCAP PTE. LTD., `DP00004`=PingCAP K.K. (JP), `DP00003`=PingCAP Sdn. Bhd. (MY); DP00005+ are Beijing/Shanghai China entities). Distinct from `countries` (where a person *lives*): **EOR (Employer-of-Record) staff reside in a country where PingCAP has no entity but are legally employed under the US / SG entity**, so a residence-country filter wrongly drops them. `CandidatePerson.company_code` feeds it; pooled into and/or like `countries`. **Navan is scoped this way** (`_NAVAN_USER_FILTER` = the four non-China entity codes, `countries=[]`) after a residence-country filter caused 48 active EOR employees to fall out of scope on 2026-07-03 (HRIS corrected their `country` to true residence), tripping the mass-action brake. Picker: `GET /identity-sync/lookups/entities`. Add a new code here if PingCAP incorporates a new overseas entity.

**`employee_no_pattern` = non-employee gate (`^P\d+$` is the platform default since 2026-06-30, in `_DEFAULT_FILTER`).** Real HRIS employees carry a P-shaped 工号; the only non-P actives are hand-created service / admin rows (刘寅 [employee_no NULL, manually created], TimeAdmin, LifecyleAdmin) that must be ignored by **every** sync. Promoting the gate to the default means new SCIM / Navan / google_schema targets are born with it (feishu / huilianyi / netsuite presets already had it explicit). `fails_employee_no_gate(employee_no, cfg)` exposes the gate alone so the sweep can tell "non-employee" apart from a general filter miss. **A person excluded by THIS gate is always `excluded` (kept, zero vendor call), NEVER deactivated/deleted — regardless of `on_offboard`** ("not an HRIS employee" ≠ "left the company"). This is load-bearing: the SCIM/Navan targets are `on_offboard=deactivate` and these rows carry live `external_id`s, so routing them through the leaver path would disable an account we were only asked to ignore. Existing live targets need a one-time `filter_config.employee_no_pattern` data-set (the default only affects newly-created targets); that data-set must land AFTER this code deploys or old-code + new-filter = deactivation.

**HR test-record band (`P99XX`) = a HARD, GLOBAL exclusion, distinct from the per-target `employee_no_pattern` gate (2026-07-13).** HR reserves `P9900`–`P9999` for their own SuccessFactors test records (P9997/P9998/P9999 were sitting `active` in prod with fake names + personal gmail/yahoo emails). Because these are P-shaped they SAIL THROUGH the `^P\d+$` non-employee gate — so a dedicated band exclusion was needed. Single source of truth: [`person_filters.is_test_employee_no(no)`](../../server/app/core/person_filters.py) (`re.fullmatch(r"P99\d{2}")`, case/whitespace-tolerant; real 工号 are five-digit `P0XXXX`/`P1XXXX` so the four-digit band never false-matches). Wired at every sync + account-creation chokepoint so the rule lives in one place: **ingest** — `HrSyncService._stage_event` drops them for onboard/update/offboard (never enter/update/churn `identity_persons`, starving every downstream path); **outbound** — `_load_candidates` filters them (covers all targets AND the `force_in_scope` push/admin "sync this person" path that bypasses `matches()`), `matches()` hard-excludes ahead of `person_ids`, `_person_downstream_gate_reason` reports cleanly; **account creation** — `account_provisioner.create_account_for` + `reserve_work_email_for` (no SF write-back) + coordinator `_is_test_record` on all 5 provisioning hooks; **hire-day cron** — both `hire_day_service` candidate queries. Regression: `tests/test_identity_sync/test_test_record_band.py`, `tests/test_google_workspace/test_test_record_band_gate_2026_07_13.py`, `tests/test_hr/test_successfactors_lifecycle.py::test_stage_event_skips_p99xx_test_record`.

**Non-corporate work_email = a sibling HARD, GLOBAL sync exclusion (2026-07-13).** HRIS junk / test records that carry a fake personal email (gmail / qq / yahoo / 163 / …) but a P-shaped 工号 OUTSIDE the P99XX band (e.g. `P0998` "test1 test2", `P12312` "ka测试 Mo", `P09987` "test EOR") were sitting `active` + `present` in prod and had created real SCIM accounts in AWS SSO / AWS AI / Alicloud CloudSSO / 飞书通讯录. None of the four existing gates catch them — their id is P-shaped (passes `employee_no_pattern`), not in the P99XX band, and SF hadn't flagged them `2061`. The one invariant they all break: no @pingcap.com corporate identity. Single source of truth: [`person_filters.is_non_corporate_email(work_email)`](../../server/app/core/person_filters.py) — True iff `work_email` is present and its post-`@` domain (compared case-insensitively, EXACT match not endswith so `pingcap-c.com` is never admitted) is NOT in `_CORPORATE_EMAIL_DOMAINS = {pingcap.com, pingcap.com.cn}`; blank / missing `@` returns False (data-quality gap handled elsewhere). To admit a new legit corporate domain, add it to that one frozenset. Wired at the **push candidate loader only** — `_load_candidates` filters them (all targets + the `force_in_scope` bypass path), `_person_downstream_gate_reason` reports cleanly. Deliberately NOT folded into `matches()` (like the purged / 2061 gates): a hard domain gate there would override the admin-configurable `email_suffixes` filter dimension — whitelisting a non-`pingcap.com` suffix is a legitimate admin choice. **Candidate-set exclusion only — never auto-deactivates** (like the purged/2061/P99XX gates): a previously-synced out-of-scope row becomes `excluded` (kept), so the existing junk SCIM accounts stay put until a deliberate vendor-side cleanup. Prod-verified 2026-07-13: all 424 active employees are on `pingcap.com`, every non-corporate active row is test/purged/2061 junk → zero false exclusions. The durable source fix is HR flagging these `customString24=2061` (不推送) or deleting them in SF. Regression: `tests/test_identity_sync/test_non_corporate_email_gate.py`.

**SF non-employee record = a shape-based ingest exclusion, replacing an enumeration that lost three times (2026-08-18).** SuccessFactors carries `User` rows for the tenant's own admin / integration / implementation-partner accounts (`CBadmin`, `TimeAdmin`, `ECadmin1`–`4`, `COMPadmin`, `ONBadmin`, `MokaAPI`, `ITadmin`, `v4admin`, `LifecyleAdmin`, `APIadmin`). Until now the only defences were two **name/domain lists** — `HR_SKIP_USER_IDS` and `HR_SKIP_EMAIL_DOMAINS`. That is whack-a-mole and prod lost it three times: 6 rows cleaned 2026-05-07, 3 more 2026-07-13 (the name list was extended that day *because* MokaAPI / TimeAdmin / LifecyleAdmin kept being ingested as "new hires"), and on 2026-08-09 **`CBadmin` walked straight through both** — absent from the name list, and its SF email sits on the real corporate domain (`cbadmin@pingcap.com`) so the domain gate had nothing to bite on. Helix ingested it as a day-one hire and **minted it a real Google mailbox** (`google_user_id` 112758918028284756989); the same happened to `MokaAPI` on 2026-07-12 (`mokaapi@pingcap.com`, since deleted). Single source of truth: [`person_filters.is_hris_non_employee_record(hire_date=…, has_emp_job=…, employment_start_date=…)`](../../server/app/core/person_filters.py) — True iff SF carries **no EmpJob row, no `User.hireDate` AND no EmpEmployment `startDate`**, i.e. a `User` with no evidence of employment anywhere. **All three must be absent and each signal alone was empirically falsified against the live roster** (862 SF `User` rows, both tenants, 2026-08-18): `empId is null` is a **dead signal** (544/560 CN rows have no `empId`, real employees included — P0416 孟凡生, P0035 施闻轩), and *no-EmpJob alone* would have blocked **P10025 Augustine**, a genuine incoming hire (status `active_external_suite`, hireDate 2026-08-31) whose EmpJob SF Onboarding had not generated yet — denying a real employee their accounts is a worse failure than the junk row the gate exists to stop. `employment_start_date` earns its place the same way: the first cut read only `User.hireDate` and ignored the employment dict sitting beside it at the call site, which wrongly flagged **seven pre-existing `_stage_event` tests** whose rows carry a real `employment.startDate` and no `User.hireDate` — the exact shape the incremental path builds from a live EmpEmployment record, so a genuine false-positive mode rather than a fixture artifact. Including it is strictly MORE conservative and costs nothing: all 10 system accounts have **zero** EmpEmployment rows in both tenants (verified), and on the full-sync path `employment["startDate"]` is *derived from* `user.hireDate`, so it is the same signal not a new one. Together these land on exactly the 26 system accounts with **zero false exclusions**. Wired at the **onboard ingest gate only** (`_stage_event`), matching `should_skip_user`'s scope for the same two reasons: existing rows must keep receiving update/offboard events so a real termination still propagates, and unlike the P99XX band (which keys on an unambiguous id) this predicate reads the `jobs` list, which a narrower sync path could hand over incomplete. Blocking onboard suffices — a person is only ever CREATED from an onboard event. Paired fix in the same PR: `preview_full_employee_sync`'s onboard branch no longer synthesizes `startDate=/Date(now)/` for a null `hireDate` (`_upsert_person` reads `startDate` into `person.hire_date`, so the fabricated value made the row read "starts TODAY" and the hire-day cron provisioned same-day; worse, each full sync minted a fresh "today" → a new `effective_date` → a brand-new onboard event past the `(person, event_type, effective_date)` dedup key, so the account re-onboarded every run and its hire_date drifted forward). The adjacent offboard branch had already been hardened this way ("we MUST NOT synthesize `today`"); the incremental path never had the bug. Regression: [`test_sf_non_employee_ingest_gate_2026_08_18.py`](../../server/tests/test_hr/test_sf_non_employee_ingest_gate_2026_08_18.py) — fixtures are the captured live SF wire payloads, and each of the two defects is mutation-verified independently.

### State machine + idempotency

Each push computes `sha256(json.dumps(payload, sort_keys=True))`. The orchestrator skips the API call when the hash matches a prior `success`/`skipped` state row **AND the row was vendor-verified within `_DRIFT_REVERIFY_INTERVAL` (7d)** — a staler row is re-pushed even on an unchanged hash so the runner re-asserts live vendor state (drift correction; see the `last_verified_at` entry at the top of this file). Failed rows always re-attempt regardless of hash.

Out-of-scope handling — when a person previously had a state row but no longer matches the filter, the per-target `on_offboard` policy applies. Capability flags on the runner (`supports_deactivate`, `supports_delete`) gate which calls happen — unsupported ops short-circuit to `status='excluded'` with no API call. This is the production-grade contract that prevents misleading no-op success returns.

### Auto-included rows (managers of foreigners) — migration 202

For Navan-style targets that enrol a subset of employees, the sweep auto-pulls the **managers of in-scope reports** (the approvers) so the SCIM `enterprise.manager` reference resolves on the vendor side AND the manager can actually approve the report's travel. Implementation:

- Detection in `_sweep`: any in-scope person whose `manager_person_id` points to an **active** person not already in scope → manager gets a `CandidatePerson` materialised and added to `in_scope_persons` for THIS sweep. **(2026-07-07: dropped the earlier `is_china_employee` gate on both report and manager.** It was a proxy for "domestic manager of a foreign report" but the flag is set for only ~37 people company-wide, so it silently excluded real approvers — e.g. a founder on the Beijing entity, flag=False, managing a US report would have had their Navan account deactivated once the mass-action brake stopped masking it. The rule is now simply: in-scope report → its active out-of-scope manager is an approver → include.)
- `identity_sync_state.auto_included_reason='manager_of_foreign'` marks the manager's state row (reason string kept for continuity). **Out-of-scope sweep permanently skips rows with `auto_included_reason` set** — the manager's Navan account is never deactivated, even if the original report leaves / transfers / changes manager.
- **Rehydration**: every sweep ALSO re-adds existing `auto_included_reason` state rows back into `in_scope_persons`, so attribute changes (job_title, dept, manager) on the manager keep flowing to Navan after the original report tie is gone. Without this, "permanent in scope" would only mean "account stays alive" but the data would silently rot.
- **First-create notification**: on the first successful upsert (`was_new=True` + `notified_at IS NULL`), the sweep writes a `notification_outbox` row (status=pending, channels=`feishu+email`, template_key=`identity_sync.auto_included_manager`, recipient = the manager) and stamps `notified_at`. Fire-and-forget — the standard notification retry worker handles delivery. `notified_at` is set at enqueue, NOT at delivery, so transient Feishu outages don't re-fire the enqueue on later sweeps.
- Summary counters: `RunSummary.auto_included` (count of auto-included this sweep — subset of `persons_in_scope`) and `RunSummary.auto_included_notified` (subset that received their first-time notification this sweep).

### Triggers

| Trigger | Source | Notes |
|---|---|---|
| Cron | `identity_sync.dispatch` ScheduledJob (every 60s) walks `next_run_at <= now()` enabled targets | Per-target cron stored in `schedule_config.cron_expr`; APScheduler `CronTrigger.from_crontab` computes next fire time. **The cron cursor `next_run_at` is advanced by `_finalise_run` ONLY when `run_row.trigger_source == "scheduler"`** — i.e. the dispatcher's own run. Per-person / event / manual runs finalise through the same `_finalise_run` but MUST NOT touch `next_run_at`; letting them (fixed 2026-07-14) perpetually shoved the daily slot into the future on a target with hourly per-person traffic (feishu-contact + onboarding 24h-ahead provisioning), so the scheduled full sweep fired ~7×/30d and existing-employee dept/manager changes never propagated (张业祥 Forge-Pod transfer incident). A drifted `next_run_at IS NULL` on an enabled cron target self-heals on any finalise so it can't get permanently stuck un-due. |
| Event | `app.domains.identity_sync.events.enqueue_person_sync(person_id, reason)` called from `hr.confirm_batch` (via `hr_sync_coordinator.enqueue_identity_sync_for_hire` — fires immediately on HRIS batch confirm when `hire_date <= today` AND `employment_status='active'`, added 2026-05-29), `hire_day_service` (cron-based fallback for future-dated hires whose `hire_date` arrives later), and `advance_service.flip_to_left` (offboard). Fan-out across enabled `cron`/`event_only` targets concurrently. NEVER raises back to the caller — sync failures don't break onboarding/offboarding. |
| Manual full sweep | `POST /identity-sync/targets/{id}/run` from the "Sync Now" button | Goes through the **dry-run impact-confirm modal** (`GET /identity-sync/targets/{id}/dry-run` returns in-scope/out-of-scope counts + samples, zero API cost). Admin sees impact before clicking "Confirm & run". |
| Manual single-person | `POST /identity-sync/targets/{id}/sync-person/{person_id}` from the "Sync this person" button | `force_in_scope=True` — bypasses filter (admin's explicit intent overrides filter rules). |

### Locks

Per-target Redis advisory lock `helix:lock:identity_sync:target:<slug>` (TTL 900s) — manual triggers can't race with cron, and overlapping cron ticks short-circuit cleanly. The dispatcher's framework lock (`sched_job:identity_sync.dispatch`) prevents double-tick overlap on the master walker.

### Dispatch ordering (added 2026-05-28)

When multiple targets are due in the same minute tick, `run_due_targets` sorts by `_KIND_DISPATCH_PRIORITY` before iterating: `feishu_department=10 < feishu_contact=30 < google_schema=50 < scim/navan=60`. Dept targets MUST land before user targets so the Feishu user runner's `department_ids` push has the dept already present. Unknown kinds default to 100 (sort last — no accidental preemption of depts). Within the same priority, ties break by `next_run_at` then `slug`.

Per-person event hook (`events.py::enqueue_person_sync`, fired from `hire_day_service` + `advance_service`) explicitly filters out `DEPT_KINDS` — dept changes don't propagate via per-person events. The daily cron is the canonical refresh path for dept-shaped targets.

### Migration of legacy `google.dept_sync`

Migration 121 deletes the legacy `google.dept_sync` ScheduledJob row outright (cascades to its run-history) and seeds an equivalent `identity_sync_targets` row of kind=`google_schema`, slug=`google-dept`, schedule = daily 02:00 UTC, integration = primary `google_workspace`, mapping = the L1-L4 dept rules. The deprecated handler is removed entirely from `google_handlers.py` — no stub registration "for safety", because a stub returning fake-success would mask re-enablement attempts that should fail loudly.

### MCP tools

`list_identity_sync_targets`, `trigger_identity_sync_target`, `sync_person_to_identity_target`, `get_identity_sync_state_for_person`, `preview_identity_sync_target` — all admin-gated. `preview_identity_sync_target` (read-only) renders the saved mapping against one person and returns per-rule trace + final JSON + warnings — the same shape the admin UI's Mapping Preview drawer renders.

### Admin UI

`/admin/identity-sync` (top-level menu — not nested under Connectors / Config Center; it's a hub product alongside Insights / AI Spending). Tabs: Targets / Runs / State. Targets list has per-row actions: Sync Now (→ confirm modal), View Logs (→ Runs tab pre-filtered), Sync One Person (→ pickled-person modal), **Mapping** (→ field-flow preview drawer, see below), Edit, Delete.

**Mapping Preview drawer** (added 2026-05-26): per-target read-only drawer that visualises the saved mapping config field-by-field. Admin picks a person; backend renders the trace via `trace_render(mapping_cfg, ctx)` and returns each rule's `source_template → raw → after_transforms → after_value_map → rendered` along with `dropped` / `used_default` flags. The drawer renders a Helix-field-→-Transform-→-Downstream-field table with one row per rule, plus the final JSON payload and the raw context dict for debugging. Powers the same flow the `preview_identity_sync_target` MCP tool exposes.

---

## 10.6 Identity Consumers (outbound PULL surface) {#106-identity-consumers-outbound-pull-surface}

Added 2026-06-11 (PR1 of 3). The inverse of §10.5: instead of Helix pushing to a downstream, an external system registers as an **identity consumer**, holds its own `helix_svc_` service token and pulls people/org data from read-only endpoints. PR1 ships the credential + governance base; PR2 adds the REST Directory data endpoints (keyset incremental); PR3 adds a read-only SCIM 2.0 server. Code: `server/app/identity/consumers/` (models / token_service / auth / router / schemas).

**Tables (migration 277)** — `identity_consumers` (slug immutable + reserved forever even after soft-delete; `owner_person_id` must be an active employee; `scopes` JSON; `filter_config` JSON in the §10.5 filter shape; `rate_limit_per_minute` NULL=platform default; `status` active/disabled = kill switch), `identity_consumer_tokens` (SHA-256 hash only, PAT-style 160-bit entropy, ≤3 active per consumer for overlap rotation, expiry presets 30/90/180/365/never), `identity_consumer_usage_daily` (per-(consumer, UTC day) request counter, upserted best-effort on every authenticated call).

**Auth dependency** — `require_consumer(*scopes)` in `app/identity/consumers/auth.py`, mirroring the MCP PAT middleware defence stack: uniform 401 (no failure oracle), per-token failed-auth window peeked pre-DB, per-IP failed-auth window (caps audit flooding), success-path per-consumer fixed-window rate limit (429 + Retry-After), scope miss = 403 audited as `directory_api.scope_denied` with the consumer named, auth failures audited as `directory_api.auth_failure` via independent committed session, last-used + usage bookkeeping in its own short session. All Redis windows fail open.

**Scopes + field-level grant (migration 285, 2026-06-15)** — the three scopes survive as one-click **bundles**: `directory.persons:read`, `directory.org:read`, `directory.pii:read` (add-on to persons:read). On top, `identity_consumers.granted_fields` (JSON) is an Authing-style least-privilege projection of the person bundle — the source of truth is `app/identity/consumers/field_catalog.py` (each person field tagged `public`/`internal`/`pii` + a `bundle` + an `always` flag for `id`/`updated_at` which can't be removed). `validate_granted_fields` enforces a field's bundle is granted (can't grant `mobile_phone` without the pii bundle); `reconcile_granted_fields` re-derives the grant when scopes change (drops orphans, defaults newly-added bundles to all fields). The `/persons` serializer (`serialize_person(..., granted_fields)`) projects to exactly the granted set — a PII field is **omitted entirely** when not granted (no masked middle-ground on a field-level grant; plaintext + audited when granted). SCIM gates `phoneNumbers` on the granted `mobile_phone` field. Backward-compat: migration 285 backfills `granted_fields` from each existing consumer's `scopes` (full bundle expansion), and `require_consumer` falls back to the bundle default for any empty grant. `/scopes` returns the full field catalog (key/bundle/sensitivity/always) so the applicant/admin sees what each scope exposes; the FE editor is `ConsumerFieldGrant.tsx` (+ `utils/consumerGrant.reconcileGrant`). **`github_id` joined the persons bundle 2026-08-17** (sensitivity `internal` — a self-chosen public handle, not personal contact data; no migration): sparse by construction (HRIS collects it at onboarding only, never retroactively), so `null` means "not on file" and a consumer reconciling GitHub org membership must never read it as "not an employee". Existing consumers do NOT inherit it — `granted_fields` is a persisted explicit list, so an admin must tick it; that is the intended least-privilege behaviour, and the same holds for every future catalog addition. **Catalog↔serializer lockstep is now test-enforced** (`test_directory_api.py::test_catalog_and_serializer_stay_in_lockstep`): a catalog key the serializer never emits is a grantable field that returns nothing (the admin UI lies), and a serializer key absent from the catalog can never be granted (dead field) — adding a person field means editing `field_catalog.PERSON_FIELDS` **and** `serialize_person` together.

**Governance** — `/api/v1/identity/consumers` (+`/scopes`, `/{id}/tokens`, `/{id}/tokens/{tid}/revoke`): reads `require_admin_read`, writes `require_admin`, every mutation audited (`identity_consumer.create/update/enable/disable/delete/token_mint/token_revoke`). Console at `/admin/identity/consumers` (token plaintext shown exactly once; PersonFilterEditor shared with §10.5 targets). MCP read twins: `list_identity_consumers` / `get_identity_consumer`.

**Self-service request flow (migration 286, 2026-06-15)** — instead of admin-only direct creation, a system owner applies via `POST /api/v1/identity/consumer-requests` (any active employee; FE: card-launched modal `ConsumerAccessApplyModal` in the 软件资产类 category of `/my/requests` — the former standalone `ConsumerAccessRequestPage` at `/my/data-access` was retired 2026-06-15 and now redirects to `/my/requests`, since records already surface in the unified 我的申请 tracker; MCP `request_identity_consumer`). Code: `request_service.py` / `request_router.py` / `request_schemas.py`, table `identity_consumer_requests`. **Submit** validates the grant (`validate_granted_fields`) + filter + PII justification + slug availability, then opens a Feishu approval instance pre-filled from the request — control values matched by NAME keyword (`_FIELD_KEYWORDS`, disambiguating "Requested Data Scope" vs "Population Scope"), single-choice controls (Includes Plaintext PII / Token Validity) resolved to their **option machine key** (`_resolve_option`) since Feishu radioV2 rejects free-form text (API 1390001). Flow_key `identity_consumer_request` → Feishu template `413C2807-…` (seeded idempotently by migration 286, resolving the primary chat_approval/feishu integration). **Callback** (dispatched in `approval/service.py::handle_feishu_callback`): on APPROVE `_provision` re-checks slug + owner-active authoritatively (conflict → request `failed` with reason, never a half-built consumer), materialises the live `IdentityConsumer`, and pushes a **one-time token-issue link** (action-token, purpose `consumer_token_issue`). The link page (`GET/POST /api/v1/open/consumer-token/issue`, no login — action-token authorised, mirrors provisioning quick-complete) mints the consumer's FIRST token and shows the plaintext exactly once. **Single-use is authoritative via `identity_consumer_requests.token_issued_at`** (migration 288, set under a `FOR UPDATE` row lock inside the mint transaction): the link mints exactly once and **stays spent even after the token is revoked** — revoking does NOT re-open the no-login mint path, and concurrent POSTs serialize on the lock (the active-token check is a secondary fallback for pre-288 rows). Rotation thereafter is admin-console only. Plaintext is never stored. Requests surface in the unified 我的申请 tracker (`my_requests._provider_identity_consumer`, approved=terminal). Read-only `GET /identity/consumer-requests/catalog` exposes the bundle/field/expiry catalog to any authenticated employee (the admin `/scopes` is admin-only) so the apply form can render.

**Periodic re-certification (migration 287, 2026-06-15)** — reuses the access-certification engine (§ auditor / `access_certification_service.py`) instead of a parallel mechanism. New snapshot builder `_snapshot_identity_consumer` (registered in `_SNAPSHOT_BUILDERS`; `identity_consumer` added to `CAMPAIGN_TYPES`) makes one cert item per active consumer, reviewer hint = owner, `raw_payload` carrying the 90-day request volume + a `dormant` flag (sourced from `identity_consumer_usage_daily`) so the reviewer can spot consumers to decommission. Decision side-effects go through `access_certification_coordinator` (the auditor domain must not write the consumer row directly): **revoke → disable the consumer** (the `disabled` kill switch IS the enforcement, synchronous, no provisioning task); **keep → stamp `identity_consumers.last_certified_at`**, and if the consumer was auto-disabled by a recert lapse (`recert_lapsed_at` set) **re-enable it** — an admin kill switch (`recert_lapsed_at` NULL) is left untouched so keep never overrides admin intent. Two crons (seeded into `scheduled_jobs` by migration 287, which also adds the `last_certified_at` / `recert_lapsed_at` columns): `auditor.identity_consumer_recert_auto_open_quarterly` (`0 9 1 1,4,7,10 *` UTC — idempotent per-quarter campaign) and `auditor.identity_consumer_recert_lapse_sweep` (`0 10 * * *` — auto-disables consumers whose recert item is pending past deadline + the 14-day grace window, sets `recert_lapsed_at`; idempotent; pairs with the generic `escalate_overdue` which raises the compliance incident). FE: the auditor `CertificationsPage` open-campaign dropdown auto-lists "Identity consumers" (campaign_types come from the backend), hides the department picker for it (org-wide), and the admin consumer detail drawer shows last-certified / lapse-disabled status. No new MCP tool — the generic `create_access_certification` / `decide_access_certification_item` cover it (campaign_type `identity_consumer`).

**Owner-leaver handling (2026-06-15, no migration)** — when a consumer's owner offboards, the offboarding scanner (`offboarding/service.py::_rescan`) emits a `RES_IDENTITY_CONSUMER_OWNER` blocker (advisory). Two outcomes: **manual handover** (`resolve_blocker_by_handover_to_successor` supports the type → `app/identity/consumers/ownership.py::transfer_consumer_owner`, active-successor validated) reassigns the owner FK and the consumer **stays live**; **T+0 unclaimed → auto-disable** (`advance_service._handle_one_blocker` branch) sets the consumer `disabled` and resolves the blocker `revoked_auto`. This is a deliberate divergence from the cloud-scope-owner MUST_TRANSFER pattern (which force-transfers to the manager cascade): a PII data tap's safe default is **stop, not silently reassign** to a manager who never asked for it — an admin re-enables + reassigns via the console if it's still needed. Supersedes the old `_require_owner` weak guard (which only blocked *setting* a leaver as owner, never handled an owner *becoming* a leaver). **This completes the identity-consumer full lifecycle** (PR1 field grant → PR2 self-service request/approval → PR3 periodic re-cert → PR4 owner-leaver).

**Data endpoints (PR2, 2026-06-11; review-hardened same day)** — `GET /api/v1/open/directory/persons` (+`/persons/{key}` addressing UUID / employee_no / work_email), `/org/nodes`, `/org/memberships` in `app/identity/consumers/directory_router.py`. Keyset incremental: ordered `(updated_at, id)`, response `{items, next_cursor, has_more}` where `next_cursor` is ALWAYS the scan high-water position (drained streams included — consumers just keep polling it); the cursor is HMAC-signed (opaque to the consumer — its payload may name an out-of-filter row id); id tiebreaker ⇒ loss-free under same-second bulk HRIS updates; a visibility safety lag (`DIRECTORY_API_FEED_SAFETY_LAG_SECONDS`, default 5s on the DB clock) holds back the freshest rows so late-committing transactions can't be skipped; timestamps carry `Z`; `updated_since` ISO start point (mutually exclusive with cursor); page_size ≤500. Population filter evaluated per request via `resolve_in_scope_person_ids` (fail-closed on malformed config; the engine's default active-only gate is a hard pre-gate, NOT a pooled or-dimension — review fix); out-of-filter person = absent from lists AND 404 on direct lookup (scope resolved before the row query to kill the timing oracle); memberships restricted to in-filter persons; manager/HRBP references returned ONLY when the target is in-filter (null/omitted otherwise — identifier leakage across the filter boundary counts as leakage). Each response row is projected to the consumer's `granted_fields` (least privilege; see the Scopes paragraph) — non-granted fields, PII included, are omitted; plaintext-PII reads (any granted PII field) are audited (`directory_api.pii_read`, independent session). Rate-limit windows degrade to a process-local fallback when Redis is down (never uncapped on a data-egress surface). Integrator guide: `docs/integrations/identity-pull-api.md`.

**SCIM 2.0 read-only server (PR3, 2026-06-11)** — `app/identity/consumers/scim_router.py`, base URL `/scim/v2` (nginx forwards `/scim/` to the backend). Discovery (`ServiceProviderConfig`/`ResourceTypes`/`Schemas`) needs any valid service token; `/Users` needs persons:read, `/Groups` (org nodes, members = in-filter persons only) needs org:read. Responses are RFC 7644 `application/scim+json` — exempt from the Helix envelope (ADR 2026-06-11); endpoint-level errors use the SCIM Error schema while auth 401/403/429 reuse the shared `require_consumer` stack. Honest filter subset (`userName`/`externalId` eq, `meta.lastModified` gt/ge, ≤2 and-joined; Groups: `displayName`/`externalId` eq) — anything else 400 invalidFilter; the parser is quote-aware (a value containing `` and `` parses). All writes 405 (`Allow: GET`) and GET-on-unknown-path 404, both via the auth-gated catch-all (SCIM Error shape). Manager block emitted only for in-filter managers. `phoneNumbers` emitted only when the `mobile_phone` field is granted (migration 285 field-level gate, not merely the pii scope; omitted, never masked — masked strings break SCIM parsers). Mapping mirrors the push presets (userName=work_email; enterprise employeeNumber/department/costCenter/organization/manager).

**Proxy trust (platform-wide, fixed here 2026-06-11)** — `app/core/net.py::is_trusted_proxy` accepts single IPs AND CIDR entries in `TRUSTED_PROXY_IPS`; `get_real_ip` + the MCP middleware both use it. Production renders the variable via `deploy/scripts/render-env.sh` with default `["127.0.0.1","::1","172.28.0.0/16"]` (the pinned compose subnet; the backend port binds loopback so nothing outside that network reaches :8000) — before this, the nginx container's dynamic bridge IP never matched the loopback-only default, X-Forwarded-For was ignored everywhere, and every per-IP rate limiter / audit ip collapsed onto one shared bucket.

**Consumer smoke endpoint** — `GET /api/v1/open/directory/me` (any valid service token, no scope): echoes slug/scopes/filter so integrators verify credentials before touching data endpoints. The `/api/v1/open/` prefix marks machine-credential surfaces — distinct from the browser-JWT `/api/v1/directory/people` picker endpoints.

## 10.7 External API (`app/api_clients`) — standalone scoped API access (2026-06-15)

A **standalone feature**, deliberately separate from §10.6 (which distributes directory/identity data). Lets partner/downstream systems read+write Helix **business resources** with their own scoped credential. NOT built on the identity-consumer framework — own credential store, auth, scope registry, admin console, and **own top-level menu** (`/admin/api-clients`, under Config Center → 对外 API). The decision to split (rather than ride identity_consumers) is in DECISIONS 2026-06-15, superseding the earlier extend-the-consumer-framework ADR.

**Principal + credentials (migration 289)** — `api_clients` (registry: name + immutable slug, in-office `owner_person_id`, `status` active/disabled kill switch, `scopes` JSON, `rate_limit_per_minute`), `api_client_tokens` (`helix_api_` bearer, SHA-256 at rest, ≤3 live per client for overlap rotation, expiry presets 30/90/180/365/never), `api_client_usage_daily` (per-(client, UTC day) counter). Token prefix `helix_api_` is distinct from `helix_pat_` (PAT/MCP) and `helix_svc_` (identity consumers); each auth path rejects the others' tokens.

**Auth** — `app/api_clients/auth.py::require_api_client(*scopes)` (mirrors the directory-pull defence stack on the new store): per-token + per-IP failed-auth windows, per-client success rate limit, uniform 401, scope denial → 403 (audited `external_api.scope_denied`), usage bookkeeping in an independent session. Low-level token gen/hash + Redis fixed-window helpers are shared via `app/core/service_token.py` (no crypto re-invented; identity_consumers keeps its own copy for now). The auth core is factored into `authorize_api_client_token(...)`; the header dependency wraps it, and `authorize_api_client_body_token(...)` reuses it for **token-in-body** callers (the Feishu external-options webhook — credential carried in the JSON body, not the `Authorization` header).

**Scopes** — own registry `app/api_clients/scopes.py`: `cloud.subscriptions:read` / `cloud.subscriptions:write` (write implies read) / `cloud.billing:read` (cloud spend, list basis only — see the billing resource below) / `software.catalog:read` (software product list) / `software.permissions:read` (grantable permissions within a software) / `hr.absences:read` (leave-availability lookup) / `people.github:read` (person ↔ GitHub handle lookup, the one scope on this plane that can LIST people — see the person-visibility ceiling below) — the middle two power the Feishu external-options endpoints, the last two the HR/people lookups, all below. Extensible: add a constant + a router that gates on it.

**Cloud visibility whitelist (2026-08-15, migration 470)** — `api_clients.cloud_visibility` JSON: `{"providers": [...], "subscription_ids": [...]}`, the two lists a **union** ("every subscription of these providers, plus these named ones"). `NULL` = unrestricted, so the column is additive and no existing grant changed. Orthogonal to `scopes`: a scope says WHAT KIND of cloud data a client may read, the whitelist says WHICH accounts — a distinction that was academic while the surface served inventory metadata and stops being academic the moment cloud **billing** rides the same credential plane. Shape (parse/normalize/audit-describe) lives in `app/api_clients/visibility.py` (SQL-free, platform level); resolution against real rows in `app/domains/cmdb/open_visibility.py` (cmdb owns `integrations`/`cmdb_cloud_scopes`; the governance router reaches it by lazy import, same platform→domain pattern as the curated-OpenAPI builder). **Load-bearing invariant: fail-closed** — a restricted whitelist that resolves to zero integrations and zero subscriptions becomes `false()`, never "no constraint"; the one direction a visibility filter must never fail in. Enforced on every `/open/cloud/...` route: list ANDs `scope_conditions()`, `/{key}` GET+PATCH resolve through `_resolve_one(..., visibility)` which returns **404** (never 403 — a 403 confirms the account exists) and filters to visible rows *before* the duplicate-external-id ambiguity test, so a client whitelisted to one of two same-numbered accounts gets its row instead of a 400 about a collision it can't see. An unknown key in the payload is **rejected, not ignored** (`{"provider": [...]}` would otherwise normalize to unrestricted). Governance validates provider + subscription existence at save time (unknown → 422, rather than a silently-matches-nothing grant the integrator debugs for a day), audits the before/after grant *shape* (not 500 UUIDs), and treats an omitted field as "unchanged" vs explicit `null` as "widen" — widening is never a side effect of a PATCH that forgot the field. `GET /open/me` reports `cloud_visibility` so an integrator seeing fewer accounts than expected can tell the whitelist apart from a filter mistake. Console: a segmented unrestricted/restricted control + provider and subscription multi-selects in the client drawer (`web/src/pages/apiClients/CloudVisibilityField.tsx`), with a save-time refusal of "restricted but empty" (which the backend would normalize back to unrestricted). **Malformed-value hardening (2026-08-18, 0 migration)** — the two defects the person-side review found (§ below) were verbatim present here, and the cloud half is the more expensive one because `cloud.billing:read` rides the same column. (a) A stored `cloud_visibility` that is present but NOT a JSON object (hand-edited row, restore from an older shape) used to fail **OPEN**: `ApiClientContext` coerced it with `isinstance(..., dict) else None`, and `None` means unrestricted, so a deliberately narrowed client silently got the whole inventory — and the whole spend picture. Fixed with `cloud_visibility_is_malformed()` (the single definition of the predicate, asked by the auth context, the resolver, the audit summary and the governance response) + `ApiClientContext.cloud_visibility_malformed`. **The fail-closed decision lives in ONE place**: `resolve_client_cloud_visibility(db, ctx)` in `open_visibility.py`, which every one of the 9 cloud call sites (3 inventory + 5 billing + `/open/me`) now goes through — pairing the coerced value with its flag at 9 call sites would have made the next route's author responsible for remembering, and `resolve_cloud_visibility` additionally fails closed on a raw non-dict so neither entry point can fail open. `ResolvedCloudVisibility.malformed` needs no special query handling (`unrestricted=False` with both id tuples empty is already `false()`); it exists so `/open/me` reports `{"restricted": true, "misconfigured": true}` instead of `restricted: false` in the one place built to explain an empty result set. (b) **A malformed value could not be repaired through the API**: the PATCH handler compared the body against the same dict-coerced `None`, so `PATCH {"cloud_visibility": null}` — the exact repair `/open/me` and the console tell the admin to make — compared `None != None`, was skipped, and left the client reading nothing forever. Now compared against the RAW stored value. `cloud_visibility_audit_detail` reports `misconfigured` (+ `stored_type`) ahead of its unrestricted branch, so clearing an unreadable whitelist — the largest widening on this surface, *reads nothing* → *reads everything* — no longer audits as `from == to == unrestricted`, and `ApiClientResponse.cloud_visibility_misconfigured` drives a red console tag instead of the "all cloud accounts" one. **Production was checked before flipping the direction** (2026-08-18: 6 client rows, one restricted `{"providers":["aws"]}`, zero malformed) — fail-closed changed behaviour for no live integration. Tests: [`test_cloud_visibility.py`](../../server/tests/test_api_clients/test_cloud_visibility.py) + [`test_cloud_visibility_shape.py`](../../server/tests/test_api_clients/test_cloud_visibility_shape.py).

**Person-visibility ceiling (2026-08-18, migration 513)** — `api_clients.person_visibility` JSON, the people-side twin of the cloud whitelist above and driven by the identical argument the `visibility.py` docstring makes for cloud: a scope says WHAT KIND of people data may be read, never WHICH people. That gap was harmless while the only people-adjacent surface was the leave lookup, which is **lookup-only** — the caller must name its subjects, so knowing who to ask about IS the boundary. `people.github:read` breaks that property (it can be asked by department, i.e. it LISTS people), so without a ceiling one leaked token enumerates the whole roster. **The payload is a `FilterConfig`** — the same vocabulary, the same `app/core/person_filters` engine and the same `PersonFilterEditor` the sync targets and directory consumers use, deliberately NOT a second dialect: two egress planes must not hold different ideas of what "population" means. `NULL` = unrestricted (additive; no existing grant changed). Shape validation in `app/api_clients/person_visibility.py` (SQL-free; delegates to `FilterConfig.from_dict` so an admin cannot save a filter the evaluator would later read differently), and the key allowlist is the **full** dimension set on purpose — narrower than the shared editor emits would reject a filter the UI itself produced. Unknown key → **rejected, not ignored** (same trap as cloud: `{"department": …}` would normalize to unrestricted). Enforcement invariants: the request's own selectors are **intersected** with the ceiling, never unioned; an out-of-ceiling person is reported exactly like a non-existent one (`unknown_person` / `unknown_handle`), the people-plane analogue of the directory's 404-not-403 rule, so the surface is never an existence oracle; a restricted filter matching nobody sees nothing (fail-closed, never degraded to unrestricted). Because that indistinguishability is deliberate, `GET /open/me` reports `person_visibility` — it is the ONLY place a client can learn its own reach is narrowed. Governance mirrors the cloud contract (omitted = unchanged vs explicit `null` = widen), and both directions are audited **by value** — see the audit hardening below; the earlier shape-only summary is what made a widening render identical to the grant it replaced. Console: `web/src/pages/apiClients/PersonVisibilityField.tsx` (segmented control wrapping the shared editor) shown only when a `people.*` scope is ticked, with a save-time refusal of "restricted but narrows nothing". **Three hardenings from the pre-merge adversarial review, each a scar worth keeping:** (a) a ceiling that names no `employment_statuses` gets `include_offboarded` injected before evaluation — otherwise the shared engine's default active-only pre-gate silently drops `leaver_pending` people from a departments/countries-only ceiling, contradicting the endpoint's documented population and making `include_left` a dead flag for such clients; a ceiling that DOES name statuses is passed through untouched (injecting there would ADD `left` = a widening). (b) A stored ceiling that is not a JSON object (hand-edited row, older serialized shape) fails **closed** via `ApiClientContext.person_visibility_malformed` — coerced-to-`None` would have meant *unrestricted*, handing a narrowed client the whole roster. (c) `person_visibility_audit_detail` records every narrowing dimension **by value** plus `include_offboarded` / `include_subtree` / `mode`: a bare count made the highest-leverage widening (one team → a whole org) render byte-identical on both sides of the audit diff, as did an equal-length `person_ids` swap. Only `person_ids` is bounded (25-id sample) and it always carries the full count. Governance also existence-checks the ceiling's department and person ids at save time (`departments` holds HRIS org-node **source ids**, not Helix row UUIDs — the wrong kind yields a ceiling matching nobody, silently). Tests: [`test_people_github_api.py`](../../server/tests/test_api_clients/test_people_github_api.py) + [`test_person_visibility_shape.py`](../../server/tests/test_api_clients/test_person_visibility_shape.py).

**Resource: Person ↔ GitHub handle lookup (2026-08-18, migration 513)** — `app/identity/open_people_router.py`: `POST /api/v1/open/people/github/lookup` (`people.github:read`). The point-query twin of the Directory pull API's `github_id` field (§10.6): Directory is for systems that *synchronise the roster* (keyset incremental, field-level grants), this is for a caller that has a question now and does not want to stand up a sync. Three combinable selectors — `emails` (forward), **`github_ids` (reverse — the direction a GitHub org member list actually gives you, and the one a lookup-by-email API structurally cannot answer)**, `departments` (org-path prefix, subtree included, tenant root stripped via `identity.bu_helpers.department_segments`, and a trailing-slash guard so `Engineering` never swallows `Engineering Support`). Contracts: **tri-state** `found` / `no_github_id` / `unknown_person` — "we have the person but no handle on file" and "no such person" mean opposite things to a reconciler and never collapse (same rule as the absences `coverage`); `github_id` is **sparse by construction** (collected at onboarding, never backfilled) so `null` means "not on file", NOT "has no GitHub account", and the docs say in as many words never to remove an org member on that basis — the actionable signal is the reverse (`unknown_handle`); default population is currently-employed (`active` / `leaver_pending` — someone serving notice still holds access, so an access audit still wants them; there is no `on_leave` status in this platform, `IdentityPerson._ALLOWED_EMPLOYMENT_STATUSES` rejects it), `include_left` adds `left`, and **`pre_hire` is never exposed on any flag** (an unannounced future hire has no GitHub access to reconcile); a department selection over 1000 people **422s rather than truncating** (no-silent-caps); egress gates (HRIS-purged / SF no-push `2061` / non-corporate email) are applied through the shared `resolve_in_scope_person_ids`, so the pull and point-query planes agree on who may egress; the response carries identity + handle + department only — no contact details, whatever other scopes the client holds. **No MCP tool** (same decision as the absences lookup: the External API plane is machine-to-machine integration, not an employee self-service flow, so the MCP-sync rule does not apply). Reference: `docs/api/external-api.md` §B.7 (+ zh-CN).

**Governance** — `/api/v1/api-clients` (+`/scopes`, `/{id}/restore`, `/{id}/tokens`, `/{id}/tokens/{tid}/revoke`): reads `require_admin_read`, writes `require_admin`; every mutation audited (`api_client.create/update/enable/disable/delete/restore/token_mint/token_revoke`). Frontend console `web/src/pages/apiClients/ApiClientsPage.tsx` (token plaintext shown once). **Slug lifecycle (2026-08-13)**: delete is soft and the `slug` unique constraint counts deleted rows, so a slug is bound to its client forever — it is never handed to a *different* client (it lands in audit rows and request logs). The exit from that is `POST /{id}/restore` (un-deletes the original row; tokens revoked by the delete stay revoked, and the active-owner check is deliberately NOT re-run so a client whose owner has left is still restorable), plus `GET ?include_deleted=true` + the console's "显示已删除" filter so the holder is visible. Before restore existed, delete + hidden row + slug-forever was a genuine dead end (prod 2026-08-12: `aisupport`).

**Resource: Cloud Subscriptions** — `app/domains/cmdb/open_cloud_router.py` (in the cmdb domain — data + write service are cmdb's; only cross-cutting import is `require_api_client`): `GET /api/v1/open/cloud/subscriptions` (offset pagination; filters `provider`/`environment`/`status`/`owner_email`/`q`), `GET /{key}` (Helix UUID or cloud-native external_id; ambiguous → 400), `PATCH /{key}` (write scope). Fields: id / external_id / provider / integration_name / scope_type / name / environment / status (active·suspended = in use, closed = offline) / owner `{name, work_email, department, department_full_path, department_path_segments}` (work email + primary department leaf name + full chain, not masked) / budget_usd_monthly / updated_at. **`department_full_path`/`department_path_segments` (2026-08-06)**: full department chain from the owner's primary `IdentityOrgMembership.org_path`, tenant root (`PingCAP`) stripped — same derivation as `IdentityPerson.department_chain`, factored into the shared public helper `app/identity/bu_helpers.py::department_segments(org_path)` (with `_TENANT_ROOTS`, so no third caliber). String is `" / "`-joined; the array is pre-split so a consumer needn't split (nor guess delimiter padding). Both degrade to `null`/`[]` when the owner has no primary membership. Added because an integrator (cyzd) was about to apply for a **separate** `directory.*` scope + `helix_svc_` credential just to reconstruct the path from Owner email — the `_serialize_scopes` membership query already loaded the row to fill `department`, so `org_path` came free. Doc `docs/api/external-api.md` §B.1 also gained a **field-name alias table** (`cloud_account_tenant→integration_name`, `subscription_type→scope_type`). Write goes through `CloudLifecycleService.apply_open_update` — dedicated method (NOT `assign_scope_owner`): audits `actor_type="service"` (client slug + IP, no actor_user_id), skips human owner-confirmation side-effects, mirrors leaver-owner (409) + closed-scope (409) guards, idempotent; metadata-only (prod-write lock N/A); owner set by `owner_email` → active person. Reference: `docs/api/external-api.md` (+ zh-CN).

**Resource: Cloud billing (2026-08-15, 0 migration)** — `app/domains/cmdb/open_billing_router.py` + `open_billing_service.py`: `GET /api/v1/open/cloud/billing/daily` + `/monthly` + `/breakdown` + `/changes` + `/tag-keys` (`cloud.billing:read`). Serves per-day / per-month cloud spend for the subscriptions the client's `cloud_visibility` grants (§ whitelist above), for BI / finance-reporting consumers that were otherwise scraping the console. Six load-bearing contracts, each one a scar: **(1) one basis, always `list`** — `fact_amount_expr(COST_MEASURE_LIST)`, and there is deliberately **no `measure` parameter**, because invoice ÷ list for the same window *is* the company contract discount rate; an invoice-basis surface would be a separate scope and a separate grant decision. **(2) facts-first with a scope-month `billing_daily` fallback** (same rule as `scope_billing_detail`; the two ingest crons run independently, so without it the API reports `$0` for a scope whose spend alert is firing) — the answering table is named per row in `source`. Resource-group-typed scopes route on `(integration_id, resource_group)` and get no fallback (the aggregate table is structurally empty for them). **(3) a missing FX rate drops the row** and discloses it in `meta.unconverted_by_currency`; `0` would read as "this day cost nothing". **(4) freshness is in-band** — `is_provisional` per row plus `data_through` / `provisional_from` in meta (`PROVISIONAL_DAYS = 3`, matching the facts cron's rolling re-ingest window), and `month_is_partial` is true for a running month, a month holding any provisional day, OR a month the window only partly covers. **(5) `meta.excludes_unallocatable`** states in-band that `scope_id IS NULL` charges (support / tax / credits / adjustments) cannot appear in a per-subscription view and that this must not be reconciled against a provider invoice. **(6) month == sum(days)** by construction — the monthly series folds the same cells rather than issuing a second aggregate. Scan discipline: every aggregate is `_month_chunks`-chunked (TiDB 8175), windows are capped (366d daily / 36mo monthly / 92d + 3mo with the service breakdown), and a materialized result over `MAX_RESULT_CELLS` (200k) 422s asking the caller to narrow rather than silently truncating. `page_size` ≤ 1000 here (vs 200 elsewhere): a year × few-hundred subscriptions at 200/page would burn 500+ calls against a 300 req/min limit. Every successful read writes one `cloud.billing.read_daily` / `read_monthly` audit row (`actor_type=service`, window + filters + row count + `measure`) — a dump of company-wide cloud spend must be reconstructable months later.  **Breakdown (`open_billing_breakdown.py`)** splits a window's total along ONE dimension — subscription / service / provider / environment / department / owner / resource (Top-N, ≤100) — 92-day window cap, and two honesty rules that exist because the alternative is a silently wrong pie chart: (a) an aggregate-fallback subscription has no service/owner/department/resource dimension, so its spend is reported in `meta.not_broken_down` (amount + the subscriptions responsible) rather than evaporating — the three subscription-derived groupings absorb it instead; (b) `department`/`owner` keep an explicit **`unallocated`** row for facts whose allocation ladder resolved to nothing (`auto_owner_source='none'`), never folded into an arbitrary owner, and `resource` reports `meta.without_resource_id_usd` because GCP's standard billing export carries no resource column at all (a Top-20 could otherwise cover a few percent of a GCP-heavy window while looking complete). `meta.total_usd` is the window's whole spend, not the sum of the shown rows. RG-typed subscriptions are re-keyed from `(integration_id, resource_group)` onto their scope for the three subscription-derived groupings — grouping those by `scope_id` alone dropped every Azure-resource-group fact into the NULL bucket, under-reporting the breakdown and disagreeing with `/billing/daily` for the same subscription (caught in review of #2750, regression-tested). Python-side group keys are capped at 100k (only `resource` can approach it) → 422 rather than a silently partial ranking, and `meta.data_through` matches the daily/monthly freshness signal. **Tag dimensions + two-dimension crossing (2026-08-15)** — the integrator's own ask ("tag 组合分析"): `group_by` takes ONE OR TWO comma-separated dimensions, the vocabulary grew `region` / `resource_type` / `cost_center` (already in the internal `GROUP_BY_COLUMNS`), and `tag:<key>` groups by the cloud resource tag on the billing line (`cmdb_cost_facts.tags`, via `JSON_UNQUOTE(JSON_EXTRACT(...))` held to the same `_TAG_KEY_RE` the internal filter uses — that regex IS the JSON-path injection guard, do not loosen it). Rows gained `keys`/`labels` objects (additive; the flat `key`/`label` contract is unchanged). Why it matters and what it costs, **measured on prod 2026-08-15**: 79.1% of positive charges carry a tag (AWS 76.4 / GCP 95.9 / AliCloud-Intl 86.6), while **Azure, UCloud and Tencent are 0%** — their drivers write an empty tag map — and the top keys are uniform across three clouds (`servicetype` `usedby` `tenant` `component` `project` `cluster` `name` `environment`). So the honesty rails are load-bearing: `untagged` is a first-class row (a fifth of the money), `meta.tag_coverage` reports tagged/untagged/share, and `meta.providers_without_tags` **measures** (never hard-codes) which clouds contribute zero tagged spend, so nobody reads "Azure spent nothing" off a chart that structurally cannot see Azure. Tag-key **case is preserved** (`tag:UsedBy` ≠ `tag:usedby`: GCP labels are lower-case by rule, AWS tags are not) — only dimension NAMES are lower-cased, because folding the key would turn a valid request into an empty result that reads as "this team spent nothing". Discovery ships alongside as `GET /billing/tag-keys` (ranked by covered spend; a bounded, recency-biased sample since `tags` is unindexed JSON with no `JSON_KEYS` pushdown — the response says in-band that the number ranks rather than totals; tag VALUES are not returned, they come from the grouping, which carries the visibility gate). Top-N now caps every grouping, not just `resource`, because crossing two dimensions multiplies cardinality. The un-splittable disclosure keeps **negative** amounts (a scope-month can net below zero on credits / refunds / adjustments — production has seen a single credit day swing an account by six figures); dropping them overstated `total_usd` and disagreed with `/billing/daily` and with `group_by=subscription`, which absorb the same value unconditionally (second review round on #2750). The disclosure block is gated on the subscription list rather than a positive amount, and that list sorts by absolute value so a large credit is not buried below the truncation line. **Incremental pull (`open_billing_changes.py`, migration 471)** — `GET /billing/changes?since=` lists the `(subscription, day)` cells re-ingested since a timestamp, and `/billing/daily?updated_since=` filters the series to them, so a nightly consumer stops re-pulling a year every night. It is an **ingest watermark, not an amount diff** (a day re-fetched with an unchanged number is still reported — under-reporting would be silent data loss on the consumer's side). The watermark columns were **measured on the real upsert paths, not inferred**, and the two tables are mirror images: `cmdb_cloud_billing_daily` advances `fetched_at` while `updated_at` stays frozen (its `onupdate` is client-side and never fires on `ON DUPLICATE KEY UPDATE`; the DDL has no `ON UPDATE CURRENT_TIMESTAMP`), and `cmdb_cost_facts` does exactly the opposite (`updated_at=func.now()` is in its SET list). Reading the frozen column of either would have shipped a feed that silently returns nothing — a mutation test pins both directions. Both sources are consulted because independent crons write them (a manual line-item backfill moves only the facts table). Both queries are horizon-bounded (default 32 days, max 92) so neither degenerates into a full scan: the aggregate side rides the new `ix_billing_daily_fetched_at` (migration 471), the facts side rides the existing `ix_facts_scope_date` — deliberately no new index on the multi-million-row fact table. The cost of that bound is stated in-band (`meta.does_not_detect`): a backfill older than the horizon is not detected, and a `since` older than the horizon is refused with the alternative named rather than answered partially. `since` parsing forgives the classic URL mistake (an unescaped `+00:00` arriving as a space) and a future `since` is refused (an empty page would read as "nothing changed" when the caller's clock is wrong). **No new MCP tool** (`_REGISTERED_TOOLS_COUNT_HINT` unchanged): the internal `cost_breakdown` / `cost_timeseries` / `cost_top_resources` tools already cover this ground for AI clients — this is the external projection of an existing capability, not a new self-service flow. Reference: `docs/api/external-api.md` §B.6 (+ zh-CN). Tests: [`test_cloud_billing_api.py`](../../server/tests/test_api_clients/test_cloud_billing_api.py) + [`test_cloud_billing_breakdown.py`](../../server/tests/test_api_clients/test_cloud_billing_breakdown.py) + [`test_cloud_billing_incremental.py`](../../server/tests/test_api_clients/test_cloud_billing_incremental.py).

**Resource: Feishu external options — software catalog + permissions cascade (2026-07-06)** — `app/domains/asset/open_feishu_options_router.py` (in the asset domain; cross-cutting imports: `authorize_api_client_body_token` + a read-only `SoftwarePermission` model import — the two tables are one FK-coupled aggregate, same "open router reads the models it serves" pattern as `open_cloud_router`; no cross-domain service/write coupling). Two **Feishu-approval-specific** webhooks, NOT the standard Helix contract: Feishu's "关联外部选项" data source `POST`s the configured URL with the credential **in the body** (`{"token": <helix_api_ token>, "linkage_params": {query, page_token, locale, <dependent params>}}`, 3s timeout), and expects the **Feishu envelope** `{"code":0,"data":{"result":{options:[{id,value,isDefault}], i18nResources, hasMore, nextPageToken}}}` (V1 plaintext), not `{success,data,meta,errors}`. Display text via `@i18n@` keys resolved in `i18nResources` (zh_cn/en_us/ja_jp). No response encryption (Feishu *key* blank — public name lists; token+TLS suffice). `include_in_schema=False` (Feishu-only webhooks). Bad/absent token → 401, missing scope → 403 (raised, so misconfig fails loudly at setup). Two endpoints: **(a)** `POST /software-options` (`software.catalog:read`) — only `status='active'` rows, `query` fuzzy on name/version, option `id` = software **name** (human-readable stable value for approval routing conditions). **(b)** `POST /permission-options` (`software.permissions:read`) — the **cascading** partner: a Feishu *dependent parameter* (依赖参数, recommended key `software_name`, fallbacks `software`/`software_id`/sole extra) carries the selected software's option id; the endpoint resolves the software by name (defensively also by UUID) → returns that software's active `software_permissions` (option `id` = `permission_key`, display = `display_name`), empty until a software is picked. Reference: `docs/api/external-api.md` §B.2/§B.3 (+ zh-CN).

### Public holidays (法定假期, 2026-08-12, migration 462)

The company-wide multi-country holiday calendar HR maintains, and the answer to
"does Helix know per-country holidays?" — which until now was **no**:
`app/core/working_hours.py:23` and `app/domains/attendance/facts.py:27` both carry
a comment saying so, and the only holiday table in the codebase
(`finance_close_holidays`) is the month-end close engine's own China-only workday
calendar. **This domain deliberately does not touch it** — that table is live in
production and load-bearing for close-date maths; converging the two is a real
question that needs its own ADR, not a drive-by.

**Tables.** `hr_public_holidays` — one row per (country, day, holiday name). The
natural key is three-part **on purpose**: Hong Kong observes both Ching Ming
Festival and Holy Saturday on 2026-04-04, and Romania observes both Children's
Day and Orthodox Pentecost Monday on 2026-06-01, so a `(country, day)` unique key
would silently drop one of each pair. `hr_public_holiday_calendars` — per
(country, year) metadata (HR's published day count, Google Calendar subscription
link, the regional-holiday/EOR advisory); **its existence is the coverage
signal**, and any write that lands a day guarantees the row exists so the
External API can never report `no_data` while visibly serving holidays.

**`group_key` is the aggregation axis.** Storage is one row per day (which makes
the month grid, date-range queries and the flat export trivial), but humans, the
Word export and the notice generator all think in *holidays*. Every day of one
holiday plus the makeup workdays that offset it share a key;
`holiday_service.build_groups` folds rows back into `HolidayGroup` objects with
contiguous date segments. One source-document table row is one group, so Japan's
two separate "New Year Holidays" rows (January and December) stay distinct. Keys
are generated by the same slug rule as `name_key_for`, so a seeded group and an
HR-created one are indistinguishable and a re-seed is an idempotent upsert.

**Observed days ≠ stored rows.** The count HR publishes ("Australia Holidays - 8
days") is the number of holiday dates falling Mon–Fri, net of makeup workdays —
verified against all 22 countries of the source document. `stated_days` is
therefore **stored, not derived**, and the API returns the derived count plus a
`stated_days_matches_derived` flag beside it. Romania is the one genuine
divergence (states 12, derives 11, because 2026-06-01 carries two holiday names);
the console surfaces that instead of silently rewriting a number HR published.

**2026 baseline** — `holiday_seed_2026.py` (pure data, no app imports, shared by
migration 462 and the `restore-defaults` endpoint): 22 countries/regions, 322
holiday dates, China's 6 makeup workdays, 22 subscription links, 5 regional
advisories. Transcribed verbatim; all 322 weekday labels were cross-checked
against the real 2026 calendar (zero mismatches) before landing.

**Surfaces.** `holiday_router.py` at `/api/v1/hr/holidays` — HR maintenance
(reads `require_hr_read` so auditor/management can look without write authority;
writes `require_hr_admin` + leaver runtime gate + rate limit + audit) and the
employee 公示 endpoints under `/public/*` (`require_active_employee`). Group-level
create/replace/delete is the primary write path; `replace_group` is diff-based so
adding one day preserves the other rows' `created_at`. Excel import is
**preview-then-commit** and stateless (the rows HR confirms are the rows written);
upload safety is delegated entirely to `asset/bulk_import.read_bulk_rows`, the
platform's one hardened spreadsheet contract. `overwrite=false` is the default, so
re-importing a sheet reports skips instead of reverting an HR edit.
`holiday_export.py` produces the flat xlsx (its column labels *are* the import
parser's aliases, so an export round-trips) and rebuilds HR's published Word
document via `python-docx` (already a dependency). `holiday_notice.py` renders the
copy-ready 放假通知 in zh / en / bilingual — **text only, Helix never sends it**,
keeping the no-new-employee-pushes rule intact.

**Employee country axis.** `holiday_service.person_country` prefers
`residence_country` (the geographic axis, derived from the SF work-location code —
holidays follow where someone actually works) and falls back to `country`, which
must be **normalised, not sliced**: that field is documented as alpha-2 but
`identity_sync` stores alpha-3 in practice, so a naive `[:2]` turns `CHN` into
`CH` and serves Swiss holidays to Beijing. Unresolvable → `None`, and the caller
widens to all countries rather than guessing. Employee-facing "today" is the
reader's local date via `core.working_hours.resolve_timezone`.

**MCP** (+3, hint 452 → 455): `get_public_holidays`, `get_my_next_holiday`,
`generate_holiday_notice` (HR-gated to mirror its HTTP twin — `tool_runtime` only
enforces active-employee status and any employee can self-mint a PAT).

**Resource: Leave availability lookup (2026-08-06, migration 453)** — `app/domains/hr/open_leave_router.py` (in the hr domain; only cross-cutting import is `require_api_client`): `POST /api/v1/open/hr/absences/lookup` (`hr.absences:read`). Driver: a TiDB Cloud on-call scheduler checking whether an on-caller is out before assigning a shift. **New table `hr_leave_absences`** ([leave_models.py](../../server/app/domains/hr/leave_models.py), migration [453](../../server/alembic/versions/453_hr_leave_absences.py)) mirrors SF EC Time-Off **`EmployeeTime`** (the leave *request*: startDate/endDate/approvalStatus/quantityInDays) — distinct from `hr_leave_postings`, which is the *accounting* ledger (a deduction that only exists once a leave is booked, carries no span, and never reflects a pending request), so postings structurally cannot answer "on leave on date X". The leave sync (`sync_leave_data`) already fetched `EmployeeTime` (to stamp reason onto postings) but discarded it; it now idempotently upserts into `hr_leave_absences` (keyed on SF externalCode, soft-deletes rows SF drops, same instance-authority gate as accounts) and **does not store the free-text comment** (the availability feed must never expose why someone is out). Reuses the existing daily `hr.leave_account_sync` cron (no new cron). `person_id` is a plain id column (no FK, TiDB-safe via `id_type_for`/`table_kw_for`, same as migration 324). **Endpoint contract**: lookup-only (caller MUST name people by work email, ≤200/call — the External API has no per-client population filter, so "you can only ask about people you already know" is the least-privilege boundary; there is deliberately no list-everyone endpoint). Tri-state per person: `covered` (has ≥1 leave account) / `no_data` (person exists but Helix has no leave source — the overseas case; **never collapses to "available"**) / `unknown_person`. Spans are coarsened: date range + `category` (`leave` — the specific type annual/sick/parental is never exposed) + `approval_status` + day count. Statuses `{APPROVED, PENDING, PENDING_CANCELLATION}` and a NULL (→`unknown`) count as out; rejected/cancelled are dropped. `meta.as_of` = feed's last sync time (daily cron; a leave filed today may lag one sync). **Coverage reality (prod 2026-08-06)**: CN 235 active / 226 covered; USA 73/13, JPN 38/0, SGP 9/1, rest overseas ≈0 — SF EC Time-Off administers the CN entity only (same shape the leave-health-insights feature already documents); "don't build a second overseas source" is a user decision, hence the explicit `no_data`. **No MCP** (user decision — a self-service MCP tool would let any employee query others' leave via an AI client); `_REGISTERED_TOOLS_COUNT_HINT` unchanged. Reference: `docs/api/external-api.md` §B.4 (+ zh-CN).

**Resource: Public holiday calendar (2026-08-12, migration 462)** — `app/domains/hr/open_holiday_router.py`: `GET /api/v1/open/hr/holidays` + `GET /api/v1/open/hr/holidays/countries` (`hr.holidays:read`). Same consumer class as B.4 (an on-call rota that must not page someone on a national holiday; a scheduler skipping non-working days), but unlike the absences lookup this **is a listing** — the data names no person and is already published to every employee, so there is no population boundary to enforce and no reason to force callers to name anything. Backed by the two new HR tables (see §"Public holidays" below). **Coverage tri-state is the load-bearing contract**: `covered` (HR maintains a calendar for ≥1 year the window touches) / `no_data` (a real country, but nobody has filled in a calendar for the years asked about) / `unknown_country` (the value did not resolve to a country code). `no_data` **never collapses to an empty `holidays` array** — same honesty rule as the absences feed's `no_data`, and for the same reason: a consumer that cannot distinguish "no holidays" from "nobody told us" will book work on a national holiday. Coverage is evaluated **per year the window intersects**, so a country maintained through 2026 reports `no_data` for a 2027 question rather than "no holidays in 2027". 调休 **makeup workdays are first-class entries** (`day_type=makeup_workday`), because a caller computing working days must subtract holidays *and add these back* or every 调休 Saturday reads as free; `observed_group` lets a caller fold a multi-day holiday back together. Country input accepts alpha-2 / alpha-3 / English or Chinese names via `core.geo.normalize_country_code`; an unresolvable code comes back as `unknown_country` **in the response** rather than 422-ing the whole call, so one bad code in a caller's list does not hide the 20 good ones. Bounds: ≤60 countries, ≤1098-day window, `year` conflicts with `from`/`to`. `meta.as_of` = newest `updated_at` across the returned scope. Reference: `docs/api/external-api.md` §B.5 (+ zh-CN). Tests: [`test_public_holidays_api.py`](../../server/tests/test_api_clients/test_public_holidays_api.py).

**Developer experience (`app/api_clients/open_meta_router.py`, 2026-06-15)** — the partner-facing onboarding surface (the global `/docs` `/redoc` `/openapi.json` are dev-only). `GET /api/v1/open/me` — verify endpoint (any valid `helix_api_` token, no scope) echoing client + granted scopes, the integrator's "first call". `GET /api/v1/open/openapi.json` — a **curated** OpenAPI built via `get_openapi(routes=…)` over only the External API's own `/api/v1/open` routes (excludes `/open/directory/*` — that's the separate identity-pull feature — and the meta endpoints); credential-gated (accepts a `helix_api_` token via header or `?key=`, OR a Helix access JWT). The cloud endpoints carry response examples so the spec shows real payloads. **Human-readable reference is in-app** (`web/src/pages/apiClients/ApiDocsPage.tsx`, route **`/api-docs`** — a universal authenticated route, NOT under the global_admin block, so any logged-in colleague can open the link) — an Ant Design `Anchor`-nav + content page (overview / auth / conventions / scopes / per-endpoint params + curl + response JSON) rendered inside the SPA. (An earlier backend-served Redoc page was removed: the API CSP `default-src 'none'` strips inline `<style>`/`<script>` and the CDN, so a backend HTML docs page can't run — the SPA is the right altitude. See CHANGELOG 2026-06-15.) Frontend: the ApiClientsPage token-mint flow shows a copy-paste **handoff block** (base URL + `Authorization` header + `/me` & list curls + scopes + rate limit + openapi.json link) so an admin can hand a partner everything in one paste; a header "API docs" button opens the in-app reference.

## 10.8 Partner directory (合作方通讯录, 2026-09-01, migration 587) {#108-partner-directory}

A read-only mirror of **another company's** staff roster, plus the
attribution it lets us compute. Today one partner is configured: CBG /
平凯星辰(北京), whose people hold `@pingcap.com` Google accounts, vendor
seats and GitHub handles in our systems while carrying `@pingcap.cn`
addresses in their own Lark tenant.

**Tables** ([`models.py`](../../server/app/domains/partner_directory/models.py))

* `partner_directory_members` — the mirror. Natural key
  `(integration_id, external_id)`, **not** email. Soft-delete only, and
  only from a `complete` snapshot.
* `partner_directory_links` — the attribution: one row per
  `(integration_id, surface, target_key)`, carrying `match_method`,
  `state`, `applied_at` and `apply_skipped_reason`. This table exists
  because the tag itself lands on `external_account_registry` and
  `github_org_memberships`, and **neither can hold the reasoning**.
  `email_localpart` is an inference, not a fact; "why is this Google
  account CBG's?" has to be answerable without trawling audit logs.

**Surfaces it attributes** — every one of them scoped to rows we could
NOT match to a person: `google_orphan_accounts` (definitionally
unmatched), `software_external_holder` with `category='external'`
(definitionally not an active employee, deduped by email since the
registry is email-keyed), `github_org_memberships` with
`person_id IS NULL`.

**Match methods.** `email_exact` / `email_localpart` / `github_id` are
identifiers the partner's IT assigned — treated as conclusive and
written automatically. `name` is not an identifier and only ever enters
the review queue. The **safety of `email_localpart` comes from the WHERE
clause, not from an assumption about collisions**: if a mailbox name is
shared with one of our active employees, that employee's row was never
in the candidate set. First production dry run: 0 matched rows belonged
to an active employee.

**Writes go through** [`partner_tag_coordinator`](../../server/app/core/coordinators/partner_tag_coordinator.py)
(domains must not import each other) and reuse the existing
`ExternalAccountRegistryService.ensure_retained_classification` /
`github_org.service.confirm_external` — the same primitives the human
path uses, so a machine-applied tag and a human-confirmed one behave
identically at offboarding. Neither can clobber an admin's explicit
classification; a refused write is recorded on the link, never dropped.

**Ambiguity is discarded, not resolved.** Two partner employees sharing
a mailbox name (or a GitHub handle) invalidate that key entirely — the
count is reported to the admin. Picking the first row would be picking
at random, and this matcher writes to production.

**First full pull (2026-09-01, CBG)**: 75 departments, 381 people, all in
service, every department readable; 380 on `@pingcap.cn`. Attribution:
41/60 Google orphans (11 never previously registered), 65/264 vendor-seat
emails, 41/217 unmatched GitHub handles.

**The `GitHub ID` custom field is hand-typed free text.** Of the 357
people who filled it in, only 236 hold a handle: 118 wrote the literal
「无」, 6 pasted a full profile URL (one on `github.cn`), 3 pasted a QQ
address, 1 prefixed `@`. `_clean_github_login` unwraps the two known
wrappers and then applies GitHub's own login syntax as the gate — the
raw value stays in `custom_attrs` so "nobody filled it in" and "we could
not parse what they filled in" stay distinguishable.

## 11. HR lifecycle automation

[`server/app/hr_lifecycle/`](../../server/app/hr_lifecycle):

- `hire_day_service.py` — runs at hire date (`hr.hire_day_it_notify`): fires onboarding notifications, opens IT setup tickets, executes onboarding provision tasks. Since 2026-07-03 the welcome ships on TWO channels with different timing: the rich HTML **email** at 09:00 (`hire_day_it_info` template), and the rich **Feishu card** (`hire_day_it_info.feishu_card`, `_build_welcome_card_fields` — same sections/blessing as the email, indigo header) as soon as the hire's Feishu account exists — attempted inline right after the email (covers late-confirmed hires whose account already exists) and otherwise delivered by the `hr.hire_day_feishu_welcome` 30-min sweep (`run_feishu_welcome_catchup`; idempotent via `"feishu" ∈ it_info_sent_channels`; 3-day window then gives up, e.g. contractors who never get Feishu). The card's outbox row sets `hidden_from_inbox` so the personal bell shows the welcome once, not twice. 2026-07-03: the HR team's separate Day-1 Feishu welcome was merged in (5 new-hire quick-guide wiki links in the IT section + a bilingual native 「前 90 天 · 融入计划」 timeline section replacing HR's EN-only bitmap; same content across email HTML / Feishu card / template text), and the module also owns `run_day90_survey` (`hr.day90_survey_notify` cron) — the 90-day onboarding-experience survey invite (`onboarding.day90_survey`, Feishu card + email, idempotent via `identity_persons.day90_survey_sent_at`, employee-local 90-day cutoff with a 90–97-day catch-up window).
- `advance_service.py` — runs on `hr.process_advance` (15-min cadence): auto-resolves blockers that have flipped resolved (e.g. SSO suspension applied, seat revoke confirmed), advances offboarding processes blocked → ready when no blockers remain, then (2026-06-03) **drives ready → completed in the same tick** via a guarded `complete_process` call after `suspend_sso` + `flip_to_left` — closing the gap where `left` people piled up forever at `ready`. `complete_process` now accepts `actor_user_id: UUID | None` (cron → audit `actor_type='system'`).
- `post_leaver_service.py` — runs on `hr.post_leaver_verify`: T+N day post-leaver checks (no residual access, mailbox handover complete, etc.).
- `digest_service.py` — runs on `hr.daily_digest`: assembles per-IT-admin daily summary (new offboardings, SLA breaches, pending approvals).
- `forensic_service.py` (2026-06-01, closes issue #622) — pure read-aggregator producing a per-person "case file" merged across 8 tables (`hr_lifecycle_events` + `workflow_step_events` + `provisioning_tasks` + `offboarding_processes` + `audit_logs` + `software_seat_allocations` + `cmdb_cloud_access_grants` + `identity_external_identities`), time-desc, with 4 KPIs (在司天数 / 当前 7 维资源持有 / 历史申请数 / in-flight). Routes: `GET /api/v1/lifecycle/forensic/{person_id}` (JSON) + `GET .../evidence-pack` (ZIP: manifest.json + timeline.json + timeline.csv + README.txt; each download writes audit row `lifecycle.forensic.evidence_pack_downloaded`). **Distinct from `/lifecycle/person/{id}`** — that one renders the in-flight pipeline phases; this one is the flat forensic event stream for the already-departed case. Soft-deleted persons are intentionally queryable (the whole point of forensic is to read the case file of someone gone). Per-source LIMIT 500 prevents OOM. `audit_logs.resource_id` is non-indexed → WHERE clause adds `created_at >= person.created_at` so the OR branch's scan stays bounded to person's lifetime. Cloud-grant lookup binds `str(person.id)` because `cmdb_cloud_access_grants.person_id` is String(36). `_TimelineRow.time` coerces naive → UTC-aware at construction (MySQL/TiDB round-trip strips tzinfo).

`hr.overdue_leavers_check` (separate handler in [`scheduled_jobs/handlers.py`](../../server/app/scheduled_jobs/handlers.py)) flags processes where `leaver_date < today` but status ≠ completed. **Local-timezone gate (2026-05-21, PR #443)**: the SQL prefilter is still UTC `termination_date <= today_utc` (index-friendly), but each candidate row is then refined in Python with `local_today = datetime.now(UTC).astimezone(resolve_timezone(person)).date()` — only flip to `left` when `local_today > termination_date`. Mirrors the same gate in `app.auth.service.is_effectively_departed` (Google SSO / Feishu SSO / password login / JWT middleware all 5 entry points). The result summary now carries `deferred_user_ids` for the people the gate is intentionally holding back until their local rollover. Reuses [`app/core/working_hours.resolve_timezone`](../../server/app/core/working_hours.py) from PR #440 — no new helper. Tests: [`tests/test_hr/test_overdue_leavers_local_tz.py`](../../server/tests/test_hr/test_overdue_leavers_local_tz.py) (13 cases).

### Event-driven HRIS lifecycle trigger (2026-06-02, migration 239)

**Architectural shift**: SF webhooks are now the primary trigger for joiner/leaver lifecycle automation. The historical full-sync path is retained as a reconciliation backstop only. Pre-2026-06-02 the webhook endpoint existed but was "half-wired" — it persisted the lifecycle ledger row + person upsert but never fired the downstream coordinator chain (provisioning, Google account creation, welcome email, offboarding-process start, identity-sync push), so lifecycle automation only ran when an IT admin manually confirmed an HRIS sync batch — even though SF had already pushed the authoritative event minutes earlier.

**The convergence point** is [`HrSyncService._dispatch_lifecycle_coordinators`](../../server/app/domains/hr/service.py). Both [`process_webhook_events`](../../server/app/domains/hr/service.py) (event path) and [`confirm_batch`](../../server/app/domains/hr/service.py) (sync path) call into it after [`_apply_event`](../../server/app/domains/hr/service.py), gated on `event_created=True`. The natural-key dedup in [`_upsert_lifecycle_event`](../../server/app/domains/hr/service.py) — `(person_id, event_type, effective_date)` — makes this gate the entire reconciliation contract: the second arrival (whichever path) returns `event_created=False`, dispatch is skipped, the ledger row stays single. No timestamps, no windows, no flags — the ledger row IS the dedup key.

**Schema**: [`hr_lifecycle_events.trigger_source`](../../server/app/domains/hr/models.py) (String(16) NOT NULL, default `'sync_confirm'`) carries `webhook` / `sync_confirm` / `initial_seed` / `manual`. Index `ix_hr_lifecycle_events_person_type_date` on `(person_id, event_type, effective_date)` covers the natural-key dedup lookup. Migration [`239_hr_lifecycle_trigger_source.py`](../../server/alembic/versions/239_hr_lifecycle_trigger_source.py).

**`hr_lifecycle_events.dispatched_at`** (nullable, migration [`439`](../../server/alembic/versions/439_hr_lifecycle_event_dispatched_at.py), 2026-07-31, #2220) — when the lifecycle coordinators actually ran for this row. Both apply paths (`confirm_batch` and `process_webhook_events`) **commit the person flip + ledger row BEFORE dispatching** (#2130 — never hold `identity_persons` locks across vendor round-trips), so `event_created` cannot express "committed but not yet dispatched": a dispatch that raised (transient 1205, blocker-scan `AppException`, a deploy killing the process) left a durable row that every later apply computed as `event_created=False` and skipped forever, while the staged row said "failed — retry next sync". The dispatch gate is now `event_created or upgraded_from_seed or dispatched_at IS NULL`, and `_mark_lifecycle_dispatched` stamps it only on success (an `initial_seed` pass deliberately leaves it NULL — that is what lets the first real webhook/confirm still fire). **Migration 439 backfills every pre-existing row with `created_at`**: NULL-means-undispatched over history would re-dispatch the entire ledger on the first post-deploy sync. `staged.status='confirmed'` likewise moved to after dispatch.

**Webhook routes**:
- `POST /api/v1/hr/successfactors/webhook` (single-tenant) + `POST /…/{integration_slug}` (multi-region — `sf-cn-prod`, `sf-eu-prod`, …). Two accepted auth schemes (pick one per integration): **(A) HTTP Basic** — `Authorization: Basic` matched against `webhook_basic_username`/`webhook_basic_password` (simple path for zero-code upstreams like SF Integration Center that can't sign a body); **(B) HMAC-SHA256** body signature over `timestamp + "." + body` with ±5min replay window. Optional source-IP allowlist (`webhook_ip_allowlist`, config_public CIDR list) gates BOTH schemes — non-empty ⇒ off-list caller `403 WEBHOOK_IP_NOT_ALLOWED` before auth; empty ⇒ allow-all. Mass-leaver brake (`max(25, 5% of active)`) always applies.
- `POST /api/v1/hr/successfactors/webhook/_diagnose` + scoped variant — same auth envelope, NO DB writes. Returns per-event `person_match` + an `unresolved_user_ids` bucket for HRIS-side iflow smoke tests. Audited under `hr.successfactors_webhook.diagnose`.
- Route order in [`router.py`](../../server/app/domains/hr/router.py): `_diagnose` declared BEFORE the `{integration_slug}` catch-all so FastAPI doesn't treat `_diagnose` as a literal slug and 404 the diagnostic.

**Admin debug surface**: `GET /api/v1/hr/lifecycle-events` (admin-only) returns the immutable ledger with filters by `trigger_source` / `event_type` / `person_id` / `since-until`. Frontend page at `/admin/hr/lifecycle-events` ([`HrisLifecycleEventsPage.tsx`](../../web/src/pages/hr/HrisLifecycleEventsPage.tsx)) renders the ledger with green chips on webhook rows, blue on sync_confirm — sustained spike in `sync_confirm` means SF isn't pushing reliably and the backstop is doing too much work.

**Dual-secret rolling rotation** ([`IntegrationService.rotate_webhook_hmac_secret`](../../server/app/integrations/service.py)): `POST /api/v1/integrations/{key}/webhook-secret/rotate` (admin-gated) generates a 256-bit `secrets.token_hex(32)`, moves the current `webhook_hmac_secret` to `webhook_hmac_secret_previous` with a 24h expiry (`…_previous_expires_at`), and returns the new plaintext **exactly once**. [`_authenticate_sf_webhook_request`](../../server/app/domains/hr/router.py) verifies the request against BOTH secrets while the previous expiry hasn't passed, so SF / CPI / Slack-style rotations land cleanly without dropping events. The audit row at `integration.webhook_secret_rotate` never includes the plaintext. UI surfaces this through the `Rotate webhook secret` button on every integration whose driver declares a `webhook_hmac_secret` field ([`IntegrationsPage.tsx::RotatedSecretModal`](../../web/src/pages/admin/IntegrationsPage.tsx)) — the modal blocks close-without-copy and never re-displays after dismissal. Companion `GET /api/v1/integrations/{key}/webhook-secret/status` returns booleans + the expiry for the UI badge without ever decrypting plaintext to a response.

**Basic-credential + IP-allowlist admin surface** (2026-06-23, sibling of the rotate flow, all `require_admin` + `require_browser_session`): `POST /api/v1/integrations/{key}/webhook-basic-credential/rotate` ([`IntegrationService.rotate_webhook_basic_credential`](../../server/app/integrations/service.py)) mints `webhook_basic_username` (`sf-webhook`) + a random `secrets.token_urlsafe(24)` password (encrypted into `secrets_encrypted`, returned **once**, audited `integration.webhook_basic_credential_rotate` sans plaintext); `PUT /…/webhook-ip-allowlist` validates/normalizes/dedupes CIDRs (`ipaddress.ip_network`, bad entry ⇒ `400 WEBHOOK_IP_ALLOWLIST_INVALID`, empty list clears); `GET /…/webhook-auth/status` returns which schemes are configured + the Basic username + the allowlist (never secrets). UI: the `Webhook 认证` button → [`IntegrationsPage.tsx::WebhookAuthModal`](../../web/src/pages/admin/IntegrationsPage.tsx) on every webhook-receiver integration. Both actions reuse `_require_webhook_receiver` (driver must declare `webhook_hmac_secret`). No MCP tool — admin integration-config surface, consistent with the rotate-secret precedent.

**Coordinator dispatch contract** (shared helper [`_dispatch_lifecycle_coordinators`](../../server/app/domains/hr/service.py)):

| event_type | action | coordinator fires |
|------------|--------|-------------------|
| `onboard` | `create` | `provision_for_new_hire` → `provision_google_account` → `send_welcome_notification` → `enqueue_identity_sync_for_hire` |
| `onboard` | `reactivate` | same chain as `create` |
| `onboard` | `update` | no-op (data refresh, not a lifecycle change) |
| `offboard` | `deactivate` | `start_offboarding_for` → after commit: `OffboardingService.dispatch_post_commit_actions` (force-logout + leaver/manager notifications) |
| any | initial_seed | no-op — historical bootstrap, the row exists for ledger continuity only |

**Per-event commit pattern** in `process_webhook_events` mirrors `confirm_batch`: short transactions release `identity_persons` row locks within ~100ms instead of holding for the entire webhook batch. Failures isolate to one user_id and bump `summary.errors` so the iflow can surface partial success (HTTP 200 with `errors > 0`) rather than retry the whole batch into the mass-leaver brake.

**Action derivation** in webhook path uses the PRE-apply person snapshot, not post-apply. A brand-new hire would otherwise read as `employment_status='active'` after `_apply_event` upserts them and route through the no-op `update` branch, silently dropping the welcome email + provisioning. Pre-apply capture is the load-bearing detail; tests in [`test_successfactors_webhook_coordinator.py`](../../server/tests/test_hr/test_successfactors_webhook_coordinator.py) pin this end-to-end.

**HRIS integration setup runbook**: [`docs/integrations/successfactors-webhook-setup.md`](../integrations/successfactors-webhook-setup.md) — the SF admin / CPI iflow author's contract document (URL/slug routing, header contract, HMAC pseudocode, payload schema, event-type mapping, recommended retry strategy, cutover & rollback).

---

## 11.5 Admin Health Console (2026-05-31, PR-A)

One-shot aggregate consumed by `/admin/health`, the IT admin's daily-check dashboard. Designed to close the "user-reports-bug → I-fix-bug → audit-round-finds-sibling" loop by surfacing the four classes of drift admins were missing.

**Endpoint**: `GET /api/v1/admin/health/overview?window_days=N` (1..180, clamped) →

```
{
  generated_at, window_days,
  panels: { cron, offboarding, notifications, data_health }
}
```

Built by [`app/domains/health/service.py`](../../server/app/domains/health/service.py)::`get_health_overview`. **Per-panel error isolation** — each builder runs in its own try/except; a raise lands as `{status: "error", error: <message>}` on that panel only, the other three still return.

**Sources reused** (no new tables, no new cron):

| Panel | Builder | Underlying call |
|------|---------|-----------------|
| `cron` | `_cron_panel` | `scheduler_health_summary` + `analytics_summary` (truncated to 6 KPI + top-5 / slowest-5) |
| `offboarding` | `_offboarding_panel` | `LeaverBlockedDashboardService.get_summary` + new `_bucket_for(leaver_date, today)` mapping into 6 T+x buckets (`t_minus_gt_7`, `t_minus_3_to_7`, `t_minus_0_to_3`, `t_plus_0_to_1`, `t_plus_1_to_3`, `t_plus_gt_3`) + pending-blocker `GROUP BY resource_type` for the long-pole HBar |
| `notifications` | `_notifications_panel` | `notification_outbox` direct select — lifetime `GROUP BY status`, 24h KPIs from `created_at >= now-24h`, 7d daily ok/failed series |
| `data_health` | `_data_health_panel` | `software_seat_sync_stat` × `software_license_plans` join (`vendor > max_seats` ⇒ under_licensed) + 6-bucket identity check (vendor != sum(buckets) ⇒ seat_drift) + `compliance_incidents.status='open'` count + `leaver_dual_sign_overrides` blocking-status count |

RBAC: `require_admin_read()` (global_admin + auditor + management implicit via `RoleRoute`). Frontend route is `roles={['global_admin', 'auditor']}` with `RoleRoute` auto-promoting management.

**Where each panel deep-links**:
- cron → `/admin/scheduled-jobs?tab=analytics` (existing `ScheduledJobsAnalyticsTab`)
- offboarding → `/admin/offboarding` (lifecycle + leaver-blocked tabs)
- notifications → `/admin/notifications/outbox`
- data_health → `/admin/compliance` (incidents) + `/admin/insights?tab=software` (seat insights)

The three existing per-page analytics tabs (`ScheduledJobsAnalyticsTab`, `AuditAnalyticsTab`, `ApprovalAnalyticsTab`) are intentionally kept — health console is the **speed-read**, those are the **drill-down**.

Test surface: [`tests/test_health/test_health_overview.py`](../../server/tests/test_health/test_health_overview.py) (10 cases — RBAC × 3, panel-by-panel asserts, T+x bucket classification, graceful-degrade monkeypatch).

Top navigation: under `ops-cockpit` group, ranked **first** (above `/admin/insights` and `/admin/ai-spending`) — IT admin's daily-check landing.

---

## 11.8 Reconciliation Center (2026-06-01, PR-E)

`/admin/reconciliation` — deep forensic complementing PR-A's health speed-read. Five lenses on orthogonal drift classes:

| Lens | Detection rule | Surface |
|------|----------------|---------|
| Seat | `vendor_license_holders > plan.max_seats` (under_licensed) OR `vendor != sum(6 buckets)` (identity_broken) | per-plan row with bucket breakdown |
| Ownership FK | resource `owner_person_id` is NULL / not_found / inactive / soft_deleted | per-resource-type list (6 types) |
| Leaver residual | resource owner_person_id points at `employment_status='left'` AND the resource is still in the active state (active grant / in_use asset / etc.) | cross-resource-type list keyed on the leaver |
| **Provisioning gap** (2026-08-06) | four buckets: approval still `pending` in Helix while `source_payload.latest_instance_detail.status` is already terminal; seat `active` + Helix-originated + connector bound + no completed grant task; task past its `not_before` and queued > 2h; task `failed` | per-bucket table, `status ∈ {ok, attention}` |
| **Cost drift** (2026-06-08) | per **closed** month × bucket, `abs(rollup − live_recompute) > max($1, 0.5%)` where rollup = `ai_spending_period_rollup` (current fingerprint) and live = `AiSpendingService._compute_period_rows` (the same path the open-month dashboard takes — reused, not re-implemented) | per bucket-month finding with `likely_cause ∈ {stale_fingerprint, not_materialized, late_facts_or_drift}` |

The **provisioning-gap** lens (`reconciliation/service.py::provisioning_gap`, MCP `provisioning_gap_overview`) exists because the 2026-08-06 Zoom-seat incident — an approved seat request that never reached the vendor — had **no representation in any screen** for 20 hours while the retry cron reported `last_status=ok`. Its value is entirely in predicate narrowness, and two obvious formulations were measured against production and rejected: "active seat with no completed grant task" returns **2,700+** rows (most seats are vendor-imported by the sync crons and legitimately never had a task), and "revoke task still pending" returns **39** (all deliberately deferred to the leaver's last working day via `not_before`). Either would have made the tab a wall of false positives. The shipped predicates measured **0 / 2 / 0 / 0** on production. `pending` seats are excluded from the seat bucket on purpose — one is not yet granted by definition, and the stuck-forever case belongs to the stale-approval bucket, keeping the two disjoint. The stale-approval check reads the snapshot the 15-minute drift reconciler refreshes, so it costs **no vendor round-trip**.

The cost lens is NOT a "three cost tables must agree" check — `cmdb_cost_facts` IS the vendor extract for the Foundry/Bedrock/Vertex buckets, `bailian_usage_daily` is the sole source for the bailian bucket, and seats come from `SoftwareSeatAllocation ⋈ SoftwareLicensePlan`; there is no independent third source. The only automatable reconciliation is "does the materialised rollup still equal a fresh live recompute from those same sources" (the rollup is a faithful cache). Cloud/Bailian **consoles** are NOT reconciled (no API-equivalent口径) — surfaced as a `data_caveat` ("console is source of truth for absolute spend"). Implemented in [`server/app/domains/ai_spending/cost_drift.py`](../../server/app/domains/ai_spending/cost_drift.py) (`cost_rollup_drift`); the reconciliation lens calls it through a thin pass-through (`reconciliation/service.py::cost_drift`). Also driven daily by the `ai_spending.cost_drift_check` cron (07:00 UTC, after `period_rollup_refresh` at 06:30) which **auto-heals first**: a supra-threshold finding triggers `refresh_period_rollup` on the drifted window (committed immediately) and a re-check — this absorbs late/restated facts, including months older than the refresh cron's `recent_months` always-recompute window, which would otherwise alarm daily forever since facts are not part of the fingerprint (2026-07-28 incident: an out-of-band backfill restated 2026-04 bedrock between the 06:30 refresh and the 07:00 check). Only a still-drifted re-check **raises** so the failure_notifier alarms (signature-deduped on the drifting bucket set). Exposed read-only over MCP as `cost_rollup_drift`.

The cost lens also carries a **`plan_currency` sub-block** (`plan_currency_findings`, 2026-06-08): for every `category='ai'` seat plan it computes the per-seat monthly USD (same cycle + FX path the seats bucket uses) and flags `non_usd_currency_with_active_seats` (a non-USD currency on a plan with active seats — the CNY-mis-entry blind spot that `_seat_native_to_usd` doesn't drop, since CNY is a *supported* currency) and `implausible_per_seat_usd` (converted per-seat outside `[$1, $1000]`). **Detection only — never auto-corrects** (legitimately CNY-priced AI plans exist). The AI-spending dashboard's seats bucket also carries a `currency_warning` flag (cheap single-query `has_suspicious_ai_seat_currency`) → a warning chip nudging admins to the cost lens. FX-missing degrades gracefully (uses the None-returning `fx_rate.get_usd_per_cny`, not the raising `_get_cny_rate`). Memory [[reference_ai_seats_plan_currency_and_rollup_fingerprint]].

Service: [`server/app/domains/reconciliation/service.py`](../../server/app/domains/reconciliation/service.py). Endpoints under `/api/v1/admin/reconciliation/` gated with `require_admin_read` (auditor + management can browse). One `/overview` round-trip + per-lens singletons for tab-driven loading.

The page is **read-only by design**: row-level actions (open compliance incident, force-revoke, ack drift) live on the existing admin pages each row deep-links to. This keeps the lens code small and the per-resource RBAC where it already is.

**Terminal-state filter (2026-06-01 fix)**: ownership + leaver lenses both filter out resources in terminal lifecycle states — closed/decommissioned cloud scopes (`status='closed'` OR `decommissioned_at` set), offline CDN domains (`status='offline'`), expired root domains (`status='expired'`), and disposed/retired assets (`status IN ('disposed','disposal_pending','retired')`). These are **intentionally orphaned** by their respective lifecycle flows (decommission_scope clears the owner on close; asset disposal preserves `assigned_to` only for audit-trail provenance) — surfacing them as "no owner" drift is noise, not signal, and drowns out the actually-actionable rows. Mirrors the same exclusion the admin overview health console has used since launch ([`admin_overview_service.py:172`](../../server/app/domains/cmdb/admin_overview_service.py) — "Owner health — only meaningful for non-closed scopes"). DNS `status='disable'` is a reversible toggle (not terminal) so DNS owner-FK is checked regardless. API keys are already filtered to `status='active'`. Pinned by three regression tests in [`test_reconciliation.py`](../../server/tests/test_reconciliation/test_reconciliation.py).

---

## 12.7 Manager team views (2026-05-31, PR-D)

`/my/team/footprint` + `/my/team/cloud` — manager self-service for "what do my reports hold on the platform?"

**Backend**: [`server/app/domains/me_universe/team_service.py`](../../server/app/domains/me_universe/team_service.py) reuses three existing primitives:

- `get_subordinate_person_ids(db, manager_id)` — BFS depth 5, cycle-safe (from `core/rbac.py`)
- `subordinates_cost_breakdown(db, person_ids, start, end)` — closed previous-month cloud spend per person (from `cmdb/cost_service.py`)
- Per-domain `GROUP BY person_id` counts via a small `_counts_by_person` helper

Endpoints under `/api/v1/me/team/`:
- `GET /footprint` → `{manager_id, subordinates_count, rows[8-dimension footprint + cost], cost_window}`
- `GET /cloud` → `{grants[], scopes[], dns[], cdn[], domains[]}` with owner enrichment
- `GET /api-keys` (2026-06-29) → `{month, api_keys[], summary}` — AI API keys **held** (`assigned_to`) by direct/transitive reports with status ∈ {active, pending_release}, each enriched with its current-month spend via `apikey.spend.compute_assignment_spend` (same dispatcher the per-AK detail page uses, so the numbers reconcile to the cent), monthly budget, `utilization_pct`, and `over_budget` / `data_available` flags. `summary` carries `total_mtd_usd` (data-available rows only), `total_budget_usd`, `over_budget_count`, `approaching_count`, `data_unavailable_count`. Surfaces as the "API Key" tab on the `/my/team/cloud` page (`TeamCloudPage`, lazy-fetched on tab open). MCP `list_team_api_keys` (sibling of `list_team_lifecycle`). Read-only — managers already see a report's last-month cloud cost on `/footprint`, so per-key spend introduces no new authority; mutations stay on `/me/api-keys` + the admin console.

Empty subordinates set returns 200 OK with empty rows (NOT 403) so leaf-node employees see a friendly "no team" state.

**Schema quirks** captured in the module docstring (and respected at every IN clause):
- `cmdb_*.owner_person_id` are `String(36)` → must pass `str(uuid)` (memory `reference_asyncmy_uuid_bind`)
- `CmdbCloudAccessGrant.person_id` / `.status` / `.role_key` (not `assignee_person_id` / `grant_status` / `role_name`)
- `ApiKeyAssignment.assigned_to` (not `assignee_person_id`)
- `CmdbDomain.domain_name` / `CmdbCdnDomain.domain_name` / `CmdbDnsRecord.record_type` / `CmdbCloudScope.display_name`

**Frontend**: two pages under `web/src/pages/team/`. Row click on the footprint table deep-links to the unified `/me/<person_id>` (MeUniverseService already supports manager-view access). Top-nav new "My Team" group with two children.

---

## 11.7 SLO + Prometheus /metrics (2026-05-31, PR-C)

Every registered handler now carries an `SLOTarget` (success rate floor, p95 latency ceiling, max consecutive failures, P0/P1/P2 tier). The decorator accepts an optional `slo=` kwarg; omitted handlers inherit `DEFAULT_SLO` (P2, 80% success rate). 6 named SLOs are seeded for production-critical crons (advance/escalation P0; outbox/digest/overdue P1; cost-facts P2 with tight tolerance).

**Prometheus exposition**: `GET /metrics` (no `/api/v1` prefix) emits nine metric families in Prometheus text/plain format. Auth is via optional `Authorization: Bearer <token>` against `PLATFORM_METRICS_TOKEN` env — empty token = open endpoint (suits VPC-internal deployments), set token = scrapers must authenticate. **In non-development `APP_ENV` the token is REQUIRED** (startup `RuntimeError`, mirror of `CREDENTIAL_ENCRYPTION_KEY` fail-fast) — Round-62 hardening, the empty default would otherwise expose cron topology / offboarding SLA buckets / compliance incidents to anyone hitting the public ALB. Computed live per scrape; ~50 handlers + a few hundred thousand `scheduled_job_runs` rows means the cost is bounded but the endpoint shouldn't be scraped sub-second.

Families:
- `helix_scheduled_job_runs_total{handler_key,status}` Counter — emits exactly two label values: `status="ok"` and `status="failed"`. Sourced from monotonic `ScheduledJob.run_count` / `failure_count` columns (Round-62 rewrite — the prior `COUNT(*) GROUP BY` over `scheduled_job_runs` violated Prometheus counter semantics in TWO ways: (a) `running` rows decrement when UPDATEd to terminal, (b) retention-pruning of `scheduled_job_runs` to 100 rows per job rolled ok rows out faster than failures, drifting `runs_total{status="ok"}` downward on the busiest handlers). `running` runs are exposed separately as the sibling gauge `helix_scheduled_job_runs_active`; `skipped` runs are single-flight collisions (worker tick collided with prior run still holding the lock), not real attempts, and excluded by design.
- `helix_scheduled_job_runs_active{handler_key}` Gauge — current `status='running'` count, emitted per registered handler. Sibling to `_runs_total` for the transient running signal.
- `helix_scheduled_job_last_status{handler_key}` Gauge (1=ok / 0=failed / -1=skipped/running/never_run)
- `helix_scheduled_job_failure_count{handler_key}` Gauge
- `helix_scheduled_job_unacked_failures{handler_key}` Gauge (emitted for every registered handler so absence vs zero is explicit)
- `helix_scheduled_job_slo_status{handler_key,tier}` Gauge (1.0=ok / 0.5=warn / 0.0=breach / NaN=no_data)
- `helix_offboarding_in_flight{sla}` Gauge
- `helix_notification_outbox_by_status{status}` Gauge
- `helix_compliance_incidents_open`, `helix_under_licensed_plans` Gauges

**SLO evaluator**: [`slo_evaluator.py::evaluate_handler_slo`](../../server/app/scheduled_jobs/slo_evaluator.py) computes actual signals from the last `window_days` of runs and returns `{handler_key, tier, status, actual, target, breaches}`. The same call powers `/api/v1/admin/health/slo` (used by the 5th panel of the health console) and the Prometheus `helix_scheduled_job_slo_status` line.

**failure_notifier tiering**: card titles get tier emoji (P0=🚨 / P1=⚠️ / P2=ℹ️) plus `[Px]` badge; subtitle carries the tier's urgency phrase; P0 failures escalate beyond IT admins to the `management` role (de-duplicated by person.id so a holder of both roles still gets one card). Tier resolves at dispatch time via `registry.get_handler(handler_key).slo.tier` — code-changing an SLO reflects on the next failure with no backfill.

---

## 11.5b Onboarding domain — state machine + cockpit (refactored 2026-06-01, PR-1)

**Promoted from read-only aggregator to first-class state-machine domain** in the 2026-06-01 symmetric-onboarding PR-1. The cockpit's response shape is unchanged so frontend deep-links keep working, but every dimension is now resolved off `onboarding_processes` + `onboarding_blockers` instead of recomputed per request.

### Tables (migration 235)

| Table | Purpose |
|-------|---------|
| `onboarding_processes` | One row per person undergoing onboarding. State machine: `scanning → preparing → ready → live → completed` (+ `cancelled`). Columns: `person_id` (UNIQUE), `hire_date`, `status`, stage timestamps, `current_step` + `step_log`, `first_ungated_at` + `sla_breach_level` (none / warn / urgent / breach), `workflow_run_id`, `hardware_allocation` JSON (PR-3 per-slot source map), `backfilled` BOOL (migration 235 seeded rows). |
| `onboarding_blockers` | Per-resource Day-1 gap. Columns: `process_id` (CASCADE), `resource_type`, `resource_id` (nullable when no concrete row yet), `severity` (blocker / advisory), `resolution` (pending / provisioned / skipped / identity_sync_managed), `provisioning_task_id` (FK), `last_error_message` / `last_error_at` / `error_count` (mirrors offboarding migration 148). |
| `onboarding_reminder_log` | Idempotency markers — UNIQUE (process_id, reminder_kind). Kinds (all IT-facing since 2026-06-15): `day0_it_gaps / sla_urgent / sla_breach / t7_verify_failed`. (Manager 7d/3d/1d/day0 reminder cards removed 2026-06-15.) A marker is one-shot per onboarding cycle, so anything that must fire again after a regression has to **delete** it: `start_process` clears the date-anchored kinds on a hire_date correction and all kinds on a boomerang re-hire; `_recompute_status` clears day0/sla on full recovery; `t7_verify` clears `t7_verify_failed` when a day1_gap incident was resolved while the gap was still open (2026-08-18). |
| `onboarding_ack_items` (migration 278, 2026-06-12) | Admin-managed 入职须知 checklist — `title` / `description` / `doc_url` (Feishu doc deep link) / `required` / `is_active` / `sort_order`. Items with acknowledgements refuse hard delete (409 — compliance evidence); deactivate instead. Managed at `OnboardingManagementPage ?tab=ack`. |
| `onboarding_acknowledgements` (migration 278) | One row per (item, person) confirm — UNIQUE pair, idempotent re-ack, `source ∈ {web, mobile, mcp}`, courtesy `process_id` back-pointer (SET NULL). Person-keyed so a boomerang re-hire's prior confirms survive. |

### Resource types (9 — `RES_*` constants in `models.py`)

`work_email` / `sso_provision` / `feishu_contact` / `software_seat` / `hardware_allocation` / `vpn` / `email_group` / `ai_tool_default` / `training_ack`. **All 9 live** (onboarding-parity PR1 closed the 3 stubs):

* `email_group` — **policy-aware**: of the seat plans active `ProvisionPolicy` rows promise the person (canonical matcher: [`app/core/provision_match.py`](../../server/app/core/provision_match.py), shared with the provisioning service), the subset whose software has an active `software_connectors` binding with `operation='group_member'` on a `google_workspace` integration must each have an active seat allocation. Blocker label names the missing groups. No matching policy → vacuously satisfied.
* `ai_tool_default` — same policy-promise shape, subset = plans whose software `category='ai'`.
* `training_ack` — every `is_active AND required` `onboarding_ack_items` row must have an `onboarding_acknowledgements` row for the hire. No items configured → satisfied. Employee confirm rescans the open process immediately (no 5-min cron wait).

### State machine

```
HRIS confirm (or manual /processes POST in future)
  → scanning   (start_process row just created)
  → preparing  (≥1 pending blocker, hire_date future)
  → ready      (no pending blockers, hire_date future)
  → live       (hire_date arrived: T+0 cron flips ready / preparing → live;
                manual /advance-now also flips ready → live)
  → completed  (T+7 verify pass → flips live → completed)
  ── cancelled (admin abort, HRIS rescinded offer)
```

**Reopening**: a `completed` or `cancelled` process with a fresh HRIS hire event (boomerang re-hire) is reset to `scanning` + rescan. Audit chain preserved.

**Idempotent re-entry**: same `hire_date` on an open process is a no-op shape-wise (audit row records the touch). Different `hire_date` updates in-place + rescans + audits the change.

### Scheduled jobs (3 — migration 235 seeds)

| Key | Cadence | What it does |
|-----|---------|--------------|
| `onboarding.reconcile_processes` | every 5 min | rescans every `scanning / preparing / ready / live` process, flips blockers to / from `pending`, drives state machine. Also runs `OnboardingT0AdvanceService.advance_all_due` (`ready / preparing → live` when hire_date is today or past) in the same tick to consolidate cron count. |
| `onboarding.escalation_tick` | every 30 min | recomputes `sla_breach_level` for every `preparing / live` process, then runs the two IT-only escalation dispatchers (`day0_it_gaps` + `sla_urgent/breach`). Manager-facing 7d/3d/1d/day0 reminder cards were removed 2026-06-15 (HRIS owns join/leave reminders). |
| `onboarding.t7_verify` | daily 02:30 Asia/Shanghai | for every `live` process whose `live_at` ≤ now-7d: rescan, flip to `completed` if clear, else open `compliance_incident` (`type=onboarding_day1_gap`, `severity=warning`) + write `t7_verify_failed` reminder log (dedup-aware). **`backfilled=true` rows go straight to completed** — the platform never observed their Day-1 readiness end-to-end. Since 2026-08-18 the same tick also keeps the incident honest in both directions: **re-arm** — a `resolved` incident whose process is still `live` with the same hard gap `T7_REOPEN_GRACE_DAYS` (3d) later drops the dedup marker and re-opens (an `accepted` incident never re-opens — that's the admin's known-exception call); **auto-close** — `_close_incidents_on_terminal_processes` resolves any `open` day1_gap incident whose process has since reached `completed`/`cancelled` (system close, `resolved_by_person_id` NULL), which covers every terminal path plus pre-existing stale rows. |

### Entry path — HR sync coordinator hook

`server/app/core/coordinators/hr_sync_coordinator.py::provision_for_new_hire` calls `OnboardingService.start_process(person.id, person.hire_date)` **unconditionally** — even when `hire_date` is in the future. The provisioning fan-out itself still defers via `_is_future_hire`, but the process row + initial scan exist immediately so the cockpit / `/my/team-joiners` (PR-3) can show pre-arrival visibility without waiting for the daily `hr_lifecycle.hire_day` cron.

### Endpoints (`/api/v1/onboarding/*` — all admin-gated)

| Method | Path | Notes |
|--------|------|-------|
| GET | `/cockpit/queue` | Cockpit aggregator — backward-compatible response shape (`{kpis, feishu, rows}`); now reads from process+blocker tables. |
| GET | `/processes` | List with `status` / `open_only` / `hire_date_from` / `hire_date_to` filters, pagination (`limit ≤ 200`). |
| GET | `/processes/{id}` | Process + every blocker, each with `compute_triage_bucket`. |
| PUT | `/processes/{id}/hardware-allocation` | PR-3 plumb-in for the per-slot source map. |
| POST | `/processes/{id}/reconcile` | Manual refresh trigger (admin). |
| POST | `/processes/{id}/advance-now` | Force `ready → live` ahead of the cron tick. 409 if not `ready`. |
| POST | `/processes/{id}/mark-live` | Manual 「直接就位」 override (2026-07-06) — forces any OPEN process (scanning/preparing/ready) straight to `live` for a hire an admin verified is set up out-of-band, skipping the scanner gate `advance-now` requires. 409 on already-`live` or terminal. Audits `onboarding.marked_live_manual` with `prior_status`. Surfaced as a per-row primary button (global_admin, Popconfirm) on the cockpit. |
| POST | `/processes/{id}/cancel` | Admin abort (HRIS rescinded). Idempotent on terminal. |
| POST | `/blockers/{id}/skip` | Reason required (1–512 chars). |
| POST | `/blockers/{id}/mark-resolved` | Optional admin note. |
| GET | `/analytics/day1-readiness` | Day-1 readiness scorecard (PR5, 2026-06-12): per-hire-month rate of `ready_at <= hire_date` (Asia/Shanghai), `months` 1–24; **excludes backfilled + cancelled** (both would lie); plus past-due hard-gap debt count. Renders as the cockpit's monthly dashboard rings; MCP twin `onboarding_day1_readiness_analytics`. `require_admin_read`. |
| GET | `/ack-items` | 入职须知 admin list (`include_inactive` flag; `require_admin_read`). |
| POST / PUT / DELETE | `/ack-items[/{id}]` | 入职须知 CRUD (`require_admin`); DELETE 409s once any hire confirmed the item. |
| POST | `/me/onboarding/acknowledgements/{item_id}` | Employee confirm — self-scoped, idempotent, rescans the caller's open process. (Lives in `me_router.py`, listed here for completeness.) |

### MCP tools (8 — `_REGISTERED_TOOLS_COUNT_HINT` 205 → 213; +4 入职须知 2026-06-12, 273 → 277)

1:1 mirror of the HTTP routes (per MCP-sync rule):

* `list_onboarding_processes` / `get_onboarding_process` — admin-read
* `set_onboarding_hardware_allocation` — admin-write
* `advance_onboarding_now` / `cancel_onboarding` — admin-write
* `skip_onboarding_blocker` / `mark_onboarding_blocker_resolved` — admin-write
* `list_onboarding_ack_items` (admin-read) / `upsert_onboarding_ack_item` / `delete_onboarding_ack_item` (admin-write) / `acknowledge_onboarding_item` (employee self-scoped, mutates) — 入职须知 checklist

### Cockpit (read-aggregator)

`/admin/lifecycle/onboarding?tab=cockpit` (PR-1 consolidation — formerly `/admin/onboarding/cockpit`, now a tab inside `OnboardingManagementPage` alongside 进度 / 策略). One screen for every hire with `hire_date` in `[today, today+14d]` (Asia/Shanghai-anchored) — backend: `OnboardingCockpitService` ([`server/app/domains/onboarding/cockpit_service.py`](../../server/app/domains/onboarding/cockpit_service.py)). Ten dimensions (7 → 10 on 2026-06-12): feishu/seat/computer/vpn/email_group/ai_tool/training resolve off pending-blocker resource_types; account/sso/work_email resolve off the live login identity (2026-07-06):

**One endpoint:** `GET /api/v1/onboarding/cockpit/queue` (`require_admin_read` — global_admin / auditor / management). Returns `{kpis, feishu, rows}`; no pagination (steady-state is a few dozen rows, the cockpit wants the whole set for client-side sort/filter). All signals are batch-queried (no N+1):

**account / sso / work_email derive from the LIVE login identity** (fixed 2026-07-06 — see below), not employment status / any identity row / a reserved address. Each row also carries `dimension_states: {dim → ready|reserved|pending}` (only `work_email` uses the `reserved` middle state); `dimensions` stays a strict ready-bool for backward compat (reserved ⇒ not-ready ⇒ excluded from `fully_ready` / KPIs).

| Dim | Source | Ready when |
|-----|--------|-----------|
| account | `identity_external_identities` (login only) | ≥1 active row `is_login_identity=True` + `provider ∈ {google,feishu}` (a real account/mailbox minted) |
| sso | `identity_external_identities` (login only) | same as account — **excludes the `successfactors` HRIS-source row** (`is_login_identity=False`), which is a directory record, not a login |
| work_email | login identity + `identity_persons.work_email` | **ready** = live mailbox (login identity exists); **reserved** = SF address planned (`@`) but no live account yet (amber ⏲, NOT green); **pending** = neither |
| feishu | `identity_sync_targets` (kind=`feishu_contact`) | target enabled + `last_run_status ∈ {ok,success}` — **global signal, shared by every row** (target health, not per-person push, is the Day-1 gate) |
| seat | `software_seat_allocations` | ≥1 row `status='active'` |
| computer | `asset_items` | a `laptop/desktop/computer` with `assigned_to=person` |
| vpn | `vpn_applications` | a row `status='approved'` (`person_id` is a String column — bind `str(uuid)`) |
| email_group | `provision_policies` × `software_connectors` × `software_seat_allocations` | every policy-promised Google-group plan (`operation='group_member'` binding on a `google_workspace` integration) has an active seat; vacuous when no policy matches |
| ai_tool | `provision_policies` × `asset_software_catalog.category='ai'` × `software_seat_allocations` | every policy-promised AI plan has an active seat; vacuous when no policy matches |
| training | `onboarding_ack_items` × `onboarding_acknowledgements` | every active+required 入职须知 item confirmed by the hire; vacuous when none configured |

The `feishu.target_id` is surfaced so the page's inline "trigger sync" action can call the existing `POST /identity-sync/targets/{target_id}/sync-person/{person_id}`. Rows sort `hire_date asc → ready_count asc`.

**False-green readiness fix (2026-07-06).** Before this, account greened off `employment_status='active'`, sso counted *any* active identity row (including the `successfactors` HRIS-source row every person carries), and work_email greened on any `@`-containing planned address — so a US hire (P10002) with **no Google account at all** read fully-ready. Now [`service._scan_dimensions`](../../server/app/domains/onboarding/service.py) counts only `is_login_identity=True` for the SSO/work_email blockers (state machine treats work_email as a **live-mailbox** gate), and [`cockpit_service`](../../server/app/domains/onboarding/cockpit_service.py) resolves account/sso/work_email off the live login-identity set directly (bypassing the blocker set), emitting `dimension_states` with the amber `reserved` state for a planned-but-not-live mailbox.

**Per-hire account-action timeline (2026-07-06; SF input projection added 2026-08-17).** `GET /api/v1/onboarding/processes/{id}/account-timeline` (`require_admin_read`; MCP twin `get_onboarding_account_timeline`) → `OnboardingService.get_account_action_timeline` unions action audits with distinct pre-transform SuccessFactors snapshots already persisted in `hr_sync_staged_events`. SF rows expose display/structured name, 邮箱名, primary email, staged action, trigger/mode and placeholder risk flags, so the page answers both “SF sent what?” and “Helix did what / why did it fail?” without a new event store or backfill. Warning snapshots are orange and human-readable.

**Page:** [`OnboardingCockpitPage.tsx`](../../web/src/pages/onboarding/OnboardingCockpitPage.tsx) — supports `embedded?: boolean` prop; mounted as the default tab of [`OnboardingManagementPage`](../../web/src/pages/provisioning/OnboardingManagementPage.tsx). Legacy `/admin/onboarding/cockpit` route redirects to `?tab=cockpit`. PR-2 (2026-06-01) added a `详情` button per row that deep-links to [`/admin/onboarding/process/{id}`](../../web/src/pages/onboarding/OnboardingProcessDetailPage.tsx) — three blocker-bucket tabs (NEEDS_IT / AUTO_IN_PROGRESS / RESOLVED) + per-row mark-resolved / skip+reason + top-bar apply-bundle dropdown / advance-now / cancel + the account-action timeline card. Row action **指定电脑** deep-links to `/admin/assets?tab=prep&hire=<person_id>` (auto-opens that hire's reserve modal in the hardware-prep worklist, scoped — not a generic asset dump); **触发同步** carries a tooltip naming the Feishu-directory (`feishu_contact`) target it pushes to. **UX tweaks (2026-07-06)**: the view filter now defaults to **待处理 (needs_attention)** instead of 全部 (open the cockpit on the actionable backlog); rows are ordered **soonest-to-onboard first** (upcoming hires nearest-first via `days_to_hire`, then already-started stragglers most-recent-first — replaces the plain `hire_date asc` that buried imminent hires under past-dated `live` rows); each non-`live` row gets a primary **直接就位** button (global_admin, Popconfirm) that POSTs `mark-live`.

### New-hire self-service (PR-4, 2026-06-01; folded into /my/lifecycle 2026-06-04)

The new hire's own 10-dim Day-1 readiness (manager + self-service deep-links Request VPN / Request Software + the 入职须知 confirm checklist, `acknowledgements` array since 2026-06-12) is backed by [`GET /api/v1/me/onboarding`](../../server/app/domains/onboarding/me_router.py); confirms POST to `/me/onboarding/acknowledgements/{item_id}` (`?source=web|mobile`, regex-constrained; the MCP tool stamps `mcp`). Employee MCP tools: `get_my_onboarding_status` + `acknowledge_onboarding_item`. Self-scoped — no admin role; `NO_OPEN_ONBOARDING_PROCESS` if caller is past the 60-day window.

**H5 mirror (onboarding-parity PR4, 2026-06-12)**: [`MMyLifecyclePage`](../../web/src/mobile/pages/MMyLifecyclePage.tsx) (`/m/lifecycle`) renders the same readiness payload during onboarding — Day-1 progress bar + 10-dim list, the 入职须知 confirm checklist (idempotent confirm with `source=mobile`, per-item re-entry guard), and the manager contact card; seat/vpn gaps deep-link `/m/apps` / `/m/requests`. Readiness fetch failure degrades gracefully (primary lifecycle view stays). Zero new notification surfaces.

**Surface (2026-06-04):** the standalone `MyOnboardingPage` / `/me/onboarding` menu entry was removed — employees had two near-redundant menu items (`我的入职` + `我的入离职`) and the onboarding one went stale once onboarding completed. The readiness content now renders **inside** [`MyLifecyclePage`](../../web/src/pages/my/MyLifecyclePage.tsx) (`/my/lifecycle`), but only when `flow.track === 'onboarding'` and the readiness payload exists (fetched via `lifecycleApi.getMyOnboarding()`). `/me/onboarding` route now `<Navigate replace>`s to `/my/lifecycle`. The endpoint + MCP tool are unchanged.

### Manager team-joiners (PR-3, 2026-06-01)

[`/my/team-joiners`](../../web/src/pages/my/MyTeamJoinersPage.tsx) mirrors offboarding's `/my/team-leavers` for incoming hires. A manager (direct or skip-level) sees their team's onboarding processes and can set a per-asset-slot device-inheritance preference per row. Backed by [`OnboardingService.list_my_team_joiners`](../../server/app/domains/onboarding/service.py) + [`manager_router.py`](../../server/app/domains/onboarding/manager_router.py) (2 endpoints under `/api/v1/my/team-joiners/*`; gated by `_viewer_can_manage_process`).

**Manager reminder cards REMOVED (2026-06-15)**: the former 7d/3d/1d/day0 manager-facing reminder cards (`dispatch_team_joiner_reminders` / `REMINDER_7D/3D/1D/DAY0_MANAGER`) are gone. Rationale: HRIS already reminds managers of joins/leaves, and account/asset readiness is IT's worklist — a manager card with no manager action is "莫名其妙的提醒". Every onboarding push that remains is IT-only and maps to a concrete IT action.

**IT escalation chain (onboarding-parity PR2 + 打扰预算红线 hotfix, 2026-06-12; rewritten actionable 2026-06-15)** — `onboarding.escalation_tick` runs two **IT-only** dispatchers:

* `dispatch_day0_it_gaps` — for every open process with `hire_date <= today` that still has pending **BLOCKER-severity** dimensions: one card (single-shot per process, kind `day0_it_gaps`) to every IT global_admin. Card is plain-language (`电脑设备未发放` not `[blocker] Hardware allocation`) + one explicit CTA naming the resolve buttons; deep link `/admin/onboarding/process/{id}`. Advisory-only debt does not fire it.
* `dispatch_sla_escalations` — `sla_breach_level == urgent` (24–72h past hire_date) → IT (kind `sla_urgent`); `breach` (>72h) → IT (kind `sla_breach`). **IT only — no managers at any tier.** Single-shot per (process, tier) via the reminder log. `_it_gap_label` maps `resource_type → 中文缺口短语` for the card body.

**SLA is BLOCKER-driven** (hotfix 2026-06-12): `evaluate_sla` counts only `severity='blocker'` pending dims. Advisory dims (feishu target health / VPN / policy promises) are visibility items — they previously pushed the whole historical in-flight backlog to urgent/breach on deploy day and paged people about non-actionable noise.

Recipients resolve through `_list_it_admins` (active `global_admin` / legacy `it_admin`; `management` deliberately excluded per the admin-broadcast fan-out rule). The daily `lifecycle_daily_digest` also gained a 入职 readiness layer (PR2): each upcoming hire's line carries ✅ 就绪 / ⚠️ 缺 N 项, plus a 【入职就绪】 section with the org-wide open-gap count split urgent/breach and a `/admin/lifecycle/onboarding` entry link.

2 manager-side MCP tools: `list_my_team_joiners` / `set_my_team_joiner_hardware_allocation` (the buddy tool was removed 2026-06-15). Caller MUST be the hire's direct or skip-level manager (same gate as the HTTP route).

### Bundles (PR-2, 2026-06-01)

[`provisioning_bundles`](../../server/app/domains/provisioning/models.py) groups N `ProvisionPolicy` rows under a named "SDE 标准包" / "销售标准包". Admin applies a bundle to a single in-flight onboarding process via [`BundleService.apply_to_person`](../../server/app/domains/provisioning/bundle_service.py) (HTTP `POST /api/v1/provisioning/bundles/{id}/apply-to-person`). The matching engine is UNCHANGED — bundles are a grouping + one-click apply surface (not a substitute for `ProvisionPolicy`'s 3-dim HRIS attribute matching).

Apply semantics: walks the bundle's active policy set through `ProvisioningService._build_preview_items`, creates a pending `ProvisioningTask` for every target the person doesn't already have. `task.source='bundle_apply'` (new VALID_SOURCES entry) so analytics / dedup counters separate bundle-driven catch-up from regular hire-day fan-out. Audit rows: `provisioning.bundle_created` / `_updated` / `_deleted` / `_member_added` / `_member_removed` / `_applied` (with `person_id` + counts in detail JSON).

Membership UI: [`ProvisioningBundlesPage.tsx`](../../web/src/pages/provisioning/ProvisioningBundlesPage.tsx) — Bundle CRUD with member-management drawer. Mounted as the 4th tab of `OnboardingManagementPage` (`?tab=bundles`).

### 备机闭环 — hardware prep loop (onboarding-parity PR3, 2026-06-12)

Closes the gap between the manager's `hardware_allocation` declaration and the Day-1 device assignment. Lives in the **asset domain** (asset admins are the audience; onboarding tables are read model-level only): [`HardwarePrepService`](../../server/app/domains/asset/hardware_prep.py) + [`hardware_prep_router.py`](../../server/app/domains/asset/hardware_prep_router.py) (`/api/v1/assets/hardware-prep*`, 4 endpoints, `require_asset_admin` + `assert_can_manage_asset` on writes; **mounted BEFORE `asset_router` — `GET /assets/{asset_id}` would otherwise swallow the path as a UUID**).

* **Reservation primitive** (migration 279): `asset_items.reserved_for_person_id` + `reserved_at`. Reserved devices keep their stock status (`idle/available/held_by_office/held_by_leaver` — the last one IS the inherit-from-leaver flow); `AssetService._ensure_not_reserved_for_other` guards every grant path (assign / claim / transfer / change_assignee / borrow) with 409 `ASSET_RESERVED` for any other recipient; granting to the reserved hire fulfils + clears (borrow keeps it — no ownership change). One device per hire: re-reserving releases the prior earmark. Vouchers: `AssetOperation` types `reserve_for_hire` / `reserve_release`.
* **待备机 worklist** (`build_queue`): open processes within the window (overdue hires always included) needing hardware prep or carrying a reservation — manager declaration, blocker state, reservation, global stock count, 6 KPIs; plus **stale reservations** (process went terminal but the earmark survived) surfaced for one-click release. Rendered as the 资产工作台「待备机」tab ([`AssetHardwarePrepTab`](../../web/src/pages/asset/AssetHardwarePrepTab.tsx), `?tab=prep`).
* **Zero notifications** — pure worklist surface, per the 打扰预算红线.
* MCP ×3 (hint 277 → 280): `list_hardware_prep_queue` / `reserve_asset_for_hire` / `release_asset_reservation` (writes go through `_mcp_enforce_asset_region`).

#### 预留即就绪 + T+0 自动领用 (2026-09-05, 0 migration / 2 endpoints / +2 MCP → hint 593)

**A reservation now satisfies the Day-1 hardware dimension**, and the T+0 advance converts it into a real claim. Read the seam first: [`onboarding_hardware_coordinator.py`](../../server/app/core/coordinators/onboarding_hardware_coordinator.py) — `resolve_hire_hardware_state` (single) / `resolve_hire_hardware_states` (batch, two queries regardless of cohort size) / `autoclaim_reserved_for_hire`. Onboarding never writes asset rows; the coordinator is the only crossing.

**Why** (production, 2026-09-05): 40 hires over six months, 9 Day-1 ready, and **29 of the 31 misses were the hardware dimension alone** — the machine existed but the ledger claim landed 1–22 days late, or never (11 hires still `live` with no computer in Helix at all: overseas self-purchase / direct ship). The reservation primitive above shipped 2026-06-12 and satisfied **no** dimension, so the only scoring path was a human clicking 领用 on the hire's first morning. Production had `reserved_for_person_id` on **0 rows** and `hardware_allocation` set **once, ever** — both tools existed and neither was used, because using them did not count.

* **Scanner**: `OnboardingService._scan_dimensions` reads the coordinator instead of counting `assigned_to` itself. `assigned` wins over `reserved` (a stale earmark on a second device must not make a hire who already holds their laptop read as only-earmarked).
* 🔴 **`hard_ready_at` honesty guard** (`_recompute_status`): re-pending a hard dimension clears the stamp **only while `hire_date` has not passed**. Unconditional clearing would let a laptop swap three months later rewrite the Day-1 and SLA metrics (both read this timestamp as historical attainment); never clearing would let a reserve-then-release at T-5 bank Day-1 credit with no machine anywhere. `hire_passed` is the only correct predicate — one test per direction.
* 🔴 **There are FOUR entrances to `live`, not three.** Besides the daily T+0 cron, `advance_now` and `mark_live_manual`, **`_recompute_status` itself promotes** a process the moment `hire_passed` is true and no blocker is pending — and the T+0 cron `continue`s past a process that is already `live`. Since the reconcile tick runs every 5 minutes and the cron once a day, **the reconcile path wins for every hire who was fully ready before their start date** — exactly the well-prepared cohort. The autoclaim was originally hung on the other three only (the function's own docstring wrongly claimed "the T+0 cron forces this transition, not the scanner"), so the earmark→claim conversion never ran for them: the ledger kept the device as unassigned stock, and `asset.stale_reservation_sweep` released the earmark once T+7 completed the process, putting a laptop that was physically in the hire's hands back into stock. Fixed 2026-09-05 (同日第七轮). **Any new `→ live` transition must call `autoclaim_reserved_for_hire`.**
* **T+0 conversion**: wired into all four entrances to `live` — the daily cron ([`advance_service`](../../server/app/domains/onboarding/advance_service.py), **inside the same SAVEPOINT as the flip** so a failed claim rolls back the flip and the next tick retries both), `advance_now`, and `mark_live_manual`. Locks the asset row `FOR UPDATE` + `populate_existing` and **re-asserts every precondition under the lock** (earmark still present / still a computer / still in a **`RESERVABLE_STATUSES`** status — the reserve path's own tuple, imported, never re-spelled: a hand-copied literal dropped `held_by_leaver` and made the inherit-from-leaver reservation permanently unconvertible / no holder **unless** the status is `held_by_leaver`, where the holder IS the departed colleague and the claim IS the recovery) — a human claim landing in between must not be silently overwritten. `operator_id` follows the offboarding-automation convention (the semantic subject, i.e. the hire; the column is NOT NULL and the cron has no acting admin); `detail.via = 'onboarding_t0_autoclaim'` + the audit row are what mark it as automation. `onboarding.advanced_to_live` audit detail carries `autoclaimed_asset_id`.
* **Cockpit**: rows carry `hardware_state` (`assigned` / `reserved` / `missing`) + `hardware_asset_tag` as their own fields. Deliberately NOT folded into `dimension_states`, whose `reserved` value means *not ready* (reserved-only mailbox) — overloading it would make the readiness count disagree with the state machine. The 电脑 chip stays green with a dashed border + tooltip.

#### 离职未归还设备对账 + 备机预留僵尸清理 (2026-09-05, migration 608 / 2 endpoints / +1 cron / +1 MCP → hint 616)

Both gaps were surfaced by a read-only audit from a parallel session, and the second one **this series had just made worse**.

**离职未归还设备** — [`leaver_recovery.py`](../../server/app/domains/asset/leaver_recovery.py), `GET /api/v1/assets/leaver-held` (+ `/export.xlsx`, `require_asset_admin`), rendered as 资产清单 `?tab=leaver`. `held_by_leaver` is a real, populated state (10 assets in production) that had **no clock and no owner**: the weekly digest was `SELECT count(*)` plus five sample rows mailed to every asset admin — no ageing, nobody accountable, no path to closure.

* 🔴 **The clock is the leaver's HRIS `termination_date`, not when IT marked the asset.** "How long has the company been without this laptop" is the question worth asking; "how long since we noticed" is not. Fallbacks are the `mark_held_by_leaver` operation timestamp, then the asset row's `updated_at` — and **every row reports which clock it used** (`clock_source`), because a bucket built on a fallback deserves less confidence than one built on the HRIS date. The UI carries a caveat Alert naming how many rows are fallback-dated.
* **Ageing buckets 30 / 60 / 90+**, not cosmetic: two weeks out is normal offboarding latency, 90 days out is lost or quietly written off, and one count for both is what let production's 10 rows sit unexamined.
* **Accountability is derived per location, never stored**; rows whose location does not resolve are counted in `summary.unassignable` and labelled 「无人负责」 rather than silently blaming nobody.
* `MAX`, not `MIN`, over the mark operations: a device recovered and later re-marked is held since the LATEST mark — the earlier episode is closed history.
* MCP ×1 (615 → 616): `list_leaver_held_assets`, read-only (the recovery action already has `recover_asset_from_leaver`).

**备机预留僵尸清理** — new cron `asset.stale_reservation_sweep`, daily 04:10 Asia/Shanghai, seeded **enabled** by migration 608. `HardwarePrepService.stale_reservations` ("earmarked for a hire with no open onboarding process") **only ran when somebody opened the 待备机 tab**; no scheduled job referenced reservations at all, and `delete_asset` neither refused nor cleared a reserved row, so a soft delete made the earmark vanish silently.

🔴 **This got materially worse on 2026-09-05**, when an earmark started satisfying the Day-1 hardware dimension: a zombie earmark is now a device that is BOTH unavailable to the next hire AND quietly counted as a rescinded candidate's Day-1 readiness. The sweep therefore ships in the same series as the change that made it necessary.

* Releases through the same voucher path a human release takes (`reserve_release`), tagged `detail.via='stale_reservation_sweep'`; `operator_id` is the hire the earmark was held for (the semantic subject, per the offboarding-automation convention — the column is NOT NULL and a cron has no acting admin).
* 🔴 **Re-asserts under the row lock against the SAME person**, not merely "still reserved". The dangerous race is a re-reserve of the device for a DIFFERENT hire whose process IS open — a non-null check alone would release that new hire's legitimate earmark. ⚠️ **Found by mutation-verifying the test**: the first regression test still passed with the person check deleted (it mutated the row before `sweep` recomputed its own candidate list, so the list came back empty and it passed for the wrong reason). The kept version patches the candidate source to hold the row in the "judged stale, then re-reserved" state; deleting the check turns it red.
* **`delete_asset` now returns 409 `ASSET_RESERVED_FOR_HIRE`** on a reserved asset. It previously passed every check — a reserved device sits in a STOCK status with `assigned_to`/`borrowed_by` empty — and the soft delete then removed the earmark with no event, flipping the hire's hardware dimension back to pending with nothing explaining why.
* 04:10 is after the 03:15 asset-admin reconcile and before CN work hours, so an admin arriving in the morning sees stock that is already truthful.

🔴 **The 台账健康度自检 page must not mix scopes — and getting this right took two passes.** The pre-merge review caught that the Day-1 hardware card is platform-wide while the others are not (a Day-1 gap belongs to an office, not an asset location, and its closing action is `require_admin`), and **omitted that card entirely for a non-global-tier actor** rather than footnoting it (a caveat on one card of four gets skimmed). The post-merge review then found the same defect in the *remaining* cards: only `idle` was actually region-scoped (it reuses `IdleComputerService`); `financial` and `never_inventoried` counted the whole platform while `totals.regional_scope` put a 「仅本人管理的位置」 tag over the lot. Every section now goes through `AssetLedgerHealthService._scoped` (`get_location_ids_managed_by` + `resolve_asset_location_ids_bulk`; an asset whose location resolves to nothing belongs to no region). ⚠️ `managed is None` (global tier, no filter) and `managed == set()` (regional admin who administers nothing) are **different** — collapsing them hands the whole platform's numbers to someone with no scope at all. **A new section on this page must take `managed` and run through `_scoped`, or it silently re-opens this.**

**Zero employee-facing notifications.** Chasing goes to IT / office admins, consistent with the standing 「别给员工加新推送」 line and with the same-day decision to close the 11 Day-1 hardware gaps through IT rather than by asking 11 people to self-report.

---

#### 财务固定资产台账对账 (2026-09-05, migration 605 / 7 endpoints / +2 MCP → hint 602)

[`cost_reconcile.py`](../../server/app/domains/asset/cost_reconcile.py) + the `/api/v1/assets/cost-reconcile*` block in [`asset_health_router.py`](../../server/app/domains/asset/asset_health_router.py) (renamed from `idle_computers_router.py` — it now serves 长期闲置电脑 / 台账健康度自检 / 台账对账, three surfaces that all answer "is the ledger telling the truth?"). Frontend: 硬件财务 `?tab=reconcile` ([`AssetCostReconcileTab`](../../web/src/pages/asset/AssetCostReconcileTab.tsx)) — a FINANCE workflow on the finance-facing asset page, two tabs rather than a tenth sidebar entry.

**`require_admin` (global_admin), not `require_asset_admin`**: the uploaded file decides which assets get money values written onto them, so the region gate cannot be applied row-by-row without parsing the file first. Same reasoning as `POST /assets/bulk-update`.

**Why standing, not a script** (explicit product decision): migration 604 + same-model medians cap out at **36.6%** completeness (measured on the real production run 2026-09-05: 94 rows written, 290→384 of 1050; only **52** of the 692 amount-less rows with a model had enough same-model reference, not the ~200 first estimated here), so the rest must come from finance by serial — and that is not a migration event. The register is re-exported every close, Helix keeps acquiring devices, and the valuable output is the two disagreement sets a script prints once and loses:

* **`conflict`** — Helix already RECORDS a different amount. Two books disagree; a finding for a human, never a silent overwrite. Applying needs an explicit `include_conflicts`.
* **`unmatched`** — a register line matching no Helix asset: **finance is depreciating a device the IT ledger does not have.** The inverse of the completeness gap, and visible on no other surface in the platform.

**Two tables (migration 605), preview-then-apply.** `asset_cost_reconcile_runs` = one upload; `asset_cost_reconcile_items` = one register line + verdict + **the Helix-side snapshot taken at preview time** + a per-row decision. The upload writes nothing. The item rows are what make the apply auditable afterwards — "what did the register say on the day we accepted it" is unrecoverable from the asset row alone. No FK to `asset_items` (an unmatched line has no asset; a matched asset can be hard-deleted later without invalidating the record).

🔴 **The apply re-checks each row against its snapshot and REFUSES it if the ledger moved**, returning those in `stale` rather than skipping silently. Re-deriving current values at apply time would HIDE a concurrent correction instead of surfacing it — that is how a reconciliation quietly clobbers someone else's fix.

**Matching refuses to guess**: serial only, through the same `mdm_serial_key` every other cross-system serial comparison uses (two normalisers = silent non-matching); a serial matching several live assets is `ambiguous` and never applied; a line with no serial or an unparseable amount is `invalid` and reported; **an amount with no currency is `invalid` too** — importing more of the exact 42-row defect this series exists to fix would be absurd. Disposed and soft-deleted assets are excluded from matching.

**`purchase_date` and `purchase_entity` are FILLED, never moved.** The register's 入账日期 is an accounting date and can legitimately differ from the acquisition date the asset row holds; overwriting would silently re-date the whole depreciation schedule.

⚠️ **`ambiguous` is currently unreachable and stays anyway.** `asset_items` carries `UNIQUE KEY uk_asset_items_serial_number` under a case-insensitive collation, so two live assets cannot share a serial (production 2026-09-05: 0 duplicate live serials). That index ignores soft deletes and could be dropped or scoped later, at which point silently picking one candidate would write money onto an arbitrary row — so the fail-closed branch remains, documented, and is covered by a **classifier-level** test rather than a DB fixture the constraint forbids.

**Three defects only a real dev server exposed**, all fixed here:
* the item list claimed "worst-first" but ordered by the `match_status` COLUMN, i.e. alphabetically — which buried `unmatched` last. Now an explicit CASE order (conflict → ambiguous → unmatched → matched → invalid).
* `match_note` is backend-generated English prose and was rendered straight into the table, putting English in front of a zh-CN / ja-JP operator (and for `conflict` merely repeating the two amounts the adjacent columns already show). The visible text is now localised from `match_status`; the backend note stays as the tooltip, and `ambiguous` additionally shows it because its note carries the colliding asset tags.
* `uploaded_at` is a second-granularity server default, so two same-second uploads left the run list order undefined while the page takes `items[0]` as "newest" — `id` added as a deterministic tiebreaker. `AssetCostReconcileRun`/`Item` also needed `__mapper_args__ = {"eager_defaults": True}` (serialising right after flush lazy-loads the server default → MissingGreenlet, same reason `AssetLocation` has it).

* MCP ×2 (hint 600 → 602): `list_asset_cost_reconcile_runs` / `get_asset_cost_reconcile_run`, both read-only and `_require_global_admin`. **Deliberately no upload / apply / skip / discard tool**: upload cannot be mirrored (MCP carries no file), and apply is "write amounts from a register the LLM neither uploaded nor can see onto asset rows" — precisely the act that must have a human read the preview first. Giving it a tool call would remove the "two books disagree, a human decides" design from the flow. Reasoning recorded in the `_REGISTERED_TOOLS_COUNT_HINT` comment block, alongside the `answer_security_quiz` precedent.

---

#### 资产财务完整度 + 台账健康度自检 (2026-09-05, migration 604 / 1 endpoint)

**Migration 604 — `asset_items.purchase_cost_source`** (`recorded` / `estimated_model` / `finance_register`, NOT NULL default `recorded`, every pre-existing row backfilled). Depreciation, BvA and cost attribution all read `purchase_cost`, and the amounts being backfilled come from three sources of very different authority — **a single amount column cannot tell them apart**, which is how a convenience backfill becomes a number finance cannot defend. Plain VARCHAR + app-level vocabulary (`PURCHASE_COST_SOURCES` in `asset/models.py`), not a MySQL ENUM: TiDB rejects `MODIFY COLUMN` on enums, so a fourth source later would need a whole expand-contract cycle. ⚠️ **Numbered 604, not 603**: the access-lifecycle series claimed 603 concurrently (PR #3440) and flagged it before either branch pushed.

🔴 **The gap's shape is the finding, and it is not "all four fields".** Production 2026-09-05, 1050 live assets, 760 incomplete — but `purchase_date` missing on **0** rows, `purchase_entity` on **3**, `purchase_cost` and `currency` on **718** each (673 both, 42 amount-only, 42 currency-only). 758 of the 760 are the 2026-04 易盘点 import; assets Helix created since May run 19 complete vs 2. A single "incomplete" count sends an operator to check the intake form, which is fine. `AssetLedgerHealthService._financial` therefore reports `missing_by_field` plus the two cheap shapes separately.

🔴 **The 42 amount-without-currency rows must NOT take their currency from the purchase entity.** Every one of those amounts is a mainland-China list price (8499 / 10999 / 12999 / 14999) including the 13 filed under `PINGCAP (US), INC.`, and among complete rows the US entity carries 21 CNY to 7 USD. [`scripts/backfill_asset_purchase_cost.py`](../../server/scripts/backfill_asset_purchase_cost.py) writes `CNY` flat and documents why, so the tidy-but-wrong rule does not get reinvented.

**The backfill script is dry-run by default and states its own ceiling in the first paragraph — and that ceiling is now the MEASURED one.** Ran against production 2026-09-05: 42 currency rows + 52 median rows = **94 written**, completeness **290/1050 (27.6%) → 384/1050 (36.6%)**. 502 of the 692 amount-less rows had fewer than 3 priced same-model siblings, 3 models were skipped for a >1.5x spread and 1 for mixed sibling currencies. ⚠️ The paragraph originally said "~200 estimable, roughly 50%" — **a desk estimate that was wrong by ~14 points**; corrected from the run itself, and the script now says which of its numbers are measured. Conclusion unchanged: still the 30-point band. ⚠️ Also note a dry run **under-reports pass 2 by construction** (pass 1's 42 newly-currencied rows only become valid priced siblings once actually written — dry run projected 50, apply wrote 52), so the applied count is >= the dry-run count, never less. Same-model medians are written only when the sample is ≥ `--min-samples` (3) **and** the sample's own max/min is within `--max-spread` (1.5); wider models are listed for a human to price, because a median across a genuinely mixed configuration is indefensible. Mixed-currency samples are skipped outright (no FX — the correct rate would be the unknown purchase-date rate). Every write carries a `trace_id` audit row naming the pass, old value and new one.

**`GET /api/v1/assets/ledger-health`** ([`ledger_health.py`](../../server/app/domains/asset/ledger_health.py), `require_asset_admin`) → four sections: `financial` / `idle` / `day1_hardware` / `never_inventoried`. Rendered by [`AssetLedgerHealthPanel`](../../web/src/pages/asset/AssetLedgerHealthPanel.tsx) at the TOP of the 资产工作台「资产健康」tab — deliberately not a new tab or menu entry. That tab already answered a different question (is the HARDWARE healthy: warranty / repair / faulty / retired); the self-check answers whether the ledger is telling the truth about it, and goes first because it is what an operator arrives for after seeing a low domain score.

* **Every section reuses the service that owns its metric** — idle → `IdleComputerService`, Day-1 → `OnboardingHardwareGapService`, financial → the same four-field predicate as `AssetFactProvider`. A test asserts the financial numbers **against the provider itself**, not against a copied field list, so a one-sided change fails.
* **Every card carries a route that can act.** A diagnostic that ends in a number is exactly how the previous version of this information failed (the health page showed 50 bare asset ids).
* `day1_hardware.coverage_pct` is **`null`**, not 0: its rate is a six-month historical window, and a made-up point-in-time 0% would read as "nothing is ready".
* **Estimated amounts are reported next to the coverage number** (`financial_estimated_count` in `aggregate_facts` too), so "90% complete" can never quietly mean "half of it is a same-model median".
* Read surfaces label provenance through one shared component ([`PurchaseCostValue`](../../web/src/pages/asset/components/PurchaseCostValue.tsx)) — **four** surfaces show this amount (detail page, detail modal, financial section, 离职未归还), and an estimate labelled on three but not the fourth teaches the reader to trust the bare number. The 离职未归还 workbook carries a 金额来源 column for the same reason: it is what a write-off conversation quotes.

⚠️ **Every data export in [`asset_health_router.py`](../../server/app/domains/asset/asset_health_router.py) must call `audit_export`.** The domain's own `/assets/export.xlsx` has audited this egress since it shipped (asset rows carry employee names and usage locations); the three workbooks this series added shipped with **no audit row at all**, and the 离职未归还 one carries departed employees by name + employee number against the serial each still holds. Use [`audit_export`](../../server/app/core/exporting.py) rather than a hand-rolled `audit.log`: it commits immediately, because `get_db` only commits after the download has fully streamed and a client that disconnects mid-download would otherwise roll the audit row back. Its `filters=` takes a **Mapping** — passing the `IdleComputerFilters` dataclass straight in is a guaranteed request-time `AttributeError` (`asdict()` it).

⚠️ **Adding a column to model + schema does NOT mean it reaches the frontend.** `AssetService.enrich_with_users` builds its dict from an **explicit field list**; the new field was missing from it and `AssetResponse` defaults `purchase_cost_source` to `recorded`, so every estimated asset reported itself as a recorded invoice figure on the detail surfaces — no error, just a wrong number. Caught only by driving a real dev server, fixed, and pinned by a regression test. Any future column that must travel with an asset needs that list updated too.

---

#### 长期闲置电脑 + 资产菜单合并 (2026-09-05, 0 migration / 2 endpoints / +1 MCP → hint 594)

[`IdleComputerService`](../../server/app/domains/asset/idle_computers.py) + [`idle_computers_router.py`](../../server/app/domains/asset/idle_computers_router.py) (`GET /api/v1/assets/idle-computers` + `/export.xlsx`, `require_asset_admin`). ⚠️ **Mounted BEFORE `asset_router`** in `app/main.py` for the same reason as `hardware_prep_router`: `GET /assets/{asset_id}` would swallow the literal segment as a UUID and answer 422.

**The service reproduces `assets.idle_rate`'s口径 line by line** (`IDLE_DAYS = 90`, the stock-status set, the `asset_operations` clock, the +30d demand horizon) with a comment saying why: this page explains that score, and a different threshold would make the two contradict each other. A test pins `surplus_rate_pct` to the same fraction the metric reports.

* 🔴 **Held-for-demand rows are listed, not hidden.** Near-term Day-1 hardware demand is subtracted from the idle queue (oldest-first, so the surplus is the head); the subtracted tail stays in the response with `is_surplus=false`. Hiding it makes the totals unexplainable; calling it surplus recommends disposing of the next hire's laptop. The MCP description states this too.
* **`never_moved_in_helix`** — production's dominant shape (136 of 146): no custody event at all, so the clock starts at intake, which for the 2026-04 legacy import is the import day rather than when the device actually stopped being used. Surfaced explicitly with a tooltip saying the number is conservative, not exact.
* **`never_inventoried`** (84 of 146) — nobody has confirmed the device physically exists. Worth its own KPI: disposing of an unverified device is a different risk from disposing of a confirmed idle one.
* **Facets are built from the UNFILTERED row set** — deriving them from filtered results makes the chosen filter the only remaining option (repo-wide UX rule). Every summary number except `filtered_count` likewise ignores the filter, so applying one never makes the page look like it disagrees with the health score.
* **Region scoping mirrors the write gate** (`person_can_manage_asset`): global tier sees all, a regional `asset_admin` sees only their locations, and an asset whose location resolves to nothing is global-tier only. The xlsx export honours the same scope and filters.
* MCP ×1 (hint 593 → 594): `list_long_idle_computers` (`_require_asset_admin`).

**资产管理 sidebar: 11 entries → 9.** The convention is a FLAT list of at most 10 with in-page Tabs for related pages; antd's grouped-menu style is banned (user direction 2026-06-28). Adding 长期闲置电脑 as a 12th entry would have made it worse.

| Folded | Into | Why |
|---|---|---|
| 操作记录 | 资产工作台 `?tab=operations` | The tab was already a 5-row preview of the same data with a 「查看全部」 link out — one dataset, two entrances. The tab now mounts the full page (filters / pagination / confirm action). |
| 回收站 | 资产清单 `?tab=recycle` | 228 lines, same data shape as the ledger it belongs to. |
| 长期闲置电脑 (new) | 资产清单 `?tab=idle` | Added as a tab, not a menu entry. |

New hub: [`AssetInventoryHubPage`](../../web/src/pages/asset/AssetInventoryHubPage.tsx) at `/admin/assets/list` (在册资产 / 长期闲置 / 回收站). `AssetListPage`, `OperationsPage` and `AssetRecycleBinPage` all gained an `embedded` prop and are no longer routed directly. **资产入库 and 硬件财务 deliberately stay top-level**: intake is a high-frequency form (not a list) that the workbench's primary button targets, and folding 硬件财务 in would give the workbench an eighth tab — moving the crowding rather than removing it.

⚠️ **Retiring a page means touching five places, not two**: the route (→ `Navigate` redirect), the sidebar item, `MainLayout`'s highlight-map set (a stale entry leaves the sidebar highlighting the wrong leaf after the redirect), every in-app `navigate()` / shortcut `href` pointing at it, and the now-unused lazy import in `App.tsx`. Dead code removed with the fold: the workbench's `operations` state, `operationColumns`, two colour maps, and the `listOperations` request it fired on every visit.

---

#### 硬件待核实 — Day-1 hardware gap worklist (2026-09-05)

[`OnboardingHardwareGapService`](../../server/app/domains/onboarding/hardware_gap_service.py), `GET /api/v1/onboarding/hardware-gaps` (`require_lifecycle_read`) + `POST /api/v1/onboarding/hardware-gaps/{process_id}/register-device` (`require_admin` — it writes into a region the acting admin may not administer, so it does not open to the region-scoped `asset_admin` tier). Frontend: 入职管理 ▸ **硬件待核实** tab ([`OnboardingHardwareGapTab`](../../web/src/pages/onboarding/OnboardingHardwareGapTab.tsx), `?tab=hardware`).

* **Window**: hire date passed + hardware blocker still `pending` + not cancelled + not backfilled + person not `left`, over a 400-day lookback. ⚠️ **Includes `completed` processes** — the cockpit's window is open processes only, so production's 20 completed-with-gap hires appeared on **no** board.
* **MDM evidence rail**: [`MdmManagedDevice`](../../server/app/domains/asset/models.py) matched by `assigned_person_id` OR `mdm_assigned_email`, giving the serial an admin should type. A null hint renders as 「MDM 无记录」 with a tooltip stating this is absence of evidence, **not** proof the hire has no computer (the fleet table covered 43 devices when this shipped).
* 🔴 **A `completed` process's blocker rows are FROZEN — no scanner will ever touch them again.** `reconcile_blockers` short-circuits on `PROC_TERMINAL_STATUSES` and the 5-minute reconcile cron selects open processes only. Two consequences this queue has to handle explicitly, both found in the post-merge review (2026-09-05 第八轮): (a) `register_device` closed the blocker by calling `reconcile_blockers`, which returns 0 immediately for a terminal process — so for exactly the completed-with-gap segment this queue exists to serve, the one-click close fixed the ledger and **left the row in the worklist forever**, 409ing on the next click. It now resolves the row itself for terminal processes (writing the same `RESO_PROVISIONED` the scanner would); that is not a second writer, it is the only one left. (b) A hire who got their laptop through the ordinary claim flow *after* their process closed keeps a pending hardware blocker forever, and the queue rendered them with a red 「无设备」 tag — a false statement on the one page whose job is naming who has none. `list_gaps` now drops any row whose hardware state is `assigned` and reports the count as `summary.already_holds_device` (a secondary line, deliberately not a fifth KPI card: they are not work). ⚠️ **"Nothing else reads these rows" was asserted here when (a) landed and was WRONG** — the very next review pass found `/me/welcome`. Frozen blockers have exactly one other consumer, now fixed: [`me_universe/welcome.py`](../../server/app/domains/me_universe/welcome.py)`::_onboarding_snapshot` took the person's most recent process **regardless of status** and rendered its pending count to the employee as 「IT 正在为你准备剩下的 N 项,很快就好」 — a promise of work that provably would not happen, on their own page, for the whole 90-day new-joiner window. It now reports zero pending for a terminal process. The manager-facing twin (`team_service`) never had the bug: it filters `status.notin_(_TERMINAL_PROC)` before reading any blocker. **Everything else is genuinely bounded** — verified by enumerating every module that references `OnboardingBlocker`: `idle_computers`, `intelligence/providers/assets`, `hardware_prep`, `digest_service` and `team_service` all constrain to `PROC_OPEN_STATUSES`, the Day-1 metric scores `hard_ready_at`, and `_count_pending_blockers` only runs for open processes. **Add a new reader ⇒ bound it to open processes, or it will read frozen rows.**
* **`register_device`** — one call, one transaction: `AssetService.intake_asset` + `claim_asset` + blocker close (`reconcile_blockers` for an open process, direct resolve for a terminal one). Goes through the asset service (never raw ORM) so asset-tag generation, brand/model lookup upsert, legal-entity resolution and managed-location normalisation all still apply. **Claim date defaults to the hire date**, not today — the machine has been in their hands since then. `purchase_entity_id` is required (a `model_validator` on `RegisterHireDeviceBody` turns intake's mid-transaction `PURCHASE_ENTITY_REQUIRED` 400 into a field-level 422); `category` must be a computer, or the row would be created and the blocker would stay pending.
* **Skip path reuses `skip_blocker`** — "this hire needs no company device" is an existing audited decision, not a new concept.
* **Zero employee-facing notifications** (explicit product decision, 2026-09-05): these gaps close through IT, not by asking 11 people to self-report.
* MCP ×2 (hint 591 → 593): `list_onboarding_hardware_gaps` (`_require_hr_read`, matching the HTTP twin) / `register_onboarding_hire_device` (`_require_global_admin`, validates through the HTTP body model so the two surfaces cannot drift). New helper `_money_or_none` gives LLM-supplied amounts a typed 422 instead of the runtime catch-all's blank 500.

---

## 11.6 Offboarding Cockpit (2026-05-31, PR-B — embedded into /admin/offboarding 2026-06-01)

`/admin/offboarding?tab=cockpit` — one-screen ops console for in-flight offboardings. PR-1 of the symmetric-onboarding series (2026-06-01) folded this into [`OffboardingManagementPage`](../../web/src/pages/offboarding/OffboardingManagementPage.tsx) as the default landing tab, alongside 进度 / 策略 / 生命周期. Legacy `/admin/offboarding/cockpit` route redirects to `?tab=cockpit`.

**Backend: zero new endpoints.** Three already-shipped surfaces feed the page:

| Action | Endpoint | Notes |
|--------|----------|-------|
| List | `GET /api/v1/offboarding/processes?include_blockers=true&page_size=200` | Already returns enriched `OffboardingProcessResponse` (sla_breach_level + first_blocked_at + default_handover_person_id/name/email + needs_it_count / auto_in_progress_count / resolved_count + per-blocker triage_bucket / last_error_message / error_count). The `blockers` array — needed for the cockpit's resource-type filter dropdown + expandable rows — is opt-in via `include_blockers=true` so the lightweight legacy `OffboardingAdminPage` list call stays unchanged. Round-62 fix: previously the service hardcoded `include_blockers=False`, silently emptying both UI features. `page_size` cap raised from 100 to 200 to match the cockpit's full-in-flight-fetch comment (PR-B). |
| Set handover | `PUT /api/v1/offboarding/processes/{id}/default-handover` | `OffboardingService.set_default_handover_person`, active-person guard + audit log |
| Advance one | `POST /api/v1/leaver-blocked/processes/{id}/advance-now` | Round-21 audit-correctness path. Idempotent on `Idempotency-Key`, scoped to single `person_id` (does NOT iterate the full leaver_pending set like `run_tick`). Failure path writes durable audit row. Respects the T+0 24h IT grace window. |
| Force-bypass grace | `POST /api/v1/leaver-blocked/processes/{id}/force-bypass-grace` | 2026-06-02. Same pipeline as advance-now but plumbs `bypass_grace=True` into `_advance_single` so the 24h grace is collapsed to zero. **Stricter RBAC than advance-now**: `global_admin` / `it_admin` only (HR rejected — see `can_force_bypass_leaver_grace` in `core/rbac.py`). Required body `{reason: str(≥30)}` recorded in audit_logs (action `leaver_blocked.force_bypass_grace_advance`). Canonical case: HRIS-late-detection where termination_date is weeks past, no T-N reminders ever fired, IT has drained resources manually. Shares `_run_advance_for_process` helper with advance-now so idempotency + durable-failure audit + identity-sync hook behave identically. 1:1 MCP tool `leaver_blocked_force_bypass_grace`. |

**Page**: [`OffboardingCockpitPage.tsx`](../../web/src/pages/offboarding/OffboardingCockpitPage.tsx). Filters in-flight by T+x bucket / SLA tier / pending blocker resource_type / handover-missing toggle / keyword. Sort: `sla_breach_level desc → first_blocked_at asc → leaver_date asc`. KPI strip is computed against the unfiltered in-flight set (stable scoreboard, not narrowed by filters).

Per-row inline actions: 「指定接手人」(UserSelect modal) → 「立即推进」(advance-now + idempotency key) → 「详情」(navigate to `OffboardingProcessDetailPage`). Expand row renders pending blockers with severity/triage_bucket/error_count tags.

`HealthConsolePage` offboarding panel deep-link updated to `/admin/offboarding/cockpit` (was `/admin/offboarding`) so the PR-A → PR-B handoff goes straight to the operations face.

RBAC: `RoleRoute roles={['global_admin', 'hr']}` (same as the parent `/admin/offboarding`). Backend mutate gates (`can_manage_offboarding` / `can_manage_leaver_blocked`) keep HR locked out of write actions — page shows the buttons, server returns 403, toast surfaces the message.

---

## 12. Domain map

Each domain is `server/app/domains/<name>/`. Domain modules **don't import from each other** — cross-domain coordination via workflow journal + scheduled jobs.

### 12.0 Domain-package index (all 31 packages; verified 2026-06-22)

Where each `server/app/domains/<name>/` package is documented — some live outside this section because they grew out of framework chapters:

| Package | Documented at |
|---------|---------------|
| `asset` | §12.1 |
| `procurement` | §12.1.1 |
| `hr` | §12.2 |
| `hr_tools` | §12.2.1 |
| `permission` | §12.3 |
| `provisioning` | §12.4 + §10 (framework) |
| `offboarding` | §12.5 + §11.6 (cockpit) |
| `leaver_blocked` | §12.6 |
| `relationships` | §12.7 |
| `google_workspace` | §12.8a |
| `feishu_helpdesk` | §4 (driver `feishu_helpdesk` / kind `helpdesk`) + §16 (migration 316) — external Feishu 服务台 ticket mirror for 服务台分析 (`/admin/helpdesk-analytics`); synced by `feishu.helpdesk_sync` (12h); read gated global_admin+management. Status codes 2=处理中 / 50=已解决 / 51·52=已关闭未解决; `score` is a 3-point CSAT (0=未评价 excluded; 1=不满意/2=一般/3=满意; KPI = 满意率). `ai_insights.py` adds an AI 月度运营洞察 card (百炼/Qwen via `ai_spending.llm.*`, cached in `platform_settings`, graceful no-key) — `GET/POST /feishu-helpdesk/ai-insights[/refresh]` + MCP `get_feishu_helpdesk_ai_insights` |
| `email_group` | §12.8 |
| `ai_spending` | §12.9 |
| `apikey` | §12.10 |
| `bailian` | §12.11 |
| `me_universe` | §12.12 |
| `auditor` | §12.13 (+ GRC spine) |
| `cmdb` | §13 (cloud lifecycle) |
| `identity_sync` | §10.5 (Identity Sync Center) |
| `onboarding` | §11.5b |
| `reconciliation` | §11.8 (Reconciliation Center) |
| `health` | §11.5 (Admin Health Console; router `/api/v1/admin/health`) |
| `bounty` | §12.15 (bug bounty operations) |
| `system_registry` | §12.16 (internal system registry — 系统台账) |
| `approval_registry` | §12.17 (approval-flow registry — 审批流程台账) |
| `sso`, `domain_registration`, `vpn_application`, `announcement_request`, `ai_tool_application`, `feed`, `policy_notices`, `platform_tips`, `security_awareness`, `soc2_email_group` | §12.14 (smaller domains, below) |

### 12.1 Asset

**Tables**: `asset_items`, `asset_brands`, `asset_models`, `asset_locations`, `asset_location_admins`, `asset_category_configs`, `asset_office_addresses`, `asset_office_local_admins`, `asset_tag_sequences`, `asset_history`, `asset_operations`, `asset_procurement_orders`, `asset_software_catalog`, `asset_suppliers`, `asset_business_payments`, `asset_legal_entities`, `software_license_plans`, `software_seat_allocations`, `software_monthly_bills`, `asset_inventory_cycles`, `asset_inventory_items`, `employee_requests`.

**Routes**: `/api/v1/assets/*`, `/api/v1/asset-inventory/*` (mounted as `asset_router` + `asset_inventory_router`).

**Invariants**:
- Asset tags auto-generated per `(location_code, category_code, year_month)` via `asset_tag_sequences` counter.
- Brand/model dual-write: free-text columns kept for back-compat; new writes also populate `brand_id` / `model_id` FKs + `auto_created` flag.
- Inventory cycle items are an immutable snapshot at cycle start. Later asset changes do NOT update cycle items; responses carry explicit `transferred_to` / `moved_to_location` fields.
- **Inventory create-time scope discoverability** (added 2026-05-20): the admin create-cycle form surfaces all five scope dimensions (`locations`, `categories`, `departments`, `person_ids`, `assignment_scope`) — backend has supported them since the original 088 migration but only the first three were rendered on the form, leaving "test on one person's laptop" impossible without a curl call. `POST /api/v1/asset-inventory/preview-filter` returns the same `CycleSnapshotPreview` shape `GET /cycles/{id}/preview` returns but operates on a candidate filter without persisting a cycle row, powering the form's "预览匹配数量 / Preview match" button. `auto_start` default flipped from `true` → `false` on the form so the canonical flow is create-draft → preview → manual start (the auto-start request path still works for scripted / cron callers). `GET /api/v1/asset-inventory/lookups/departments` is the asset-admin-gated companion of `/identity-sync/lookups/departments` (which is `it_admin`-only) so the picker doesn't require global-admin grants. All five filters are AND-combined inside `_query_eligible_assets` — copy on the form explicitly says "交集 / AND-combined" to avoid the recurring "is it union?" admin question.
- Software seat cancel-at-period-end: marked rows revoked at month boundary by `software_seat.auto_revoke_pending_cancels`. Original requester preserved in audit. `cancel_next_month` checks ownership BEFORE status (status oracle protection); `cancel-undo` rejects with 409 if status is no longer `active` (auto-revoke cron may have run between request and undo).
- Monthly bills: closed via admin "close month". Reopen deletes + re-creates rows (never edits in place). Per-period MySQL/TiDB advisory lock (`GET_LOCK('helix.billing.close.<period>', 5)` — 2026-05-16 bumped 0→5s so admin-vs-cron benign overlap completes instead of 409'ing immediately; the existing-rows count guard still catches the second caller with `PERIOD_ALREADY_CLOSED`) serializes concurrent close attempts so a double-click admin or admin-vs-scheduled race gets a clean 409 instead of duplicate rows. `close_month` + `reopen_month` both wrap the post-lock body in `try / finally` so a mid-flight INSERT / FK / OOM raise releases the advisory lock immediately instead of waiting for session-end auto-release on a pooled connection; the RELEASE_LOCK call itself sits inside an inner `try / except logger.exception` so a release-side network hiccup can't mask the original exception (session-end auto-release is the final safety net). `_current_period()` is anchored to Asia/Shanghai (the company's billing tz, matches the `software_billing.auto_close` cron's seeded timezone) so the auto-close cron at "03:03 SH on the 1st" doesn't see the period as "still current" because UTC hasn't rolled over yet.
- Software catalog `offboarding_policy` ∈ {`sso_cascade`, `auto_revoke`, `identity_sync_managed`, `owner_notify_manual`} — the offboarding blocker scanner checks this to decide if a seat needs explicit revocation. `sso_cascade` = the seat dies when an upstream IdP (Google / Feishu SSO) is closed — blocker is marked resolved with no task. `auto_revoke` = create a provisioning task that calls the bound `software_connectors` driver. `identity_sync_managed` (added 2026-05-29) = the catalog row IS the identity itself (Feishu / Google account, not a downstream SaaS), and its create/freeze lifecycle is owned by an Identity Sync Center target — `feishu-contact` for 飞书, future `google-contact`. The seat-layer scanner marks the blocker `RESO_IDENTITY_SYNC_MANAGED` with an audit row pointing at identity_sync; **no provisioning task is created** because firing one in parallel with the runner's `deactivate` would 409 against the same vendor user from two code paths. `_resolve_execution_mode` has a defensive guard that routes any task hitting an `identity_sync_managed` catalog row to `EXEC_MANUAL_NOTIFY` (skipping `auto_api` / `sso_auto`) so a stray admin-forced grant still surfaces for a human before hitting the vendor. `owner_notify_manual` (added 2026-07-13) = an un-integrated system (no connector, no seat sync — e.g. a shared org console like *TiDB Cloud Org*) that Helix cannot deprovision; it does NOT ride the seat-blocker scanner at all. Instead, when a person departs, `OffboardingService._notify_manual_close_owners` (called from the tail of `_dispatch_offboarding_start_notifications`, so it shares the leaver/manager start-notification gating and fires once per process from `dispatch_post_commit_actions`, deduped by `OffboardingReminderLog(kind='owner_manual_close')`) pings the software's `owner_person_id` (Feishu + email) to close the leaver's account by hand. BU scope lives in `offboarding_config.notify_bus` (list of BU names; empty/missing = every leaver, else case-insensitive match on the leaver's primary-membership BU via `app/identity/bu_helpers`). Owner who is inactive/deleted/the-leaver-themselves is skipped; audit `offboarding.owner_manual_close_notified`. Admins set the policy + scope from the software-catalog form (the `notify_bus` tags picker fed by `/auth/business-units`).
- **State-machine ghost-data discipline (2026-05-03 hardening)**: `return_asset` / `return_borrowed_asset` / `dispose_asset` all clear `expected_return_date = None` on transition out of an assigned/borrowed state. Without this cleanup a re-borrow that omits a date inherited the previous cycle's stale value, and disposed assets surfaced indefinitely in "due back" reports.
- **Schema bounds**: `repair_count`, `usage_limit_months`, `purchase_cost`, `repair_total_cost` all carry `ge=0` (months `ge=1`) on Create/Update/Intake; `image_urls` capped at 20 entries; `custom_fields` JSON capped at 32KB to prevent list-query bloat.
- Seat-grant authorisation invariants (post 2026-05-03 hardening): every state transition flowing into `software_seat_allocations.status='active'` (`admin_grant_seat`, `approve_seat`, `_activate_software_seat` Feishu callback) re-checks `target.employment_status != 'left'`, calls `check_plan_quota`, calls `enqueue_grant_for_seat`, and writes an `audit_logs` row. No path silently flips status without these four steps.
- `revoke_seat` cross-department exfiltration guard: when the bound driver moves data to the successor (Zoom `action_on_offboard=delete`, Google `user_suspend`), a successor in a different `IdentityOrgMembership.org_node_name` requires `confirm_cross_department=True` body flag AND restricts the call to `global_admin`. asset_admin can only revoke with same-department (or no-membership-on-either-side) successors.
- Vendor seat-sync drift cleanup safety: every handler (`slack.seat_sync`, `zoom.seat_sync`, `jira.seat_sync`, `google.seat_sync`) refuses the drift-revoke loop when the vendor returned 0 matching members AND Helix has existing active seats. An empty roster is almost always an integration outage (bot kicked, OAuth scope removed, transient API failure), not a genuine "everyone left at once" event. Each handler surfaces a high-severity error in `errors[]` so `last_result_summary` shows the cause to admins. Google's `list_all_users` / `list_group_members` driver methods also fail-stop on ANY paginated error, not just page-1, so a mid-stream HTTP 503 doesn't return a truncated list that the handler would interpret as "missing users were removed".
- License-plan price visibility: `/license-plans` and `/software-catalog/{software_id}/plans` redact the `price` field (returned as `null`) for callers without `can_view_finance OR can_manage_assets`. Plan name + seat count are still visible so the seat-request modal works for every employee.
- `post_leaver_verify` software-seat scan filters on `status IN ('active','pending')`. Without it, a leaver who held N seats over their tenure (all properly revoked at offboarding) would have generated N critical incidents on every cron run. Same status filter applied to the hardware scan.
- Driver base-URL SSRF defense: `app/integrations/_url_safety.assert_safe_external_url(url, allowed_suffixes=...)` is called from Slack `_scim_base()`, GitHub `_base_url()`, and Jira `_site_url()`. Refuses non-`https`, non-allowlisted suffix (Slack→`slack.com`, Jira→`atlassian.net`; GitHub Enterprise allows any public suffix), and any host that resolves to RFC1918 / loopback / link-local / metadata-IP. Prevents an admin (or a stolen admin token) from leaking the vendor auth bearer to attacker.example or pointing the integration at `169.254.169.254` and reading internal responses through `test_connection`.
- Feishu webhook replay-protection: signature-path enforces a 5-min `X-Lark-Request-Timestamp` window; the body-token-only fallback path now ALSO requires the same timestamp header (URL-verification handshake exempted). Without this guard, a leaked `event_verification_token` (a static shared secret) could replay any past callback indefinitely, re-firing seat activations. Webhook also caps body at 1 MB and returns canonical 400 on malformed JSON instead of falling through to a 500.
- **Bulk operation idempotency two-step contract** (added 2026-05-04 — `bulk_claim_assets`, `bulk_borrow_assets`, `bulk_recycle_to_office`): caller-supplied `idempotency_key` is reserved BEFORE any row work via `_acquire_bulk_idempotency_lock` (Redis `SET asset:bulk:{action}:{key}` `NX EX 900`), then the existing DB-side `_check_bulk_idempotency` runs as a second-line check. Without the Redis lock, two concurrent requests with the same key both pass the SELECT (no `AssetOperation` row stamped yet) and both run the per-row claim loop → duplicate state. **Fail-closed** on Redis exception: raises `503 IDEMPOTENCY_LOCK_UNAVAILABLE` rather than silently allowing a double-bulk that would mutate `asset_items` rows twice with no recovery path. **The fail-closed `try` wraps ONLY the `redis.set()` network call** — the `from app.core.redis_client import get_redis` import + client construction sit OUTSIDE it (2026-05-27 fix: the import was misspelled `app.core.redis`, the broad `except` swallowed the `ModuleNotFoundError` and re-raised it as a 503, so *every* `bulk_recycle_to_office` 503'd in prod for weeks while single recycle worked — a programming error must surface as a real 500, never masquerade as a transient Redis outage). `bulk_intake_assets` / `bulk_update_assets` upload Excel files (no `idempotency_key` in the schema) and rely on per-row `asset_tag` UNIQUE for natural idempotency, so they don't acquire the lock.
- **Borrow preserves owner** (added 2026-05-04, migration 115): `asset_items` now has dedicated `borrowed_by` + `borrow_started_at` columns. `borrow_asset` writes `borrowed_by = borrower` and KEEPS `assigned_to` pointing at the original owner; `return_borrowed_asset` clears `borrowed_by` and restores status to `claimed` if `assigned_to` survived (else `idle`). `change_assignee` REJECTS `borrowed` assets — admin must call `borrow_return` first (closes the silent ownership-transfer hole). `/my/inventory` + offboarding scans + `complete_offboarding` blockers all union `assigned_to == me OR borrowed_by == me`, so a leaver who has lent gear out is correctly enumerated. Migration 115 backfills pre-existing `status='borrowed'` rows from the latest matching `AssetOperation('borrow')` row.
- **Asset soft-delete** (added 2026-05-04, migration 115): `asset_items.deleted_at` replaces the prior hard-DELETE path. Service-layer `delete_asset` now sets `deleted_at = now()` + writes a `'deleted'` `AssetHistory` row; `asset_history` and `asset_operations` rows are PRESERVED (the prior cascade-delete broke the audit chain — CLAUDE.md rule 6). All `select(AssetItem)` paths add `AssetItem.deleted_at IS NULL` so deleted rows are invisible to lists, detail, claim, return, borrow, transfer, dispose, and inventory.
- **公司主体信息 single-source-of-truth** (added 2026-05-08): `asset_legal_entities` (managed via the OfficeAddress page's 公司主体信息 tab) is the only source for `usage_company` Select options on every asset form (list filter / claim / borrow / change-assignee / batch claim / batch borrow / edit / detail-page edit). `assetApi.listLegalEntities()` → `GET /assets/legal-entities` → filtered by `is_active=true`. Admins manage the list once in the admin console; no more hardcoded company arrays scattered across page components. Old `usage_company` strings on existing assets that no longer match an active entity still render via Antd Select's raw-value fallback (no data loss).
- **位置 single-source-of-truth** (added 2026-05-08): `asset_locations` (managed via AssetSettingsPage 位置管理 tab) is the only source for `location` Select options on every asset form. `AssetIntakePage`, `AssetListPage` (filter + edit + batch + bulk-update), `AssetDetailPage` (4 inline-edit modals), `MyInventoryPage` (inventory respond `moved_to`), `AssetInventoryAdminPage` (cycle scope) all call `assetApi.listLocations(false)` → `GET /assets/locations?include_inactive=false`. Backend already orders by `sort_order, code`. Before this PR the five pages each shipped their own hardcoded array (`['北京', '上海', ..., '北美', '新加坡', ...]`), so a rename in 位置管理 silently desynced from every dropdown — the bug that triggered the refactor was operator renaming `北美` → `美国` and seeing the old name persist in intake/edit. Existing `asset_items.location` strings that no longer match an active location still render via Antd Select's raw-value fallback. Pairs with one-shot `UPDATE asset_items SET location='美国' WHERE location='北美'` data backfill (transfer history snapshots stay frozen by design — they're audit evidence).
- **Managed-location write-time resolver** (added 2026-05-30, migration 226): the read-side single-source-of-truth above only constrained the DROPDOWN; the column itself was still free-text `String(256)`, so HR sync (`work_location_name` city-level values like `Santa Clara`/`北京市`/`Würzburg`) and legacy 易盘点 xlsx imports re-dirtied it on every write. New [`app/domains/asset/location_resolver.py`](../../server/app/domains/asset/location_resolver.py) `ManagedLocationResolver` enforces the contract at the SERVICE layer: every write path that touches `asset_items.location` (`claim_asset` / `return_asset` / `borrow_asset` / `change_assignee` / `create_asset` / `intake_asset` / `update_asset` / `recycle_to_office` / inventory `moved_to`) routes the raw value through `.resolve()` which (1) exact-matches against active `asset_locations.name`, (2) falls back to a 70-entry built-in normalization map (`北京市→北京`, `Santa Clara→美国`, `Würzburg→德国`, `Sydney→澳大利亚`, `Toronto→加拿大`, …) re-validated against the master so deactivating a managed row instantly breaks resolution without code change, (3) returns `None` for unknowns. PATCH semantics: unresolvable raw protects the existing column value (typo on edit can't blank a correct value); explicit empty/None clears it; resolved-and-rewritten cases stamp `operation.detail['location_raw']` alongside `detail['location']` so the operator's actual submission is auditable. Migration 226 ships 13 new master rows (武汉/大连/宁波 + 澳大利亚/加拿大/印度尼西亚/爱尔兰/台湾/泰国/韩国/阿联酋/越南/波兰/英国/意大利/罗马尼亚, codes ISO-3166 alpha-2 + P-CNxx) so HR's actual-employee-country distribution all resolves to a managed name rather than getting silently dropped to `None`. **HR auto-fill plumbing**: `GET /assets/return-defaults` now pre-resolves `office.city` / `work_location_name` and returns a `resolved_location` field; `AssetListPage` + `AssetDetailPage` 退库 modals consume that field directly, so the Select never receives a value the dropdown lacks. Resolver master snapshot is per-request (no process-wide cache; admin location settings take effect on the very next call). Tests: 22 cases pin matrix + boundary + regression against historical data-fix trace_ids `f63a1c64-…` and `f5ebbdd4-…`.
- **Location is the unit of asset authority** (2026-08-24, migration 535): `asset_location_admins` (location_id, person_id, is_primary) is the single source of `asset_admin` role membership AND the region every asset write is scoped against. It replaced `asset_office_admins`, which forced every "who covers this asset" question through a four-tier inference ladder (`holder_office_id` → assignee city → `office.asset_location` → the one office in that country) with a wrong answer available at each rung. Resolution is now two tiers, both through `ManagedLocationResolver`: the asset's own `location`, then its assignee's HR work location — so a person and the assets they hold always land in the same region. Unresolvable ⇒ only the global tier may act. The resolver is loaded with `include_inactive=True` for the gate specifically: retiring a location must not strand the assets left there. **`asset_office_local_admins`** (the renamed office join) survives as the office's 当地 Admin — a local IT contact read by exactly one flow, the Day-1 welcome CC, which resolves from the *hire's* work location and answers "who greets this person". It grants nothing; `test_office_local_admin_grants_nothing` pins that. **The rule for which list a flow reads is its input, not its business name** (2026-08-25): a function handed an ASSET asks the location's administrator (offboarding hardware recipient, recycle-to-office recipient, handover suggestion, inventory fan-out); one handed a PERSON asks the office's 当地 Admin. The first three were briefly mis-filed as person-anchored because their business names sound like HR, and that mis-filing also concealed a live defect: they matched `asset.location` to `office.city` by strict equality, which held only for the six Chinese cities, so 300 of 1099 production assets — every US, Singapore, Japan and Malaysia device — fell back to the leaver's manager. `resolve_office_location_primary_admin()` ("who owns what is shipped here") is deliberately a separate function from `get_office_primary_local_admin()` ("who do the people here ask"). **Mutating the location master or any location's administrator list takes `require_global_asset_admin()`** (`global_admin` + `asset_global_admin`), not `require_asset_admin` — the list IS the grant set, so leaving it at the regional tier would let a Beijing admin add themselves to Tokyo. The five region gates live in [`app/domains/asset/region_gates.py`](../../server/app/domains/asset/region_gates.py) and the MCP tools import them rather than keeping the copies that had already drifted (the MCP twin had lost the explicit-dispatch-location fallback and nested the `pre_hire` bypass under an extra check). `GET /assets/locations/coverage` backs the settings page's coverage view; a location that is active, holds assets or people, and has no administrator is reported `is_uncovered`. The inventory bucket token is `location_admin` (**migration 543**, 2026-08-25); it read `office_admin` until the recipient stopped being the office's 当地 Admin. **Reads accept both spellings and writes emit only the new one** — the deploy applies migrations before restarting containers, and production had four active cycles holding 75 rows in this bucket, so an old container could still write the old token against a migrated table. Filter on `HOLDER_KINDS_LOCATION_ADMIN`, never on the bare new value; the reminder fan-out indexes its bucket dict **directly** by `holder_kind`, so an unmapped token raises KeyError partway through a send (`_bucket_key()` normalises it). **Migration 536** then dropped `asset_office_addresses.asset_location` (the office→location bridge from 525): the last reader, the office-name display beside an asset, derives the same answer from `office.city` through the resolver, and a second stored copy of the relationship was one nothing maintained. The office's single primary contact comes from `office_local_admins.get_office_primary_local_admin()`; the denormalised `admin_person_id` column was dropped by **migration 540**, one deploy after its readers moved, because the deploy applies migrations before it restarts containers. The office API still returns `admin_person_id` — derived from the join's `is_primary` row, so the wire contract did not change. Coverage is computed once in [`location_coverage.py`](../../server/app/domains/asset/location_coverage.py) and rendered by both the settings page and the MCP twin `get_asset_location_coverage`.
- **Asset-tag auto-generation rule** (added 2026-05-08, migration 132; migration 133 dropped the `code` UNIQUE in same day): on intake the asset tag is generated server-side as `IT-{purchase_entity.code}-{cat_no_p}-{YYYYMMDDHHMMSS}` (example `IT-CN-NB-20260508151325`) when `asset_tag` is omitted. `purchase_entity.code` comes from the new `asset_legal_entities.code` column (1-16 chars `[A-Z0-9]`, **NOT unique** — multiple Chinese subsidiaries legitimately share `CN`; tag-row uniqueness is enforced at `asset_items.asset_tag` UNIQUE), surfaced in the OfficeAddress 公司主体信息 admin tab. the `{code}` segment is the literal tag code from the `asset_category_configs` row whose `asset_category` matches the asset category (laptop→NB, desktop→DT, monitor→MT, printer→PR, server→SV, network_device→ND, accessory→AC, conference_display→CD). **As of migration 205 (2026-05-27) this mapping is admin-managed via the 分类管理 tab, NOT a hardcoded dict** — the old `_CATEGORY_TO_TAG_CODE` constant and the "store PDT then strip leading P" dance are gone; `code` is stored and emitted verbatim (what an admin sees in 分类管理 is exactly what lands in the tag), resolved by `AssetService._category_tag_code` / `generate_asset_tag_for_category`. The enum link is `asset_category_configs.asset_category` — a column that already existed on **prod** as undocumented drift (added out-of-band, populated mapping each row to the enum; NOT in migration 001 or the model). Migration 205 **adopts** it (adds it on fresh/migrate-from-001 envs where it's absent, adds the UNIQUE index, brings it into the model) and sets each row's `code` to the real tag segment — on prod this `UPDATE`s the 8 pre-existing rows (whose admin codes were the unused `P-PC`/`P-NB`/… scheme; names/hierarchy/is_active preserved), on a fresh DB it `INSERT`s them. Editing a category's `code` takes effect on the next intake; a missing/inactive config row fails loud with `CATEGORY_TAG_CODE_MISSING` (no hardcoded fallback). System categories (`asset_category` set) can't be deactivated or deleted (the intake dropdown depends on them). The `/assets/generate-tag` endpoint now takes a category *enum* (`category`) and resolves via the same path. The 14-digit timestamp is to-the-second so two intakes for the same `(entity, category)` pair within a minute don't collide; on a same-second collision the service retries with `+1s` up to 60 ticks then raises `ASSET_TAG_ALLOCATION_FAILED`. Admins migrating legacy assets with externally-printed labels may still pass an explicit `asset_tag` and the auto-generation path is bypassed. New error codes (all 400, surfaced verbatim by the intake form): `PURCHASE_ENTITY_REQUIRED`, `LEGAL_ENTITY_INACTIVE`, `LEGAL_ENTITY_MISSING_CODE`, `CATEGORY_TAG_CODE_MISSING`, `ASSET_TAG_ALLOCATION_FAILED`. The abandoned `(location_code, category_code, year_month)` `AssetTagSequence` counter (and its `asset_tag_sequences` table) was fully removed in migration 205 — it was dead code, never consulted by the timestamp-based path.
- **Asset state-machine bypass closed** (added 2026-05-04): `AssetUpdateRequest.status` and `AssetIntakeRequest.assigned_to` REMOVED from the public schema. Status can only change through the lifecycle endpoints (claim / return / borrow / borrow_return / transfer / dispose / repair); intake creates an idle asset, then the caller must `claim_asset` to assign it. **Inventory responses never move ownership** (changed 2026-07-17): `respond_to_item` no longer accepts `response_type='transferred'` — `transferred` was removed from `RESPONSE_TYPES_BY_HOLDER_KIND` for every holder_kind, so user-to-user asset moves are asset-admin-only via the dedicated `POST /assets/{id}/transfer` / `change-assignee` endpoints (`require_asset_admin`). A holder who no longer has an asset reports `not_mine` (photo required); an admin then executes the real transfer. The prior inventory-driven `asset.assigned_to` rewrite + `AssetOperation('transfer')` write is gone; legacy items that already recorded `transferred` still read back and stay frozen against re-respond (see below). The MCP `respond_inventory_item` tool dropped its `transferred_to_id` param in the same change (no tool-count change). Borrow `expected_return_date` is REQUIRED by the schema; the new `asset.borrow_overdue` cron warns the owner + borrower the day after a borrow lapses (per-day idempotent via outbox lookup).
- **Inventory re-respond policy** (added 2026-05-21): the /me/inventory UI exposes a "修改回应" button on already-responded items and prefills the prior values into the same RespondModal — backend `inventory_service.respond_to_item` was previously rejecting every non-pending item with a 409 "first response wins", a UI/backend contract mismatch the user hit by flipping `damaged → in_use`. Service now permits re-respond on `confirmed` / `issue_reported` items while bounded so we never have to undo side-effects we can't undo cleanly: (a) `issue_status='resolved'` items are frozen (admin already acted), (b) prior `response_type='transferred'` items are frozen (canonical asset row has already moved; the asset transfer flow owns the undo). Counters self-correct via the existing `_recompute_counters` pass over the items table. Frontend `RespondModal` also seeds `photoList` from `item.evidence_photos` on edit and mints presigned-GET URLs for thumbnails so re-saving an unchanged `not_mine` response no longer trips the "≥1 photo" validator. Audit log already captures every `/respond` call via the router so edits remain traceable. Tests: [`test_inventory_respond_modify.py`](../../server/tests/test_asset/test_inventory_respond_modify.py) pins the four boundaries (issue→confirmed flip + counters, confirmed→issue flip, resolved 409, transferred 409). 0 migrations, 0 MCP changes.
- **IT 帮忙确认 — admin proxy inventory response** (added 2026-07-31, 0 migration / 0 cron / 0 RBAC / 0 new MCP tool): `POST /asset-inventory/items/{id}/respond` has always accepted an asset-admin answering an item routed to someone else (`is_admin=True`), but nothing exposed it — the admin cycle-detail table had no affordance, so the only ways to clear a non-responding holder's row were to nag them or to close the cycle with it still 待响应, and the `it_fallback` bucket (rows with NO holder, which only IT can answer) was unreachable from the UI entirely. The action column on [`AssetInventoryCycleDetailPage`](../../web/src/pages/asset/AssetInventoryCycleDetailPage.tsx) now offers 「IT 代确认」 on pending holder rows, 「IT 登记结果」 on `it_fallback` rows and 「修改回应」 on confirmed rows — all gated on `cycle.status === 'active'` because the service 409s a response against a draft/closed cycle. The employee-facing form was extracted verbatim to a shared [`InventoryRespondModal`](../../web/src/pages/asset/components/InventoryRespondModal.tsx) (`onBehalf` prop) so the per-holder_kind option sets, the `moved_to` / `not_mine` conditional fields and the controlled evidence-photo upload contract cannot drift between the two surfaces; `IT_FALLBACK_RESPONSE_TYPES` mirrors the server's union set for that bucket. **Two invariants make the proxy path auditable rather than a silent confirm-anything button**: (1) `respond_to_item` REQUIRES non-blank `response_notes` when an admin answers an item that HAS a holder (record how the asset was verified — Feishu reply, in-person SN check); the holder's own submit and the holder-less `it_fallback` bucket keep notes optional. (2) `_hydrate_items` returns derived `responded_on_behalf` (= `responded_by` set, holder set, and the two differ) + `responded_by_name`, and BOTH surfaces tag the row 「IT 代确认」 — the admin table (with a who-did-it tooltip), the holder's own /me/inventory list and the mobile 已回应 list — so a confirmation the employee never typed is never presented as if they did (they can still edit it until the cycle closes). No new column: the flag is derivable from `responded_by` vs `holder_person_id`. The audit detail gains `responded_on_behalf` alongside the pre-existing (broader) `is_admin_override`, which also fires for the holder-less bucket, so log and screen agree. MCP parity: the existing `respond_inventory_item` tool already computed `is_admin` from roles, so it inherits the notes rule — and it now mirrors the HTTP route's #1778 **region gate** on the override path (a region-scoped `asset_admin` could previously proxy-confirm an out-of-region row through MCP). Tests: [`test_inventory_it_proxy_confirm_2026_07_31.py`](../../server/tests/test_asset/test_inventory_it_proxy_confirm_2026_07_31.py) (7 cases: proxy confirm stamps the admin + flips counters + writes back to the asset row, notes required incl. whitespace, self-response unaffected, it_fallback is not a proxy, non-admin third party still 403, hydrate flag/name appears and CLEARS when the holder edits it themselves, plus the HTTP round-trip).
- **Asset detail anti-enumeration** (added 2026-05-04): non-admin / non-scope viewers get a uniform 404 on `/api/v1/assets/{id}` regardless of whether the asset exists, is idle, or is held by another user — closes a UUID-probing leak that previously distinguished 404 (missing) vs 403 ("you can only view your own") vs 403 ("unassigned, admin only"). Operation/history rows in the response are also filtered by viewer scope so a manager doesn't see third-party borrowers' names.
- **Active-employee guard on grant targets** (added 2026-05-04): `_verify_user` (used by every asset claim / borrow / change_assignee path) refuses targets whose `employment_status ∈ {leaver_pending, left}`. Same gate added to `software_seat` `admin_grant_seat` (router) and `_activate_software_seat` (Feishu callback) so seats can't be granted to in-progress leavers either; the callback also enforces `allocation.user_id == approval_request.applicant_id` as defence-in-depth IDOR check.
- **Asset list search joins holders** (added 2026-05-09): `list_assets()` `OUTER JOIN`s `identity_persons` twice (assignee + borrower aliases) and the search predicate matches `display_name / work_email / employee_no` on either side in addition to `asset_tag / brand / model / serial_number / asset_name`. Without this, typing a borrower's name returned no rows and admins couldn't reach borrowed laptops from the asset list. Count query uses `func.count(func.distinct(AssetItem.id))` so the two joins don't inflate totals (each AssetItem has at most one assignee and one borrower, so duplication is a non-issue in practice — distinct is belt-and-suspenders). Pairs with the search-placeholder copy update advertising person/employee_no/email coverage.
- **Workbench distribution charts** (added 2026-05-09): `AssetStatsResponse` now also returns `by_brand / by_location / by_usage_company / by_model` (top-10 cap on model only — brand/location are already small cardinality). Frontend `AssetWorkbenchPage` distribution tab renders 6 ECharts panels (`echarts-for-react` 3.0): category-share donut, lifecycle-share donut, top-10 brands bar, top-10 locations bar, top-10 models bar, usage-company donut. Empty fields (`brand IS NULL OR brand = ''`) excluded from each aggregation. The progress-bar list previously rendered for category distribution is gone; status chip rail kept beneath the chart grid.
- **Asset list filters surfaced inline** (added 2026-05-09): the "高级筛选" drawer was removed; status / category / location / usage-company / assignee multi-selects now render in the top-of-list filter row alongside the search box. The assignee `UserSelect` opts in to `filterStatus={['active', 'leaver_pending', 'left']}` so admins can find assets owned by leavers — default `UserSelect` filter is unchanged.
- **Departed-employee tag in `UserSelect`** (added 2026-05-09): picker option renders `(employee_no)` next to the display name and a red `已离职 / Departed` Tag when `status === 'left'`. Selected-tag label appends `· 已离职` so the indicator survives after the dropdown closes. New i18n key `common.departed`. Pairs with `list_users_filtered` now matching `employee_no` on the search predicate so `P0xxxx` resolves directly.
- **Asset status vocabulary is closed and write-enforced** (2026-08-07, migrations [454](../../server/alembic/versions/454_canonicalize_legacy_in_stock_status.py) + [455](../../server/alembic/versions/455_asset_status_default_idle.py); ADR in DECISIONS.md): `asset_items.status` has exactly **14** legal values, defined once in [`models.ASSET_STATUSES`](../../server/app/domains/asset/models.py) with `schemas.ALLOWED_STATUSES` derived from it, and **`AssetItem.validate_status` (`@validates`) rejects anything else at write time** — ORM level, not just the request schema, because the token that froze a laptop for months arrived through a bulk import that never touched `AssetCreateRequest`. Live states: `idle` (canonical stock) / `claimed` (employee picked up) / `assigned` (admin dispatched) / `borrowed` / `held_by_office` / `held_by_leaver` / `disposed`. Legacy-but-accepted: `available` (idle's twin — `unassign_asset` was its last writer, now writes `idle`; `AssetCreateRequest` default and the DDL column default both moved to `idle` in the same pass). Reserved, no writer today: `claim_pending` / `return_pending` / `borrow_pending` / `borrow_return_pending` / `transferring` / `disposal_pending` — kept because `ASSET_IN_USE_STATUSES` (offboarding scanner) and the inventory snapshot exclusions speak them, and a future request→confirm flow would reuse the names; they are excluded from the frontend status filter instead. **Retired** (0 prod rows, 0 writers): `maintenance` (repair lives on `device_status`), `retired` (duplicate of `disposed`), `faulty` (name-collided with `device_status='faulty'`), `testing` / `legacy`. Turning the guard on immediately surfaced **8 test fixtures seeding `status="in_use"`** — an inventory *response type*, never an asset status, and the same typo `hr_lifecycle/advance_service.py` documents as having "silently corrupted the status enum" and hidden assets from a lender's offboarding scan. Frontend mirrors the vocabulary in [`types/asset.ts`](../../web/src/types/asset.ts); the list filter offers only the 7 reachable states (was 15, of which 9 could never match).
- **`asset.status.in_stock` legacy token — RETIRED from data 2026-08-07, migration [454](../../server/alembic/versions/454_canonicalize_legacy_in_stock_status.py)**: `in_stock` is a pre-Helix Excel-import synonym for idle, never in `schemas.ALLOWED_STATUSES` and written by no code path. 2026-05-09 added an `asset.status.in_stock` i18n key so the "流转阶段" cell stopped rendering the raw token — that made the row *legible* but left it **frozen**: every holder-changing gate (`claim_asset` / `assign_asset` / `borrow_asset` / `recycle_to_office` / `mark_held_by_leaver`) whitelists `('idle','available',…)`, so the asset could not be dispatched to anyone and 「变更持有」 answered 「当前状态不支持变更持有」. Reported 2026-08-07 by IT for `P-JP-NB2302001`; prod carried exactly **1** such row (full distribution then: idle 489 / claimed 418 / held_by_office 117 / disposed 42 / borrowed 20 / held_by_leaver 11 / in_stock 1). Migration 454 flips it to `idle` (idempotent, ignores `deleted_at`); the i18n key stays as a renderer for any un-migrated environment. **Rule this establishes: a status token outside the canonical vocabulary must be migrated out of the data, not taught to each gate** — migration 188's `IDLE_STATUSES = {idle, available, in_stock}` was the one place that knew the synonym, and one place knowing it is what let the row sit unusable from the 2026-04-21 import to the 2026-08-07 report.
- **Asset list `holder_kind` + `holder_office_id` filters** (added 2026-05-26; PR 2026-05-26 follow-up extends MCP parity + closes audit gap): the asset list ([`AssetListPage.tsx`](../../web/src/pages/asset/AssetListPage.tsx)), the export endpoint, the MCP `search_assets` tool AND the MCP `list_team_assets` tool all accept two new query params — `holder_kind` (multi: `personal`|`office`) and `holder_office_id` (multi-UUID). The `list_team_assets` parameters AND-combine with the manager's transitive subordinate scope so a manager filtering office-held assets still sees only their reports' rows. Backend [`AssetService.list_assets`](../../server/app/domains/asset/service.py:826) validates `holder_kind` against the canonical pair and rejects unknown values with `ValidationError` at the service boundary (single contract shared by HTTP / MCP / internal callers). Export endpoint audit detail now captures both filters (was silently omitting them — admin exporting "office-held only" left a `filters={...}` payload that said "no narrowing applied"). i18n re-uses the existing `asset.holderKind.*` / `asset.holderOffice.*` keys (no new bundles). Frontend UX: `optionLabelProp="tagLabel"` shows just the city in the selected chip while the dropdown row keeps `城市 · 办公室名`, eliminating the responsive-overflow measure-then-collapse flicker on long CJK labels; archived offices stay resolvable in chips with a `(已归档)` suffix via the new `common.archived` i18n key; activeFilters chips are `closable` (per-chip clear). Mobile admin list (`MAdminAssetsListPage`) intentionally stays search-only — design has a `DesktopOnlyBanner` for fine-grained filters. Motivated by: after [Office-held mode](#) migration 183/186-188 landed, holder column already rendered office name but admins had no way to scope the list to "all office-held" or "Shanghai office only" without falling back to fuzzy location-string search.
- **Office-held mode** (added 2026-05-19, migrations 186-188; 2026-05-26 `location`-sync follow-up): `recycle_to_office` and `return_asset(return_target='office')` now sync `asset.location ← office.city` whenever the holder flips to office mode so inventory group-by-location / location-filter / legacy office-routing fallback don't keep pointing at the prior holder's city. `return_asset` respects an explicit `request.location` override (admin typed something); `recycle_to_office` always overrides (recycle has no admin-typed location field). Explicit first-class holder mode for assets that physically live at a city office rather than with a Person. Schema: `asset_items.holder_kind ∈ {'personal','office'}` (default `'personal'`) + `holder_office_id` FK → `asset_office_addresses.id`. Companion status `held_by_office` (frozen — claim or release transitions out). Invariant: `holder_kind='office' ⇒ assigned_to IS NULL AND holder_office_id IS NOT NULL` (service-layer enforced). Routing: inventory's `_classify_asset` checks `holder_kind` FIRST — `'office'` lands the route at `holder_office_id.admin_person_id` with no string match, no surprises. The legacy `asset.location ↔ asset_office_addresses.city` path survives as a fallback for personal-held idle stock that didn't get migration-188 backfilled. Lifecycle entry points: `POST /assets/{id}/recycle-to-office` (Asset Admin) + bulk variant + `POST /assets/{id}/release-from-office`; office mode is also accepted at intake (`AssetCreateRequest.holder_kind='office'` lands the row directly at `held_by_office`). Claim from `held_by_office` is a legitimate transition that clears the office fields. Dispose clears holder fields. MCP tools `recycle_asset_to_office` + `release_asset_from_office`. The "everything stacked on Meng" pattern that motivated this redesign was the T+0 force-transfer cron silently overwriting `assigned_to` to whichever IT admin had `_resolve_hardware_recipient` resolve to them — see migration 188 docstring + DECISIONS.md ADR for the full picture.
- **`held_by_leaver` non-standard but real asset state** (added 2026-05-12, migration 149): captures hardware that physically left the company with a departed employee (overseas walk-out, unresolved dispute, deliberate IT decision). Set automatically by `OffboardingService.skip_blocker` when the operator clicks Skip on a `RES_HARDWARE` blocker, and manually via `POST /assets/{id}/mark-held-by-leaver` (asset-admin only; MCP tool `mark_asset_held_by_leaver`). The asset stays company property: `assigned_to` (and `borrowed_by` if applicable) is PRESERVED so "last holder" attribution and the audit trail remain intact. Status is deliberately NOT in `ASSET_IN_USE_STATUSES` — the offboarding scanner uses that set to find resources still held by an in-process leaver, and a `held_by_leaver` row by construction belongs to an already-departed person, so re-detecting it on rehire would re-open the process for the wrong reason. Every standard mutation path (`claim_asset`, `return_asset`, `borrow_asset`, `change_assignee`, `repair_asset`) refuses `held_by_leaver` via its existing status whitelist; `dispose_asset` ALLOWS it (write-off path is intentional). Recovery: `POST /assets/{id}/recover-from-leaver` (MCP tool `recover_asset_from_leaver`) clears `assigned_to` / `borrowed_by` / `expected_return_date` and flips status to `idle` so the asset can be re-claimed. Weekly digest cron `asset.held_by_leaver_reminder` (migration 149) counts held rows and pushes one in-app notification per IT admin per ISO-week (silence-by-default if count=0). Frontend: red volcano-tag everywhere status renders; "已离职" chip next to `assigned_to`; admin actions "标记为离职持有" + "追回" in the AssetListPage row menu; offboarding console adds a "已转为离职持有" sub-tag in the resolved bucket on skipped hardware blockers. Tests: [`test_held_by_leaver_2026_05_12.py`](../../server/tests/test_offboarding/test_held_by_leaver_2026_05_12.py) pins 14 invariants (skip→held, hardware-only, recover→idle, 5 mutation rejections incl. repair, idempotency, dispose-without-force preserves `from_user_id`, `_to_process_response.asset_held_by_leaver` server-stamping). Plus [`test_held_by_leaver_reminder.py`](../../server/tests/test_scheduled_jobs/test_held_by_leaver_reminder.py) for the cron's silence-by-default + per-week dedup. The `OffboardingBlockerResponse` schema gains an `asset_held_by_leaver: bool` field (batch-fetched from `asset_items.status` in `_to_process_response`) so the frontend "已转为离职持有" chip is gated on actual asset state, not just `resource_type='hardware' + resolution='skipped'` — without this gate the chip would lie when `skip_blocker`'s best-effort auto-mark fell through.

### 12.1.1 Procurement (v2, PR 1 + PR 2 + PR 3 — 2026-05-30)

**Tables** (9 original — migration 223, plus serial allocator): `procurement_categories`, `procurement_suppliers`, `procurement_contracts`, `procurement_contract_attachments`, `procurement_contract_versions`, `procurement_orders`, `procurement_order_items`, `procurement_payments`, `software_procurement_meta`; migration 446 adds `procurement_serial_counters`. Human-number allocation uses one transactional row per `(prefix, UTC calendar year)` and MySQL/TiDB atomic UPSERT. Supplier/contract/order counter mutations, resource rows and HTTP audits share the caller transaction; payment counter and payment row share its existing service-owned transaction, so a rollback in either path reuses the uncommitted number rather than leaving a hole. Both automatic and canonical explicit paths take the counter before the target UNIQUE index; the explicit floor update and INSERT share a SAVEPOINT so duplicate input returns 409 without advancing the floor. Supplier/order MCP writes reuse the same service allocator and their normal `tool_runtime` transaction. Contract-version and added-order-line allocation lock only their own parent row; PO batch creation reserves all explicit positive line numbers before filling omitted values. This allocator does not change payment deduplication or audit orchestration.

**Routes**: `/api/v1/procurement/*` — `categories` (CRUD) / `suppliers` (CRUD) / `contracts` + `/{id}` (CRUD with version-history audit) / `orders` + `/{id}/items` (CRUD) / `payments` (CRUD) / `software/{id}/meta` (1:1 sidecar) / `migration-review` + `migration-review/{type}/{id}/clear`.

**MCP twins** (8 new tools — count hint 195 → 203): `list_procurement_categories`, `list_procurement_suppliers`, `create_procurement_supplier`, `list_procurement_contracts`, `get_procurement_contract`, `list_procurement_orders`, `get_procurement_order`, `create_procurement_order`.

**Positioning** — Helix is the IT-side facts layer, NOT the approval / payment engine. Finance approval lives in 汇联易; actual payment execution lives in Oracle NetSuite. PR 1 stops at: master data + relational links + version-history audit + multi-currency original-+-CNY fields + migration-review queue. PR 2 lands the unified P2P flow chart + page redesign + asset↔PO backfill. PR 3 lands the budget pool + cron reminders.

**Invariants**:
- **Four-role owner model**: every PO + contract + software_procurement_meta carries `business_owner_id` + `it_contact_id` + `finance_contact_id` + `procurement_owner_id` FKs to identity_persons.
- **Multi-currency with frozen FX**: row-level `currency` + `total_amount`; service layer freezes `fx_rate_to_cny` + `total_cny` at create/update time. USD via `resolve_cny_per_usd`; other currencies leave CNY rollup NULL.
- **Contract version history is append-only** — every PATCH that touches an audited field writes a `procurement_contract_versions` row BEFORE updating the main row.
- **No speculative backfill**: legacy free-text that doesn't resolve to a FK goes into `legacy_unresolved_fields` JSON for admin review at `/admin/procurement/migration-review`.
- **TiDB FK-strict types**: all FK source columns built with `_fk_source_col_type` mirroring target DATA_TYPE+COLLATION_NAME.
- **Old asset-prefixed tables coexist through PR 1 + PR 2** — PR 3 drops them after a deploy window. PR 2 redirects the UI routes (`/admin/assets/{procurement,business-payments,suppliers}` → corresponding v2 page); legacy components stay in the bundle as a rollback safety net.
- **Authoritative seeds**: `procurement_categories` ships with 4 starter codes; `receipt_semantics` enforced against {`hardware_provision`, `software_activation`, `service_acceptance`}.
- **Audit-by-design**: every write through `/api/v1/procurement/*` emits an `audit_logs` row via the shared `_log_audit` helper.
- **Concurrency-safe numbering (#2287, 2026-08-02)**: `SUP / CT / PO / PAY` numbers allocate from one transactional `(prefix, serial_year)` counter row via MySQL/TiDB atomic UPSERT; counter and resource commit or roll back together. A missing row bootstraps from the numeric maximum of legacy numbers, and an explicit canonical high number advances the floor. Target UNIQUE keys remain the final arbiter; explicit duplicates are isolated in a SAVEPOINT and return typed 409 without poisoning the outer session, while drift collisions resynchronise the floor and retry. Contract edits lock the contract parent before allocating `version_no`; order-line creation locks the order parent before allocating `line_no`, covering the empty-child case where no child row/gap exists to lock.
- **Constant-query list decoration (#2288, #2423, 2026-08-02)**: supplier, contract and order list cards batch all child counts for the current page rather than issuing per-row COUNTs. Supplier `(contract_count, active_contract_count, po_count)` and contract `(attachment_count, po_count)` each use independently pre-aggregated child streams plus one `UNION ALL` rollup (avoids cross-product inflation); order `item_count` uses one grouped aggregate. Query count is constant for page sizes 1/50/200, empty pages skip the aggregate, and the orders XLSX export reuses the same decorated-row path. HTTP RBAC/masking/filter/order/page semantics and the existing raw MCP list shapes are unchanged. `SupplierRead` NULL-JSON normalization is copy-based: serializing a GET response never dirties the ORM entity, so the request dependency's success commit cannot turn a read into an unaudited supplier write.
- **Durable create idempotency (#2284, #2438, #2440, 2026-08-02)**: supplier / contract / PO / payment creation accepts HTTP `Idempotency-Key`; the two matching MCP create tools accept `idempotency_key`. The shared `core_idempotency_records` row is scoped by `(operation, actor, key)` and bound to a canonical Pydantic payload fingerprint. Every wrapper locking-current-validates the supplied actor and rejects an actor/scope mismatch, so key presence cannot bypass the leaver runtime boundary. Resource rows, PO items, the initial budget `lock`, audit, and completed intent commit atomically. Same-key replay reloads the original resource, a concurrent duplicate cannot create a second row, and different payload returns 409. Payment canonicalizes amount/currency/invoice/explicit number before fingerprinting on both keyed and unkeyed paths. For a keyed payment, the counter-first number mutation, payment, completed intent and one create audit share the payment service's single commit; callback/commit failure rolls all four back before retry, while replay emits no audit. The web create modals retain one key after uncertain failures. Payment's legacy 60-second heuristic remains only for callers that omit a key. Helix does not currently create NetSuite / 汇联易 objects here; `external_*` fields are local correlation values and are recovered by replaying the original Helix resource.

**PR 2 additions (UI consolidation, 2026-05-30)**:
- `/admin/procurement` Overview page with a **9-stage P2P flow chart**. Each stage is one of three states: `helix` (bright green, click-through), `planned` (yellow, on the PR-3 roadmap), `external` (grey, owned by Helian / Oracle NetSuite — tooltip explains what Helix records vs what the external system owns). The flow chart is the single canonical answer to "where in the P2P loop does Helix sit?" for new operators.
- `GET /api/v1/procurement/overview` (read-only, `require_software_read` — global_admin + asset_admin + finance + auditor + management, Round-57) returns a one-shot bag of dashboard data: KPIs (active suppliers, contracts expiring in 90 days, overdue-not-marked, total POs), PO histogram by status, migration-review backlog counts, and the 5 most recent POs + contracts. No MCP twin (computable from existing list endpoints).
- Legacy routes `/admin/assets/procurement`, `/admin/assets/business-payments`, `/admin/assets/suppliers` now redirect to the v2 pages. Component files + their backing API endpoints remain alive for rollback; PR 3 deletes them.
- MainLayout sidebar collapsed: the "采购管理" group now points to 7 v2 entries (overview / orders / contracts / suppliers / payments / categories / migration-review), replacing the previous mix of asset-prefixed + procurement-prefixed entries.

### 12.2 HR

**Tables**: `hr_employees` (legacy mirror of `identity_persons`), `hr_sync_batches`, `hr_sync_staged_events`, `hr_lifecycle_events`.

**Routes**: `/api/v1/hr/sync`, `/api/v1/hr/sync/batches/*`, `/api/v1/hr/successfactors/webhook`, `/api/v1/hr/offboarding-cases/{user_id}`, `/api/v1/hr/people/{person_id}/mark-leaver` (manual offboarding entry point — admin-only; flips `employment_status='leaver_pending'` + `termination_date` + invokes `OffboardingService.start_process` on the same path the HRIS sync coordinator uses, so the resulting flow is byte-identical to a SF-driven termination; idempotent: re-marking a non-`active` person → 409. Matching MCP tool `mark_person_leaver_manual`).

**Invariants**:
- Sync modes: `full` / `incremental` (since lastModifiedDateTime cursor) / `window` (hire+term events in date range).
- Webhook from SF creates batch in `pending_review`; admin confirms → applies. Confirm runs `SELECT FOR UPDATE`.
- Batch failure isolation: critical errors (FK violation, dup email) roll back entire batch; non-critical (phone parse) recorded in `hr_sync_errors` row, batch proceeds.
- Idempotency: `(source_system, hris_person_id)` UNIQUE; re-running same SF data is safe.
- **work_email ↔ provider_email parity (added 2026-05-07, migration 130)**: `HrSyncService._resolve_unique_work_email` MUST keep `identity_persons.work_email` in lock-step with the SF-returned real email recorded on `identity_external_identities.provider_email` for the matching `provider='successfactors'` row. Two anti-downgrade rules: (a) when the SF email collides with another live row's `work_email` (the SF dup-userId state for the same human), the resolver returns the existing person's CURRENT real email (threaded in via `current_email=person.work_email` from the update call site) rather than flipping to the synthetic `<user_id>@successfactors.local` placeholder; (b) every fallback to that placeholder emits a `logger.warning` so SRE / on-call see a paper trail rather than the silent rot that previously poisoned 100113 / 80100057 in prod. The placeholder is a last-resort signal — when SF returns NO email AT ALL AND no real value exists to keep — not a routine collision-resolution path. Backfill-on-every-sync comes for free: a row sitting on a stale placeholder is upgraded to the SF real email automatically the next sync tick once the upstream dup is gone (the resolver returns the real email and the update assignment promotes `person.work_email`). Migration `130_backfill_stale_successfactors_local_work_email.py` performed the one-time fix for rows already poisoned before the code fix landed (scans `work_email LIKE '%@successfactors.local'`, looks up `provider_email`, UPDATEs `work_email + deleted_at = NULL` only when the real email is uncontested; idempotent re-run; downgrade is intentionally a no-op so a rollback can't re-poison real employees). The dormant `app/identity/successfactors_sync.py` full-roster path mirrors the same warning + no-downgrade behaviour so a future re-wire can't reintroduce the bug. Pairs with the existing **`HR_SKIP_EMAIL_DOMAINS`** create-time gate ([`server/app/core/config.py:138`](../../server/app/core/config.py:138)) which already blocks NEW ghost-row creation when the resolved email lands on a placeholder domain — that gate is necessary but not sufficient on its own (it doesn't catch update-time downgrades).

### 12.2.1 HR Tools (年假计算器)

**Tables**: `hr_tools_annual_leave_calculations`.

**Routes**: `/api/v1/hr-tools/annual-leave/calculate`, `/api/v1/hr-tools/annual-leave/history`, `/api/v1/hr-tools/annual-leave/history/{record_id}`.

**Gate**: split by verb (2026-05-14 privacy update; tightened same-day).
- `POST /annual-leave/calculate` → `require_hr_admin()` (HR + global_admin only — Finance excluded by product decision; running a new calculation is HR's tool).
- `GET /annual-leave/history` + `GET /annual-leave/history/{record_id}` → `get_current_user`, **two-pronged self-scope for every caller, including HR + global_admin**. A row is visible iff `row.person_id == current_user.id` (subject branch) OR `row.calculated_by_user_id == current_user.id` (calculator branch). The list endpoint accepts a `person_id` query parameter for API back-compat but unconditionally ignores it — the OR'd visibility clause is the only filter that runs. The detail endpoint returns 404 (not 403, don't leak existence) when neither branch matches. Sandbox rows (`person_id IS NULL`) pass only via the calculator branch — only the HR who created the sandbox sees it. Each row has AT MOST two visible users (the subject and the calculator), so the leak surface is bounded; a third party (admin browsing the calculator page) sees nothing they didn't personally touch. Iteration history: PR #305 had cross-role read for HR + global_admin (reverted: dev saw colleague's row); PR #306 tightened to strict subject-only `person_id == self` for every role (reverted: returned 0 rows for HR who only ran calcs for others — they couldn't audit their own work); this revision (PR #307) settled on the OR'd shape that balances privacy with HR's audit-of-own-work need. Audit-across-everyone still goes through the dedicated `audit_logs` admin surface, which has its own access controls + `trace_id` correlation.

**Invariants**:
- Calculation engine lives in [`server/app/domains/hr_tools/calculator.py`](../../server/app/domains/hr_tools/calculator.py). Pure function — no DB access, no side effects; deterministic input → output.
- Fiscal year window is anchored at Apr 1 → Mar 31 of the next year, derived from the termination date.
- Segmentation splits the fiscal-year range at every breakpoint that changes the (legal_basis, welfare_basis) lookup: career-start anniversaries 1 / 10 / 20 (社会工龄 N) and company-tenure anniversaries 1..8 (司龄 S). N=20 zeros welfare for every subsequent segment — the rule "一旦社会工龄跨入 N≥20 当日起：福利年假直接按 0 天执行".
- Two blocks are produced per run: **entitlement** (full fiscal year, "what they'd earn working all year") and **settlement** (prorated to actual employed days = max(fiscal_year_start, hire_date) → min(termination_date, fiscal_year_end)). Both pass through identical rounding + cap pipelines.
- **Proration denominator is the fixed average year `365.25`, NOT the fiscal year's own 365/366 length** (2026-08-26, HR-confirmed; migration 550). Each segment contributes `segment_days / 365.25 × basis`. It was a flat `365` until then, which quietly overpaid every full-year employee in a fiscal year containing Feb 29: `366/365 × 10 = 10.0274 → 10.03 → 10.5` days for simply working the whole year. Note that 365.25 **on its own does not fix that** (`366/365.25 × 10 = 10.0205`, which still ceilings to 10.5) — the 基数 cap below is what pulls it back to 10. The denominator earns its place in the other direction: it keeps a NON-leap full year whole (`365/365.25 × 10 = 9.9932 → 9.99 → 10.0`), which any larger denominator would have shaved. Both halves are load-bearing; removing either one reintroduces a real defect, and `test_leap_fiscal_year_full_service_is_capped_at_basis` fails on either removal.
- Rounding pipeline: sum of per-segment proration → step 1 **TRUNCATE** to 2 decimals (was round-half-up before 2026-08-26) → step 2 round UP to next 0.5 step. Applied to legal and welfare totals independently. Truncation only bites in the narrow band just above a half-day step, because step 2 ceilings anyway: `10.5051` half-up → `10.51` → buys a whole extra half-day (`11.0`), truncated → `10.50` → stays `10.5`. It is a 0.01 tolerance so an arithmetic crumb can't cost half a day.
- **年假基数 cap** (2026-08-26): applied AFTER rounding, BEFORE the 15-day cap, to each block and each leave type. A prorated result may never exceed the employee's own basis. The cap value is the **MAXIMUM basis across the block's segments**, deliberately not the closing segment's basis — an employee crossing 社会工龄 N=20 mid-year ends the year on welfare basis 0, and capping on that would retroactively confiscate the welfare days already earned in the earlier segments (pinned by `test_basis_cap_uses_max_basis_not_closing_basis`, which reproduces exactly that person). Because a block's raw total can only exceed its max basis when the block spans more than 365.25 days, this cap is inert outside the leap-year case it exists for.
- 15-day cap: applied AFTER rounding and after the 基数 cap, to each block. `legal + welfare > 15` → keep legal, reduce welfare so the sum equals exactly 15. Both values are already in 0.5-day increments, so the result is too. The two caps carry **separate** persisted flags (`cap_applied_*` vs `basis_cap_applied_*`, migration 550) because they produce different explanations to an employee disputing a settlement; one shared flag would put the wrong sentence on screen.
- Every run writes one row to `hr_tools_annual_leave_calculations` carrying the full computation graph (inputs + per-segment formula list + raw/rounded/capped totals + final balance + cap flag), plus one audit-log row `hr_tools.annual_leave.calculate`. History is HR-readable via `GET /history` and `GET /history/{id}` — the detail endpoint builds its response from the **persisted columns alone and never re-invokes `calculate()`** (`service._record_to_response`), so a rule change doesn't retroactively rewrite a historical settlement. This is what made the 2026-08-26 formula change safe to ship without touching data: rows computed under the flat-365 rules keep replaying their original numbers and their original `days/365×basis` formula strings. The flip side is that the same person recalculated today gets a different number than their archived row — an expected consequence of a policy change, and whether anyone is re-settled is HR's call, not the platform's.
- The MCP tool `calculate_annual_leave_balance` is a 1:1 wrapper around the HTTP route per the MCP-sync rule, gated by the same HR-or-admin check. AI clients can drive the calculator without scraping the page.
- New `identity_persons` columns (migration 163) — `work_start_date` (社会工龄 N 起点; SF `EmpEmployment.originalStartDate` → `_upsert_person`); and four nullable leave-balance columns (`annual_leave_carry_legal_days`, `annual_leave_carry_welfare_days`, `annual_leave_used_legal_days`, `annual_leave_used_welfare_days`).
- **`work_start_date` 全量同步必须真去查 EmpEmployment(2026-06-02 fix)**: [`import_full_roster`](server/app/domains/hr/service.py) 是 prod seed 路径,早期版本只给 forfeited 用户调 `get_employments_for_users` 抓 endDate,对 active 用户的 onboard 事件直接 `originalStartDate = user.hireDate` 伪造 —— 导致 prod 432 名 active 员工中 430 行 `work_start_date == hire_date`(career start 等于 hire date,违反字段语义)。Fix 把 `get_employments_for_users` 调用扩到所有 userId,对每个用户都用真 `EmpEmployment.originalStartDate`,SF 没 carry 才回落 `user.hireDate`。`_upsert_person` 见到非空 originalStartDate 就 unconditional 覆写,下一次手动 Full Sync 自动回填全 prod。Incremental 同步走 `list_employments_modified_since`(SF 真 EmpEmployment),originalStartDate 一直是真的 —— 所以这个 bug 只活在 full-roster 路径,但 prod 永远停在 seed 那把的错误值上,因为 SF 很少发 EmpEmployment 变更 event。回归断言钉在 [`test_confirm_full_sync_active_pulls_work_start_date_from_sf`](server/tests/test_hr/test_full_sync_forfeited.py)。
- **`work_start_date` 真正的 SF 字段是 `firstDateWorked` 不是 `originalStartDate`(2026-06-02 round-2 fix)**: 上一条 fix 之后用户验收时发现自己「参加工作日期」仍等于「入职日期」。SF 直连 dump 揭穿语义混淆 —— SF 的 `originalStartDate` 是「在这家公司多次入离职里的第一次入职」(司龄 anchor for rehires),只有 PingCAP 再入职的 47/432 行才与 `startDate` 不同;PingCAP HR 实际把「参加工作日期 / 社会工龄 N 起点」存在 **`EmpEmployment.firstDateWorked`** 里(prod 500 行抽样:66% 填了,其中 270 行与 startDate 不同 = 真 career start 数据)。`_upsert_person` 改成三级 fallback `firstDateWorked → originalStartDate → user.hireDate`,`_EMP_EMPLOYMENT_SELECT_FIELDS` 抽成共享常量挂上 `firstDateWorked` + `seniorityDate`。同 PR 顺手加 [migration 240](server/alembic/versions/240_identity_persons_hris_extra_fields.py) 三列:`seniority_date`(SF 司龄 anchor,99.8% populated)、`probation_end_date`(72% populated,转正流程依赖)、`fte`(100% populated,年假兼职 proration 用)。`gender` / `employeeClass` / `nationality` / `maritalStatus` 评估后跳过(数据稀疏或区分度=0)。回归 [`test_work_start_date_falls_back_to_original_start_when_no_first_worked`](server/tests/test_hr/test_full_sync_forfeited.py) 钉 tier-2 fallback。
- **HRIS carryover auto-sync (2026-05-14 fix)**: every `confirm_batch` run kicks off a post-commit `SuccessFactorsLifecycleService.sync_annual_leave_carryover(user_ids)` pass that refreshes those four columns from SF's EC Time-Off ledger. Helper aggregates `TimeAccount` (50 users / call OR-batched by userId + accountType) → groups by (user, accountType) into PRIOR FY (endDate < today, latest) and CURRENT FY (today within range) buckets → fetches `TimeAccountDetail` (16 uuids / call OR-batched by `TimeAccount_externalCode`) → sums bookingAmount per account → writes `carry_*` from prior-FY balance, `used_*` from sum of |bookingAmount| of `EMPLOYEE_TIME` + `DEDUCTION` lines on current FY. **Important entity-name fact** (replaces pre-2026-05-14 guess in this section): PingCAP CN tenant uses **`TimeAccount`** + `TimeAccountDetail` — `EmpTimeAccount` returns 404 (entity doesn't exist), and `EmpTimeAccountBalance` rejects `or` in $filter (`COE_HRIS_QUERY_USING_OR_IN_FILTER`) so it can't batch. Account-type codes are `CHN_AnnualLeave` (法定) + `CHN_PL` (福利) — settings `HR_TIMEOFF_LEGAL_ACCOUNT_TYPE` / `HR_TIMEOFF_WELFARE_ACCOUNT_TYPE` override per tenant.
- SF Time-Off helpers `SuccessFactorsClient.list_time_accounts` + `list_time_account_details` are defensive — on 404 / 401 / 403 (tenant lacks EC Time-Off, scope missing, entity name differs) per-chunk failures swallow with a `WARNING` log and that chunk's data falls back to NULL → HR manual input. The legacy `list_time_account_balances(EmpTimeAccount)` method (pre-2026-05-14) was deleted; if you see it in a future PR's diff, that's a regression to a broken entity name. NULL on any of the four `annual_leave_*` columns means "no HRIS source for this person yet, HR will enter manually in the calculator" — never blocks the page.

### 12.3 Permission

**Tables**: `software_permissions`, `user_permissions`.

**Routes**: `/api/v1/permissions/*`.

**Invariants**: A permission is a software feature with a per-permission `requires_approval` flag (model default `True`). Granting creates a `provisioning_tasks` row + `user_permissions` row (status flips to `active` only after the task completes).

- **No permission-approval flow exists** (clarified batch-6, 2026-07-07). Unlike sso / apikey / ai_tool_application, the permission domain has **no** approval-callback/consumer — there is no `approval_type` registered in [`app/approval/service.py`](../../server/app/approval/service.py)'s dispatcher for it, no Feishu instance, and no activation wiring. Consequently:
  - `requires_approval=False` → `request_permission` self-serves: creates a `pending` `user_permissions` row + grant `provisioning_tasks` row; reconcile flips to `active`.
  - `requires_approval=True` → `request_permission` is **fail-closed**: it raises `PERMISSION_REQUIRES_ADMIN_GRANT` (400) and creates **no** row (previously it stranded a `pending` row that could never be granted). The grant path for approval-gated permissions is IT-only `admin_grant` (`POST /permissions/grants`). If a real Feishu-routed permission-approval flow is built later, replace the guard in [`permission/service.py::request_permission`](../../server/app/domains/permission/service.py) with create-instance + callback wiring (mirror the sso/apikey pattern) and log it in DECISIONS.

### 12.4 Provisioning

(Framework — see §10.)

### 12.5 Offboarding

**Tables**: `offboarding_processes` (UNIQUE on `person_id`), `offboarding_blockers` (with round-14 `severity ∈ {blocker, advisory}` column + `(process_id, severity, resolution)` covering index, migration 136), `asset_transfer_requests`, `offboarding_reminder_logs` (idempotent on `(process, reminder_type, target_date)`).

**Routes**: `/api/v1/offboarding/*`, `/api/v1/transfer/*`.

**Invariants**:
- Process state machine: `scanning → blocked → ready → completed | cancelled`.
- Blocker resolution enum: `pending`, `transferred`, `revoked_auto`, `revoked_manual`, `skipped`, `sso_cascade`. `sso_cascade` carries explicit audit "platform deferred to SSO suspension" — distinct from `skipped`.
- Resource types — **25 as of 2026-08-29**, and like the driver count this is
  derivable, so derive it rather than trusting this line (it said 17 while the
  code had 24, before `github_org_membership` made 25):
  `grep -c '^RES_[A-Z_]* = "' server/app/domains/offboarding/models.py`.
  `hardware`, `software_seat`, `software_permission`, `api_key`, `domain`,
  `dns_record`, `cloud_server`, `load_balancer`, `cdn_domain`, `software_owner`,
  `software_it_contact`, `email_group`, `cloud_scope_owner`, `sso_grant`,
  `domain_registration`, `system_owner`, `approval_flow_owner`,
  `cloud_access_grant`, `google_orphan_owner`, `cloud_iam_user`,
  `vpn_application`, `ai_tool_application`, `software_account`,
  `identity_consumer_owner`, `github_org_membership`. **Adding one means
  touching four places, not one**: the constant here, `_SEVERITY_BY_RESOURCE`
  (fail-closed to the red queue otherwise), `_rescan`, AND the mirrored SELECT
  in `get_offboarding_preview`. The two counts must agree — they are both 25
  today, and a scanner type missing from the severity map surfaces every leaver
  in IT's blocker queue.
- **Blocker severity** (round 14, 2026-05-08, migration 136): every blocker carries `severity ∈ {blocker, advisory}`. `_SEVERITY_BY_RESOURCE` in [`offboarding/service.py`](../../server/app/domains/offboarding/service.py) maps the 17 known auto-handled types to `advisory`; unknown / future types default to `blocker` (fail-closed). The split is a UI hint only — `_recompute_status` and `LeaverBlockGuard.assert_can_flip_to_left` keep their original strict semantics (every pending row is force-resolved at T+0+24h and the flip-to-left invariant still requires zero pending). `OffboardingProcessResponse` exposes both `pending_action_required_count` (severity=blocker pending) and `pending_advisory_count` (severity=advisory pending); their sum equals `pending_blocker_count`. Migration 136 backfills the same 17-type allowlist that lives in `_SEVERITY_BY_RESOURCE`; the two MUST stay in sync — adding a resource type to the scanner without an entry surfaces the row in the red queue rather than silently auto-bucketing.
- **Transfer-accept IT visibility** (round 14, 2026-05-08): `respond_to_transfer` accept branch dispatches `transfer_accepted_it` to every active global_admin / it_admin so the inventory team gets a heads-up when hardware (or any other transferable asset) changes hands without an HRIS ticket attached. Fire-and-forget — failures are logged + swallowed (best-effort by design; an outage in Feishu cannot gate a real ownership transition). Body uses the bilingual `transfer_accepted_it` template registry entry; the action button (`url=`) is the only CTA — body deliberately has no inline portal URL to avoid the `localhost:3000` leak class.
- **No inline portal URL in notification bodies** (round 14, 2026-05-08): the test `test_no_inline_portal_url_in_notification_bodies` walks every `*.py` under `server/app` and asserts no `body_text` / `body=` string interpolates `{portal_url}/...` outside three allowed sinks (`url=` kwarg, local `url = f"..."` var fed into the kwarg, `"portal_url": "..."` context dict entry that the template registry renders into `{cta_line}`). The CTA button (rendered from `NotificationEnvelope.url`) is the only intended URL surface for ALL channels. Three sites fixed in this round and converted to bilingual template entries: `leaver_it_grace`, `provisioning.manual_notify`, `zoom.seat_evicted`.
- **Read-only preview** (added 2026-05-08): `GET /offboarding/people/{person_id}/preview` runs the full scan SELECTs without persisting any blocker / task / audit row. Frontend's "Mark as Leaver" modal calls this on open so the operator sees the resource list (with auto-picked successors) inside the confirm dialog. MCP parity: `get_offboarding_preview`. Note for maintainers: when adding a new resource scan to `_rescan`, mirror the SELECT in `get_offboarding_preview` — `test_offboarding_preview_persists_nothing` pins the contract.
- **Post-commit dispatch** (added 2026-05-08): `start_process` is fully transactional — Google `users.signOut` and leaver/manager notifications fire from `dispatch_post_commit_actions` AFTER `db.commit()`, never inside the transaction. Eliminates the "Google session killed but Helix has no record" inconsistency on rollback. HR router + matching MCP tool both call this. Idempotent (signOut is idempotent vendor-side; notifications dedupe via `OffboardingReminderLog` rows keyed by `(process_id, kind)`).
- **Cancel-restart blocker reset** (added 2026-05-08; extended 2026-05-29 with `RESO_IDENTITY_SYNC_MANAGED`): when a cancelled process is reopened, every non-terminal blocker (`PENDING` + `RESO_SSO_CASCADE` + `RESO_IDENTITY_SYNC_MANAGED`) is re-pended so the fresh scan re-evaluates the catalog row's current policy. RESO_TRANSFERRED / RESO_REVOKED_AUTO / RESO_REVOKED_MANUAL are kept (real vendor-side state changes already happened).
- **already_advanced gate** (tightened 2026-05-08): cancel refuses without `confirm_irreversible=True` only when real evidence exists — `sso_suspended_at` set OR ≥1 blocker in {TRANSFERRED, REVOKED_AUTO, REVOKED_MANUAL}. Earlier the gate fired on `ready_at is not None` alone, over-blocking sso-cascade-only flows.
- **Per-task try/except in suspend_sso** (added 2026-05-08, P0-2): the loop continues on per-task failure and records `executed_task_ids` + `failed_task_ids` in the audit row + the `SuspendSsoResultResponse` body. `sso_suspended_at` is set unconditionally so the operator gets one terminal "everything that could fire has fired" marker. Mirror of the `bulk_execute` fix from PR #207.
- **`_get_process(lock=True)`** (added 2026-05-08, P1-7): every state-mutating call site (cancel / suspend_sso / complete / reconcile / resolve_blocker_*) takes `SELECT ... FOR UPDATE` on the process row. Read-only callers leave the lock off so dashboards never time out behind a long admin operation.
- **Asset re-check on transfer accept** (added 2026-05-08, P1-8): `respond_to_transfer` re-checks the asset is still owned by the original initiator before applying. Multi-transfer race for the same asset now fails with `ASSET_NO_LONGER_HELD_BY_INITIATOR` instead of silently overwriting.
- **Transfer cancel/reject must unlink blocker** (fixed 2026-05-13): `cancel_transfer` and the rejection branch of `respond_to_transfer` now call `_unlink_blocker_from_transfer` to clear `blocker.transfer_request_id`. Without this, the next `create_transfer` for the same asset raised `TRANSFER_ALREADY_LINKED` with no escape. `reconcile_blockers` also clears stale CANCELLED/REJECTED links as belt-and-suspenders.
- **`asset_label` NOT NULL guard** (fixed 2026-05-13): synthetic `AssetTransferRequest` rows created by T+0 `_force_transfer_all` (`advance_service.py`) and manager-escalation (`escalation.py`) now fall back to `str(blocker.resource_id)` when `blocker.label` is None — prevents MySQL 1048 on stale blocker rows.
- **last_working_day bounds** (added 2026-05-08, P1-5): HTTP `mark-leaver` and the matching MCP tool reject dates outside `[today-7d, today+365d]` so a typo doesn't trigger an immediate `hr.overdue_leavers_check` cron transition to `left`.
- **Successor TOCTOU re-validation** (added 2026-05-08, P1-3): `provisioning.service._invoke_connector` re-validates the stamped successor (`successor_user_id` in `task.context_data`) is still `employment_status='active'` BEFORE calling the driver. Earlier the successor was validated only at scan/enqueue, but execute can fire days later.
- **leaver_date update on existing process** (added 2026-05-08, D-5): `start_process` called with a different `leaver_date` on a non-cancelled in-flight process now updates the date in place + writes `offboarding.leaver_date_updated` audit, instead of silently returning the existing process unchanged.
- SLA breach (migration 090): `first_blocked_at` set when process enters blocked AFTER leaver_date. `sla_breach_level` advances `none < warn (24h) < urgent (48h) < breach (72h)`.
  - **WARN (T+24h)** dispatches to the manager chain (`manager_person_id` and skip-level), max 2 recipients. Leaver self always excluded.
  - **URGENT (T+48h) / BREACH (T+72h)** dispatch to the leaver's HRBP + the `it_admin` pool (PR 2026-05-02). HRBP resolution by [`server/app/integrations/feishu_hrbp.py`](../../server/app/integrations/feishu_hrbp.py) (fix(leaver) 2026-06-16) is now **SF-first**: it prefers `identity_persons.hris_hrbp_person_id` (SF-authoritative, from `EmpJobRelationships` type 2448) when present + active, skipping Feishu entirely; otherwise it falls back to the Feishu-cached `hrbp_person_id` (migration 111) and finally a live Feishu walk over the per-user `custom_attrs` (any attr with `i18n_name` matching `HRBP` / `HR Business Partner`). `_extract_hrbp_user_id` accepts BOTH Feishu user-attr shapes — `USER` (`value.user.user_id`) and `GENERIC_USER` (`value.generic_user.id`, the PingCAP tenant's shape, verified live 2026-06-16). Before this fix it read only the USER shape and so returned `None` for every PingCAP user, silently routing every HRBP escalation to the `it_admin` fallback — the actual HRBP was never paged. On HRBP resolution failure (no Feishu account, no HRBP attr value, HRBP email doesn't match a Helix person) the dispatch still pages the `it_admin` pool AND writes a `leaver_sla.hrbp_resolve_failed` audit row so ops can see the gap. The previous "fan out to all `it_admin` + `hr` role members" behaviour (33 people in prod, including the leaver themselves when they held `hr`) was scrapped.
  - SLA card / email content lives in `_DEFAULT_TEMPLATES` under `leaver_blocked_sla_warn` / `_urgent` / `_breach` (PR 2026-05-02). Both Chinese and English variants ship; templates honor the `[r]…[/r]` red-bold marker for the elapsed-time emphasis.
  - All three tiers set `env.url=/admin/leaver-blocked/<person_id>` so the Feishu interactive card renders an action button.
  - **Per-recipient URL routing** (added 2026-05-19, broadened 2026-05-20): the dispatcher now picks `portal_url` per-recipient on every tier — `global_admin` recipients land on `/admin/offboarding` (full console); every non-admin recipient (HRBP from URGENT/BREACH, direct + skip-level manager from WARN) lands on `/my/team-leavers`, a read-only lookout backed by `GET /api/v1/offboarding/my-team-leavers` and rendered by [`web/src/pages/my/MyTeamLeaversPage.tsx`](../../web/src/pages/my/MyTeamLeaversPage.tsx). The lookout's scope is `hrbp_person_id == viewer OR manager_person_id == viewer OR manager_person_id IN (direct reports of viewer)` so every non-admin SLA recipient sees the leaver that triggered their notification. Fix for the unusable "Handle in Helix" button on the SLA card when the recipient wasn't a global_admin (P01461 incident; WARN tier was bit by the same default-True `is_admin` bug a day later, found in code review).
- **Process-start notifications** (added 2026-05-08, body updated 2026-05-20): `_DEFAULT_TEMPLATES` gained `offboarding.process_started_leaver` + `offboarding.process_started_manager` (both bilingual). Leaver gets a self-notification with three explicit options per asset — [b]归还[/b] (`/my/offboarding`) / [b]回购[/b] (`/my/requests` submitting the existing `offboarding_buyback` self-service flow) / [b]不处理[/b] (T+0 default → `held_by_leaver`, IT notified same-day). Manager gets a "watch for transfer requests" heads-up worded for the new default (resources stay with the leaver if untouched; manager is not auto-stacked with everything). Plus `offboarding.ready` (IT admin signal that suspend_sso is safe), `offboarding.completed` (manager final ack), and `offboarding.t0_default_held_by_leaver` (2026-05-20: same-day IT push when an asset auto-flipped to `held_by_leaver` via the default path — see §12.5 Force-transfer hardware row).
- Escalation (`escalation.py`, runs hourly): T-7 / T-3 / T-1 / T+0 reminders; on T+0 auto-opens transfer requests to manager for every MUST_TRANSFER blocker. Idempotent via `offboarding_reminder_logs`.
- **Reconcile cron** (added 2026-05-08, migration 132 seed): `offboarding.reconcile_processes` runs every 5 minutes against every BLOCKED / READY process. Closes the gap between async provisioning task completion and the owning blocker's status — operators no longer have to remember to click "Reconcile". Skips PROC_SCANNING (another tx in flight) and terminal states.
- **Orphaned-`left` recovery cron** (added 2026-06-03, migration 245 seed, `hr.recover_orphaned_left`, interval 900s, **enabled by default**): repairs the invariant *"a `left` person must never have a non-terminal process with pending blockers."* `hr.process_advance` (run_tick) only selects `employment_status='leaver_pending'`, so once a person reaches `left` while their process is still `BLOCKED` with pending blockers, NOTHING resolves them — `reconcile_processes` only flips blockers whose linked task/transfer already COMPLETED, never force-transfers owner-bound infra (cloud_server / DNS / cloud_iam_user / email_group / hardware). This cron runs `ProcessAdvanceService.recover_orphaned_left_processes` → `_advance_single(bypass_grace=True)` (grace is moot once the person is gone) to force-transfer to `default_handover_person_id` / manager fallback and complete the process. Idempotent; handler raises on unexpected per-person errors (`feedback_handler_status_must_raise`) while "no fallback owner" stays a `still_blocked` + its own CRITICAL incident. **(Extended 2026-06-03)** selection broadened from `BLOCKED + has-pending` to **every `left` person whose process is non-terminal — `SCANNING` / `BLOCKED` / `READY`**: the original predicate was structurally blind to the dominant prod wedge (16 processes stuck at `READY` in the "可关停 SSO" bucket, 11 already SSO-suspended for weeks) because `_advance_single` flipped the person to `left` then **parked the process at `READY`** — `complete_process` (READY→COMPLETED) had only a manual-button caller and no cron owned the terminal transition. Now `_advance_single` itself calls `complete_process` after `suspend_sso` + `flip_to_left` (guarded — a `PROCESS_NOT_READY` from a failed revoke task is swallowed and retried next tick), so both the autonomous T+0 path and this recovery sweep drive `left` processes all the way to `COMPLETED`. `SCANNING` orphans are re-scanned via `reconcile_blockers` (deferring advance to the next tick) rather than fed to `_advance_single`, which reads only already-enumerated blockers. **Root cause it backstops** (prod 2026-06-01 lifu.wu / chujie): the only writer of `employment_status='left'` is `LeaverBlockGuard.flip_to_left`, which `with_for_update()`-locked the process row (current read) but read pending blockers via a plain *snapshot* read — under MySQL/TiDB REPEATABLE READ a locking read does not advance the snapshot for later plain SELECTs, so a flip whose snapshot predated a concurrent `start_process` blocker-INSERT commit saw ZERO pending blockers and was allowed (success path writes no `leaver_block_audit`). Prevention fix: the pending-blocker reads in [`guard.assert_can_flip_to_left`](../../server/app/domains/leaver_blocked/guard.py) and the before_flush [`listener._scan_pending_blockers_sync`](../../server/app/domains/leaver_blocked/listener.py) are now `.with_for_update()` current reads. Tests: [`test_orphaned_left_recovery_2026_06_03.py`](../../server/tests/test_offboarding/test_orphaned_left_recovery_2026_06_03.py).
- **Cloud IAM user owner transfer** (round 20, 2026-05-12, [`offboarding/service.py:_rescan`](../../server/app/domains/offboarding/service.py) + [`advance_service.py:_handle_one_blocker`](../../server/app/hr_lifecycle/advance_service.py)): closes a long-standing gap where `cmdb_iam_users.owner_person_id` was scanned only by the T+1 post-leaver verifier (incident-creating, no resolution) and never by the offboarding scanner / force-transfer chain. Leavers therefore kept named-on-record responsibility for IAM credentials they no longer controlled. New `RES_CLOUD_IAM_USER = "cloud_iam_user"` resource type (advisory severity); scanner emits one blocker per `(owner_person_id == leaver, deleted_at IS NULL, revoked_at IS NULL)` row; T+0+24h force-transfer rewrites the FK to the manager-fallback person (same chain as scope owner). The cloud-side IAM credential keeps existing — only the Helix bookkeeping pointer moves; an admin still needs to rotate AKs / disable the user on the provider side, which the post-leaver T+1 verifier continues to catch as a compliance signal. Test pins in [`test_round20_iam_user_and_widget_split_2026_05_12.py`](../../server/tests/test_offboarding/test_round20_iam_user_and_widget_split_2026_05_12.py).
- **Force-transfer** (round 13, 2026-05-08, [`advance_service.py:_force_transfer_all`](../../server/app/hr_lifecycle/advance_service.py)) is the T+0+1 mechanism that rewrites resource ownership FKs when a leaver fails to self-handle. **Invariants**:
  - **Skips `software_seat` / `software_permission`** — those are people-bound; auto_revoke pipeline owns them. Transferring "ownership" of a Zoom seat in our DB without touching Zoom is a no-op that produces a misleading audit row.
  - **Per-blocker `begin_nested()` SAVEPOINT** isolates failures: blocker N crashing rolls back only that savepoint, leaving blockers 1..N-1 committed and N+1..M free to proceed.
  - **Synthetic `AssetTransferRequest` always carries `asset_label`** (column NOT NULL on `asset_transfer_requests`). Pre-round-13 the missing arg crashed every T+0 tick on first iteration and cascade-poisoned the session.
  - **Hardware recipient resolution** prefers the local office asset admin via `_resolve_hardware_recipient`: `asset.location → asset_office_addresses.city` (strict equality only — no fuzzy match) → `admin_person_id`. Falls back to the per-leaver default (manager → IT admin → fallback email) when no office matches or the admin is inactive/deleted. **Superseded for the owner path (2026-05-19, migrations 186-188)** by disposition-aware handling: `_apply_hardware_disposition` reads `offboarding_processes.hardware_disposition[asset_id]` and executes `keep` (mark `held_by_leaver`) / `recycle_to_office` (clear `assigned_to`, set `holder_kind='office'` + `holder_office_id`, status → `held_by_office`) / `transfer_to_person` (legacy force-transfer). **Default flipped 2026-05-20**: when no disposition entry exists, the resolver now returns `keep` (asset stays with leaver as `held_by_leaver`, `assigned_to` preserved) instead of the prior `recycle_to_office`. Rationale: the prior default produced a class of bogus "离职归还入库" rows for assets the leaver actually still physically held; routing assets the leaver kept into an office admin's queue was misattribution. The fallback `keep` carries an internal `_default_fallback=True` marker so `_dispatch_post_advance_notifications` can fire a same-day `offboarding.t0_default_held_by_leaver` push to active IT admins (`list_admin_recipients`) listing the leaver + auto-flipped assets — distinct from the weekly `asset.held_by_leaver_reminder` digest. Explicit admin `keep` does NOT fire this push (admin already chose). `_resolve_hardware_recipient` survives only as a fallback recipient computation for the borrower-only return path. Test pin: [`test_t0_default_keep_2026_05_20.py`](../../server/tests/test_offboarding/test_t0_default_keep_2026_05_20.py) (K1–K6).
  - **Direct-revoke branches** (SSO grant, VPN application, AI-tool application, cloud access grant) flip `blocker.resolution = RESO_REVOKED_AUTO` directly with no synthetic transfer record — these resources are people-bound and revoked at source.
  - **Per-recipient notification fan-out**: each chosen recipient (could be different per asset when office admins differ across the leaver's portfolio) gets exactly one push naming exactly the items routed to them. Original lenders of borrowed-and-returned items get a separate "your asset is back" push.
- **T+0 grace window** (round 13, refined round 58 2026-05-30): the day a process is first seen by the worker (`now - proc.created_at < 24h`), `_advance_single` defers force-transfer for **physical-handover blocker types only**. Round-58 split `pending` into three buckets so permission grants do not stay alive during the 24h hold:
  - **`IMMEDIATE_REVOKE_RESOURCE_TYPES`** (`sso_grant` / `vpn_application` / `ai_tool_application` / `cloud_access_grant`): force-resolved on the FIRST tick regardless of grace state. People-bound permission grants must end at T+0 — leaving them alive during the grace window is an active security exposure (P0788 / huangmeng@pingcap.com walk-through, 2026-05-30 — IT was manually clicking revoke on each leaver because the system was waiting for nothing). The grace-window IT push lists only the remaining physical-handover blockers and reports the immediate-revoked rows in a separate "already handled this tick" section.
  - **`SCAN_SCHEDULED_RESOURCE_TYPES`** (`software_seat` / `software_permission` / `software_account`): owned by the ProvisioningTask pipeline (`OffboardingService._apply_software_policy` / `_apply_account_policy` create tasks with `not_before = leaver_date + 1 (local TZ midnight)`). They do NOT participate in the advance-tick grace decision. A still-pending row of this type means the task is in-flight or retrying — round 58 added explicit `hold_reason="awaiting_provisioning"` so the cron + admin UI distinguish this from "stuck, no fallback configured" (was mis-bucketed as `held_missing_fallback` pre-58, Round-21's bug all over again).
  - **`grace_eligible`** (everything else: hardware / api_key / domain / dns / cloud_server / load_balancer / cdn_domain / cloud_iam_user / cloud_scope_owner / email_group / software_owner / software_it_contact / domain_registration / google_orphan_owner): genuine "needs IT to decide a human successor" set, still gets the 24h grace push. Day-2 onward runs `_force_transfer_all` normally.
  Idempotency via `REMINDER_IT_GRACE = "it_grace_window"` row in `offboarding_reminder_log` (its `detail` JSON now also carries `immediate_revoked_blocker_ids` + `immediate_revoked_count` so reconcilers can tie a T+0 grant revoke back to the grace tick). Built for HRIS-late-detection scenarios (P01121 was discovered T+7 days after termination_date — without grace the system would have skipped the human window entirely). Round-58 also (a) added `RES_SOFTWARE_ACCOUNT` to `_force_transfer_all`'s skip set (previously fell into the unknown-resource-type tail and tripped a spurious `LEAVER_ORPHAN` incident every 15 min until the Jira task completed), and (b) split the SLA `_raise_critical_incident` title into "未清 blocker" (human handover stalled) vs "软件 revoke pipeline 72h+ 未完成" (vendor stall) so the on-call admin reads the right next-step. Test pins: [`test_round58_immediate_revoke_2026_05_30.py`](../../server/tests/test_offboarding/test_round58_immediate_revoke_2026_05_30.py).
- **Lifecycle person flow API** (`/api/v1/lifecycle/person/{id}`) exposes per-resource detail for the admin UI: each reminder includes `detail` JSON (recipients / blocker_ids / per-tick context); each notification includes `body_text` / `url` / `last_error` / `next_attempt_at` / `max_attempts` so the frontend Modal can render the full story without round-tripping for detail.
- **Transfer recipient suggestion** (`GET /api/v1/transfers/suggest-recipient?asset_type=hardware&asset_ref_id=...`): returns the local office admin (when `asset.location` matches a configured office city) so the frontend transfer modal can pre-select for cross-geo offboarding. Returns `null` for non-hardware types or when no office matches.

### 12.6 Leaver Blocked

**Tables**: `leaver_block_audit` (append-only), `leaver_dual_sign_override`.

**Routes**: `/api/v1/leaver-blocked/*`.

**Invariants**:
- **Guard**: SQLAlchemy `before_flush` event listener registered at app startup ([`listener.py::register_listener`](../../server/app/domains/leaver_blocked/listener.py)). Intercepts every UPDATE on `identity_persons.employment_status`. If target=`left` and pending blockers exist, raises + logs to `leaver_block_audit`. **Catches background tasks, scripts, sync confirms** — not just admin API.
- **Override**: IT initiates with named blocker IDs to waive + ≥30-char reason. Secondary signer (department director chain) reviews within 7 days; auto-expires otherwise. On approval, IT calls execute → guard temporarily allows the listed blockers.
- Dashboard service (`dashboard_service.py`): summary by SLA level; per-process detail.
- SLA service: maintains breach levels; tied to compliance incidents at `breach`.
- `attempted_via` enum: `admin_api`, `hris_sync`, `process_advance`, `dual_sign_override`, `cli`, `listener_trap`. Tells you exactly how the rejected attempt happened.
- **Orphan-seat sweep dedup** (`guard.py:_revoke_orphan_seats`, hardened 2026-06-02): runs at the tail of every `flip_to_left()`. For each `status='active'` `SoftwareSeatAllocation`, queries the shared [`provisioning_coordinator.find_in_flight_seat_revoke_task`](../../server/app/core/coordinators/provisioning_coordinator.py) helper FIRST — if the offboarding pipeline already owns this seat (PENDING / IN_PROGRESS / COMPLETED revoke task exists), the sweep skips every side effect (no seat flip, no enqueue, no revoke audit) and writes a single `leaver_block_guard.orphan_sweep_skipped` audit row. For true orphans, the sweep resolves a successor via local manager → IT-admin pool (gated on candidate holding an active seat on the same software) when the bound driver requires one; if unresolvable, the seat still flips to `revoked` (billing-correctness invariant) but vendor revoke is held — a `TYPE_LEAVER_ORPHAN` compliance incident opens so an admin can pick a successor manually. Before this fix every leaver going through both `OffboardingService.start_process` and `flip_to_left` in the same HRIS sync produced TWO `ProvisioningTask` rows per seat (offboarding Path A with `offboarding_blocker_id` + auto-resolved successor; orphan-sweep Path B with bare context + no successor that always crashed Google `user_suspend`'s precheck and double-paged IT's failure DM). The shared dedup helper closes the gap at both endpoints — `OffboardingService._apply_software_policy` also calls it and emits `offboarding.blocker_auto_revoke_dedup_linked` when it finds a sibling. 9 dup pairs accumulated 2026-05-08 → 2026-06-01 before the fix; cleanup is migration 237 (forward-only cancel).

### 12.7 Relationships

**Tables**: `rel_relationships` (single edge table with `from_type/from_id/from_label`, `to_type/to_id/to_label`, `rel_type`, `attributes`, `source`, `weight`, `first_seen_at`, `last_seen_at`, `deleted_at`). Schema rebuilt in [`097_rebuild_rel_relationships_schema.py`](../../server/alembic/versions/097_rebuild_rel_relationships_schema.py).

**Routes**: `/api/v1/relationships/*`.

**Invariants**:
- One row = one directed edge. UNIQUE on `(from_type, from_id, to_type, to_id, rel_type)`.
- UPSERT bumps `last_seen_at`; rows missing in latest sync get `deleted_at` (soft delete) so the timeline of ownership stays queryable.
- Crons: `relationships.full_sync` (occasional), `relationships.incremental_sync` (5-min). Sync sources: cloud providers (CMDB), identity, asset, etc.
- Consumers: offboarding blocker scanner (find owners of resources), `/admin/relationship-graph` UI.
- **Concurrency invariants (added 2026-05-04)**: every sync entry point (`run_full_sync`, `run_incremental_sync`, admin manual trigger) acquires session-scoped advisory lock `GET_LOCK('helix.relationships.sync', 0)` and bails fast (`skipped: another sync run in progress`) if another run holds it — avoids the row-lock pile-up on `rel_relationships` that produced `Lock wait timeout exceeded` against the autoflush-emitted `UPDATE rel_relationships SET last_seen_at=...` when 5-min incrementals overlapped a slow full_sync. `_EdgeBatch.flush` writes via single `INSERT ... ON DUPLICATE KEY UPDATE` per edge (no SELECT-then-update, no ORM dirty-tracking → no autoflush trap). Per-source `await db.commit()` between sources caps the worst-case lock window at one source's edges instead of the whole graph.
- **Self-only RBAC clamp on `GET /graph` (added 2026-05-09 round 16)**: non-privileged callers (anyone without `can_view_relationship_graph`) can ONLY centre the BFS on themselves AND the depth is hard-clamped to 1 AND the edge filter is restricted to the "my own resources" set (`assigned_to`, `holds_seat`, `has_api_key`, `owns`, `grants_access_to`, `hosts`, `resolves_dns`). Org-chart edges (`manages` / `reports_to` / `member_of` / `leads`) and software-catalog ownership edges (`responsible_for` / `it_contact_for`) are deliberately filtered out to prevent the graph from being a back-door around the admin-only `/api/v1/users` directory. Privileged callers keep the full `depth=5` envelope. Asking for ONLY org-chart edge types returns 403 (not an empty graph) so the gate is loud.

### 12.8a Google Workspace (account lifecycle + orphan inventory)

Added in [`103_google_account_lifecycle.py`](../../server/alembic/versions/103_google_account_lifecycle.py) (PR 2026-05-02). The Google Workspace *driver* is at [`server/app/integrations/drivers/google_workspace.py`](../../server/app/integrations/drivers/google_workspace.py); this domain ([`server/app/domains/google_workspace/`](../../server/app/domains/google_workspace/)) is the business layer above it.

**Tables**: `google_orphan_accounts` (every Google user not matched to an `identity_persons` row — shared mailboxes, service accounts, contractor leftovers; carries `owner_user_id` so leaver scan can demand a re-assignment).

**Routes**: `/api/v1/google-workspace/orphans/*` (admin only).

**Auto-create on hire** ([`account_provisioner.py`](../../server/app/domains/google_workspace/account_provisioner.py)):
- Wired into `hr_sync_coordinator.provision_google_account` — runs on every HRIS confirm `create / reactivate / update`.
- Gated by `platform_settings.google.account_provisioning.auto_create_on_hire` (default False; flip after the integration is wired).
- **Email local part — HRIS-provided "邮件名" first** (2026-06-08): `_local_part_from_hris_email(work_email)` takes the local part of the HRIS-synced corporate email (`identity_persons.work_email`, HR pre-assigns it in SuccessFactors) verbatim (dots preserved, per-segment sanitise, capped at `local_part_max_length`). Audit `detail.local_part_source = "hris_email"`. Since 2026-06-11 the onboarding push may carry a **bare 邮箱名** (`san.zhang`, no `@`) — accepted as the local part directly (the final address depends on Helix's collision dedup, so HRIS no longer pretends to know the domain). **Falls back** to the `<given>.<family>@<domain>` 名.姓 derivation (`detail.local_part_source = "derived_name"`) only when work_email is missing or the synthetic `<userId>@successfactors.local` placeholder. Derivation (the **名.姓 convention, 2026-06-02 override of PR #677**) authoritative path uses `identity_persons.first_name` / `last_name` (SF `User.firstName` / `User.lastName` columns landed by [migration 243](../../server/alembic/versions/243_identity_persons_first_last_name.py)); each side pinyinised independently into ONE token so a compound family name like 欧阳 produces `nana.ouyang` (correct) rather than `na.na.yang.ou`. Legacy fallback path (rows where SF didn't populate both columns yet — backfills on next sync touch) re-pinyinises `display_name`, treats first token as family + concatenates the rest as given, emitting `wei.chen` (2-syllable), `sanfeng.zhang` (3-syllable 单字姓+双字名), and the approximate `yangnana.ou` for 4-syllable display_name-only cases. Domain picked from `platform_settings.google.account_provisioning.fte` vs `contractor`; contractor detected via `platform_settings.google.contractor_company_codes` (matches `identity_persons.company_code`). **Collision retry = zero-padded `01`-`09` suffix** (`wei.chen01` … `wei.chen09`, 9 attempts; changed 2026-06-08 from bare `2`…`10`). Initial password = 16-char `secrets`-strong, sent to `personal_email` only (work mailbox doesn't exist yet); Google forces rotation on first login.
- **Placeholder mailbox fail-closed (2026-08-17; P10020).** `unknown` / `null` / `N/A` / `none` / `nil` / `placeholder` / `dummy` are data-state markers, never valid company-email local parts. Both HRIS-email and name derivation reject them and stop account creation. The SF staged preview→confirm boundary also persists `firstName` / `lastName`, so a real structured name is not discarded when `displayName` is a shell value.
- **Onboarding Feishu creation alerts include retry-green blocks (2026-08-17).** `onboarding_feishu_provision` treats a Feishu `blocked` result (such as 41010 after the mobile cannot be rendered) as operationally actionable even though the identity-sync retry engine correctly keeps it green. The HRBP + HR Ops + IT card used for vendor failures covers both outcomes, is person-deduped for 20 hours, and also covers an absent enabled `feishu_contact` target or a background-task crash. The Feishu preset maps mobile country ISO-3 to ISO-2 before mobile formatting.
- On success: writes `IdentityExternalIdentity(provider='google')` + `SoftwareSeatAllocation` against the seeded "Google Workspace" plan, so the existing `_apply_software_policy` picks the seat up at offboarding scan time.
- **HRIS email write-back** ([`hris_email_writeback.py`](../../server/app/identity/hris_email_writeback.py), 2026-06-08; real SF OData upsert 2026-06-11): after a successful create + welcome-email, `notify_hris_email_assigned` writes the final chosen email back into SuccessFactors as a `PerEmail` row via `POST {base_url}/upsert?$format=json` (payload `[{__metadata:{uri:"PerEmail"}, emailType, personIdExternal, emailAddress, isPrimary}]`), reusing the existing SF connector credentials — no separate endpoint/secret. `personIdExternal` is resolved per call from `EmpEmployment` by `userId` (`SuccessFactorsClient.get_person_id_external`); unresolvable → fail closed (`status="failed"`), never guess the key. SF answers HTTP 200 even for record-level failures — the per-record envelope (`status`/`httpCode`/`message`) decides `sent` vs `failed`. Config = `platform_settings.hris.email_writeback` (`{enabled (default true), email_type (default "1046" = 办公邮箱 picklist on PingCAP tenants; HR's vendor sample showed 1584 — override here if the tenant disagrees), is_primary (default true)}`). Best-effort: a write-back failure never rolls back the account; partial-success (welcome-email failed → account suspended) skips the write-back. Outcome surfaces on `ProvisioningOutcome.hris_notify_status` and audit `detail.hris_notify`. Client write primitives: `SuccessFactorsClient._post_json` / `upsert_per_email` — the only write path against SF; everything else stays read-only.

**Auto-suspend on leaver**: rides the existing `auto_revoke` policy. Catalog "Google Workspace" ships with `offboarding_policy=auto_revoke` and a `software_connectors` row of `binding_config={"operation":"user_suspend"}`. `hr.process_advance` (every 15 min) finishes blockers → `suspend_sso()` dispatches the revoke task → driver suspends + transfers Drive/Calendar to manager-fallback successor. Force-logout at offboarding kickoff is unchanged ([`offboarding/service.py:_push_google_logout`](../../server/app/domains/offboarding/service.py)).

**Orphan inventory + owner blocker**:
- `google.seat_sync` (added in 103) walks the tenant daily: Phase A reconciles base "Google Workspace" seats; Phase B reconciles every Google-rooted plan whose connector binds `operation=group_member` (Jira-style); Phase C upserts `google_orphan_accounts`; **Phase D** (added 2026-05-07, migration 129) stamps `deleted_at = now` on any orphan whose `last_seen_at` falls behind `now - GRACE_DAYS` (default **3 days**, overridable via the `google.seat_sync.orphan_stale_days` platform setting; set to 0 to disable). Soft-delete only — owner + purpose + audit history stay on the row. List endpoints filter `deleted_at IS NULL` by default; admins surface the historical roster via `include_deleted=true` (frontend toggle "包含已删除账号"). Re-appearance of the same `primary_email` in the tenant list clears `deleted_at` automatically. **Leaver-remnant fast path** (2026-06-11): an orphan whose `primary_email` matches an `identity_persons` row with `employment_status='left'` is the leaver's own account, not a shared mailbox — Phase D tombstones it on the first successful tick that fails to observe it (1-hour epsilon, no 3-day wait); the list/detail payloads expose `former_employee: bool` and the FE renders a 离职残留 tag.
- `OffboardingService._rescan` adds a `RES_GOOGLE_ORPHAN_OWNER` blocker for every orphan owned by the leaver. Resolved via the same `POST /offboarding/blockers/{id}/handover` endpoint used for `sso_grant` / `domain_registration` — `OrphanAccountService.assign_owner` rewrites the FK and the blocker flips to `transferred`. The leaver scan filters `deleted_at IS NULL` — an orphan that's already gone from Google upstream can't be re-assigned, so blocking the leaver on it would just stall offboarding.

**`/admin/offboarding-config` "必须转让" tally** (added 2026-05-07): the page used to count only `asset_software_catalog` rows whose `offboarding_policy` is `must_transfer`, surfacing **0** to admins even though hundreds of cloud subscriptions / email groups / IAM users / physical assets were owned by employees and would need transfer at offboarding time. `TransferableSummaryService` (`server/app/domains/offboarding/transferable_summary_service.py`) now computes six disjoint categories — software (must_transfer policy), cloud scope (cmdb_cloud_scopes with owner+active+alive), domain (domain_registrations active + cmdb_domains with owner, summed), email group (distinct email_group_id from email_group_owners), asset item (asset_items in `ASSET_IN_USE_STATUSES` with assigned_to or borrowed_by), IAM user (cmdb_iam_users with owner_person_id, alive). `GET /api/v1/offboarding/transferable-summary` (admin-only) returns `{categories: [{key, count, admin_path}], total}`. Frontend renders the 6 cards as click-throughs to each category's admin page; the "必须转让" badge above the column flips from a software-only count to the full transferable universe so admins immediately see "this is the breadth of hand-off the offboarding pipeline drives". 8 tests pin the per-category contract so no future refactor silently drops a stream.

### 12.8 Email Group

**Round-5 review — 15 fixes, and the heaviest one is in no diff at all** (2026-09-03, **0 migration** / **0 new endpoint** / **0 new MCP tools → hint stays 582**). Round 5 changed method: one pass kept reading round 4's diff line by line, the other read NO diff and exercised the feature as a system — six workflows end to end, every write path's traceability, every displayed number against the guard that acts on it, the irreversible path's defence in depth, and "what would a new engineer misunderstand".

**The finding only the integration view could see.** Every estate row has a 删除 button: a role/owner check, a 4-character reason, then Google's hard delete. The cleanup workbench **on the same screen** builds four independent "deleting this breaks something else" gates — nested in another group, a real second address, a live Helix BigQuery dataset grant, referenced in Helix config — and the per-row button had none of them. **So every gate the workbench builds was bypassable one group at a time**, including on a group the workbench marks `blocking` because it is currently serving analysts a dataset. No single diff introduced this; it became a defect the moment a stricter sibling appeared, because with two doors of unequal strength the weak one IS the security boundary. `delete_mail_group` now runs the identical signal computation, refuses a BLOCKING verdict with a 409 naming the signals, and admits an explicit `acknowledge_blocking` only from `can_delete_any_mail_group` holders — with `risk` and the signal keys recorded in the audit `detail` **either way**, so a deliberate override is reconstructable instead of looking routine. Emptiness is deliberately NOT in this gate: the workbench refuses a group with members because its POOL is "empty static groups", while an owner disbanding their own ten-member group is legitimate. What generalises is "something else depends on this address".

Also fixed: `_probe_nesting`'s failure path keyed results by the LOWERCASED address while the success path used the raw value — and failures are now kept in the dict (so "failed" and "never attempted" can be told apart), so a mixed-case row missed its own lookup and fell into the "never attempted" branch: a genuine permissions/stale-address failure reported as a Helix-imposed cap, the exact inversion that branch was added to prevent; `add_members`' mirror repair booked an "已补齐" audit row for an address resolving to no person — which both `_mirror_has_owner` and `_mirror_owner_added` skip, and this endpoint explicitly supports external owners and treats departed employees the same way — so every retry asserted a repair that never happened while the group still read 无负责人 (its twin in `grant_owner` had it right from the start); the probe-budget note still travelled inside `nesting_probe_error`, whose only renderer is a banner headed "some nesting probes **failed**", so a page with zero failures announced failures — the per-row lie, restated one level up (split into `nesting_rows_not_probed` / `nesting_probe_budget` and its own info banner that says what to do: shrink the page); `nesting_not_probed`'s detail carried only a `budget` number while the frontend's evidence chain reads `reason`, i.e. a detail key nothing reads renders an **empty tooltip** — the same declared-and-unread defect fixed for `live_bindings` one round earlier; `afterChange` called both `fetchGroup` and `fetchMembers` while `setGroup`'s new object identity re-fires the member effect, so every write issued two concurrent `members.list` calls with no ordering guarantee — on the roster that decides the operator's next click (who is "the last owner", which rows are selectable), hence a `seqRef`; the batch-remove "all already gone" notice checked only that nothing was removed, so 1 absent + 2 failed showed the red failure toast AND a claim that all three were already gone; `dynamic_service.remove_alias` recorded `noop` but still returned only `get_detail`, so the UI showed a green "别名已删除" with the alias still listed one line below (the mirror deliberately keeps an address Google never removed); the delete dialog asserted "该组当前有 N 名直接成员" in the present tense from a column only the 6-hourly sync and `_refresh_member_count` ever write — five people admitted through join requests do not update it, so a freshly-populated group read "0 members" in a hard-delete dialog; 20 refusals × 20 addresses serialises to ~19.7KB, past `AuditService`'s 16KB cap, which replaces the WHOLE detail — batch reason included — with a `_truncated` marker, and "an operator tried to hard-delete `careers@` and the system refused because it had just gained members" is precisely what this desk exists to record (per-group `cleanup_refused` rows now, with the batch summary trimmed to an index); alias add/remove were the ONLY writes on the desk with no reason, while removing the last real alias clears the `has_real_alias` BLOCKING signal and pushes a row toward `risk: low` — i.e. makes a group sweepable (optional `reason`, recorded, with the remaining alias count).

**A latent async trap removed.** `db.get(EmailGroup, id, options=[selectinload(...)])` does NOT apply loader options to an instance already in the session's identity map, so `group.owners` lazy-loads — under the async session that is `MissingGreenlet`, raised on the **ownership check of an irreversible delete**. Production has never hit it because every request and every MCP call gets a fresh session and the map is empty, and that is exactly what makes it dangerous: invisible until something shares a session. Now an explicit `select(...).options(...).execution_options(populate_existing=True)`.

**Three places where the code was right and the words were not.** The delete payload's docstring promised "the Feishu notification to the group's remaining owners" — **there is no such notification**; this domain ships ten templates and none is a deletion, so a reader would trust that owners are told their group is gone when nobody is (the false promise is replaced by a record of the real gap). `sync_renamed` also fires on a description-only edit, and it means the DISPLAY NAME — an address change is a different group key on Google and surfaces as an unlinked `sync_removed` + `sync_discovered` pair. And group creation stamped `last_synced_at` on a row whose `aliases` and `direct_members_count` are both NULL, claiming a full reconcile that never ran — the last writer still doing what `_refresh_member_count`, `transfer_group_owner` and `leave_group_for_person` each explicitly refuse to do.

**The new cross-side i18n guard caught itself immediately**: `email_group.cleanup_refused` was registered before its three-locale copy existed and `test_every_registered_action_has_a_label_in_all_three_locales` went red on the spot — which is the whole reason it exists, since the bundles' parity guard cannot see "equally missing from all three".

**Round-4 review — 14 fixes, and three of the five overlapping findings were the same shape: the backend half went green and the red moved to the READING half** (2026-09-03, **0 migration** / **0 new endpoint** / **0 new MCP tools → hint stays 582**). Line-by-line, security and logic passes over round 3's own diff; five findings were hit independently by all three angles.

1. **The backend deliberately answered "unknown" and a frontend `?? 0` turned it back into the ownerless-group alarm value — in a green success toast.** Round 3 made `remaining_owner_count` `None` on `already_absent` because the count came from a read it KNOWS was stale. The drawer's only success path was `remaining: resp.data.remaining_owner_count ?? 0` against `'{{email}} 已不再是负责人,该组还剩 {{remaining}} 名负责人'`. Revoke one of three owners while someone edits the same group in the Google console → the operator is told the group now has ZERO owners: the exact state the `LAST_OWNER` guard exists to prevent, raised falsely, on a call where Helix changed nothing. `already_absent` had no label in the UI at all, and the batch-remove sibling was worse — a batch where every address was already gone has `removed: 0` and `failed: 0`, so `reportBatch` emitted **no toast whatsoever**. All three fixed; `OwnerOpResult.remaining_owner_count` narrowed from `number?` to `number | null` with "do not `?? 0`" in the type's own doc comment.
2. **A signal's evidence fields were declared and read nowhere — and that signal renders an empty tooltip in the ORDINARY case, not just under truncation.** `live_bindings` (round 2) and `present_effects` (round 3, added precisely so the number survives a capped dataset sample) were both unread: `SignalTag`'s evidence chain only consulted `datasets`. And `data_access_principal` fires on `live or has_present_effect`, so an approved-but-not-yet-compiled binding is `datasets: []` with `live_bindings: 1` → a red BLOCKING tag with a completely empty tooltip, on the surface whose stated premise is that every signal is trustworthy, one click from an irreversible delete. Counts now lead, dataset names follow.
3. **A registered audit action with no label in any of the three locales.** `email_group.owner_mirror_repaired` shipped in round 3 registered but unlabelled, so `actionLabel` fell back to the raw key and the operation log's filter dropdown and 动作 column rendered `email_group.owner_mirror_repaired` verbatim in every language. Second occurrence (nine actions did it a round earlier), and **the i18n parity guard structurally cannot catch it — all three bundles were equally missing, so parity holds.** The fix is a CROSS-SIDE test: `test_every_registered_action_has_a_label_in_all_three_locales` reads `web/src/i18n/*.ts` from the backend suite and pairs the registry against the bundles. It is the only place in the repo that sees both sides.
4. **One cell said "not actionable", the cell beside it offered the action.** Round 3 made Google's address-less whole-domain entry (`type: "CUSTOMER"`) visible and gave its action cell "Google console only" — but left its batch checkbox enabled (such an entry's role is `MEMBER`) and its `rowKey="email"` resolving to `null`. Ticking it, or 全选, posted `emails: [null]` and 422'd the WHOLE selection.
5. **The drawer's tombstone guard could never hold, because it read a group the drawer had not fetched yet.** `isDeleted` derives from the self-fetched group, which is `null` on the render where `open` flips true — so an `open`-keyed effect always ran with `isDeleted === false`, undoing the very fix its own comment describes: opening 管理 on a soft-deleted row fired `list_members`, which 404s for any non-active group, so the operator got a red toast every time, before the tombstone notice even rendered.

Also fixed: the new `GET /manage/groups/{group_id}`'s authz lived in the ROUTER BODY — the shape this repo has already paid for (skipped on an `@idempotent` replay, invisible to any second caller) — on a response carrying the full owner roster (`display_name` / `work_email` / `employee_no` / `employment_status`) for an arbitrary id including tombstones; the gate moved into `EmailGroupManageService.get_group` like every sibling read, with tests for a 403 on a non-HR caller and for `{group_id}` not shadowing `export.xlsx` (typed `UUID`, so shadowing yields a 422 that reads like a broken export rather than a routing mistake); rows past the new probe budget took the "probe **failed** for this address" branch, sending an operator to investigate Google permissions for a cap Helix imposed on itself (new `nesting_not_probed` signal — equally indeterminate, equally never `low`, but honest, and the page-level note says how to act: shrink `page_size`); the cleanup-candidates export audited `nesting_probed: true` for a sheet that can contain 300 unprobed rows (now carries `nesting_probe_note` + `rows_exported`); `dynamic_service`'s alias removal still logged `status="success"` when Google removed nothing while the static twin has logged `noop` + `already_absent_on_google` since it shipped — the same event, the same log, opposite conclusions, so an auditor reading the dynamic row concludes an alias that never existed was deleted; `MAX_CANDIDATE_PAGE`'s comment still cited the retracted "~25 rounds" arithmetic; two MCP owner tools' comments claimed `email` reaches a LIKE (it reaches `_person_by_email`, a bound equality — only `alias_email` reaches a LIKE; the bound was right, the rationale was not); `schemas.py`'s `sole_owner` contract doc and `MyEmailGroupsTab`'s comment both still said the UI disables 退出, which round 3 changed to warn-don't-disable — the latter contradicted by code twenty lines below it.

**Worth recording.** While adding the `nesting_not_probed` copy, an unescaped `request's` in the English bundle made `en-US.ts` a syntax error — and `pnpm lint` and `pnpm typecheck` were both green at the time (neither had been re-run since the key landed). The first thing to report it was **vitest's esbuild transform**. Re-run typecheck after touching an i18n bundle; do not trust the previous green.

**Round-3 review — 19 fixes, and all three angles independently led with the same one** (2026-09-03, **0 migration** / **+1 read endpoint** / **0 new MCP tools → hint stays 582**). Line-by-line, security and logic passes over round 2's own diff. That three independent reviewers ranked the same finding first is itself the signal: it was not an edge case.

1. **`await db.flush()` immediately followed by `raise` writes NOTHING — and the error message said the write had happened.** `get_db` is `try: yield; commit() / except: rollback(); raise`, so round 2's "clear the phantom owner row before the 409" fix was rolled back by the very exception it preceded: the phantom `email_group_owners` row survived as an offboarding blocker no UI action could clear (every retry took the same branch) behind a 409 asserting it had been cleaned up. **A flush is not a commit: on any path that raises, every write in the request is discarded.** The reason it looked green is the thing to carry forward — `conftest` overrides `get_db` with a bare `yield db_session`, no commit and no rollback, so an API test can observe session state production throws away. **No functional test in this repo can catch this class**, so the guard is a static one: `tests/test_email_group/test_no_flush_then_raise.py` scans the domain for a `db.flush()` whose next non-comment statement is a `raise`. The branch's semantics were fixed too: `already_absent` (the roster moved between the read and the write) is now **200 with a distinct `already_absent` outcome**, not a 409 — "this person is no longer an owner" IS what the operator asked for, just not via the route they picked — and `remaining_owner_count` returns `None`, because that number came from a read we KNOW was stale.
2. **Round 2's "last resolved row" fallback for the drawer re-installed the bug round 1 removed — the one that renames a group back on Google.** Round 1 fixed "the drawer is pinned to a pre-write snapshot, so reopening 编辑 re-submits the old name and `update_display` renames the group back". Round 2, fixing "the row leaves the filter and the drawer blanks", added a `lastResolved` fallback — which IS that pinned snapshot. The deep fix removes the parent list as a data source entirely: new `GET /email-groups/manage/groups/{group_id}`, the drawer fetches by id, and the parent's filter becomes irrelevant **by construction**. Three shapes of one bug, all three now pinned by tests (the parent list returns an empty array and the drawer still shows post-write server state; the mutation that reverts the drawer to reading the parent's rows turns two tests red).
3. **The live sole-owner check made one Google blip kill self-service leave platform-wide.** `list_group_owners` answers `None` for EVERY non-404 failure (403, 5xx, transport, paginator overrun) and needs a scope the leave itself does not. Once round 2 ran that check on every leave, refusing on `None` returned "请联系 IT 管理员" to ordinary members of groups where no ownership question exists. An indeterminate read now falls back to the mirror, and only in the safe direction: mirror says they may hold ownership alone (or there is no mirror at all) → refuse; mirror says they are not an owner → allow, which is precisely the state this check was never written for, so an outage cannot be worse than before it existed.
4. **The notification performed real HTTP inside the `SELECT … FOR UPDATE` window, and the lock comment written in the same commit claimed the window held "ONE Directory API call".** `dispatch` = enqueue + a synchronous first delivery attempt, so a slow Feishu send (30s timeout) holds the `email_groups` row lock for its duration — and `email_group_owners` has an FK to that row, so the 6-hourly owner-join rewrite blocks with it. Now `enqueue` (durable outbox row only; `notification.outbox_retry` delivers), which is the established pattern here (~10 call sites under `domains/offboarding` and `domains/asset`). The lock comment now states the truth: two to three Directory round trips.
5. **"20-way concurrency" was never deliverable, and a 500-row page can starve Day-1 provisioning.** `list_groups_for_user` runs each call through `asyncio.to_thread` — the loop's DEFAULT executor, `min(32, cpu+4)` = 6 on a 2-vCPU box, and nothing in `app/` calls `set_default_executor`; a semaphore wider than the pool adds queueing, not parallelism. And the page size must not BE the budget: `_build_directory_service` rebuilds credentials per call, so each probed row costs a token exchange plus the API call, against a Directory quota (2400 q/min/user) shared with `google.group_sync`, `google.seat_sync` and onboarding. Concurrency is now 6 (the real pool width) with a hard `_MAX_PROBED_ROWS = 200` budget; rows past it get `nesting_unknown`, i.e. it degrades **in the safe direction through the indeterminate machinery that already exists**.

Also fixed: the alias-collision guard used an unanchored substring LIKE plus an unordered `LIMIT 20`, so looking up `it@pingcap.com` matched every array holding `audit@` / `submit@` / `recruit@` — round 1's config-reference bug, inverted — and with enough such rows the true holder fell outside the window, the parsed confirm found nothing, and the operator got back the bare Google error the guard exists to replace (now matches a whole JSON array element, `%"<alias>"%`, ordered); round 2's `grant_owner` mirror repair sat on a path **the UI cannot reach** (the drawer renders 设为负责人 only for a non-owner row, so the affordance an operator actually uses is 添加负责人 → `add_members(role=OWNER)`, which short-circuited on `already_member`) — both paths repair now, and the INSERT is audited (`email_group.owner_mirror_repaired`) because an `email_group_owners` row feeds the ownerless KPI, the `owners=none` filter and the offboarding blocker set, but only when it actually inserted, so a re-click does not become log noise; `still_member` was filled with `是`/`否`, putting a Chinese character mid-sentence into the English and Japanese bodies of an employee-facing push (split into two template keys with no such variable); the compiled-effects query was `.limit(500)` with no ORDER BY, so later group ids on a large page silently lost their dataset evidence and, in the binding-revoked/effect-present drift case, the **blocking verdict itself** (verdict now from an untruncatable aggregate, evidence from a deterministically-ordered bounded sample); `transfer_owner` notified only the incoming owner while HR/admins can transfer on someone else's behalf (new `mail_group_owner_transferred_away` ×3); `_notify_owner_revoked`'s docstring justified itself by claiming member removals are deliberately silent — false (`remove_members` dispatches `mail_group_member_removed`), and the same wrong rationale had reached CHANGELOG and PLATFORM, where it would have given the next reader a wrong baseline for employee push volume (all three corrected); `list_members` still filtered by address, so the estate row read "1 member" while the drawer opened from it rendered 「该组没有直接成员」 — the desk contradicting itself on the very case round 2's raw-count fix was written for (address-less entries are kept, rendered as 全域成员 under their Google id, and explicitly marked console-only since every write here is keyed by address); the alias tri-state was still asserted away as "no aliases" **on the screen where aliases are managed**, and `alias_never_synced` was read nowhere in `web/src` (both the drawer and the dynamic detail read it now; `dynamic_service` and the all-employee directory also moved to `real_aliases` + NULL preservation, which incidentally stops showing Google's `.test-google-a.com` mirrors to every employee as real second addresses); `dynamic_service`'s own alias add/remove collapsed the same NULL (round 1 fixed only `admin_service`'s copy); the employee-side sole-owner flag is mirror-derived yet **permanently disabled** the leave button while the API decides live, so a disagreement left users with a dead control and advice they could not act on (warn, don't disable — desktop and mobile); the all-employee directory's `search` / `suffix` LIKE were unescaped, the other half of the paired contract round 2 fixed on the admin side (same `_esc` now, so the halves cannot drift again); four MCP parameters (`alias_email`, `email`) were unbounded.

**A process lesson worth recording.** The first mutation-verification run hit the 2-minute tool timeout, the outer process was SIGKILLed, its `finally` never ran, and `service.py` was left holding an `if True:`. The next run then reported "the test does not catch it" when the truth was **the source was corrupt**. The harness now writes a sidecar backup to disk before mutating and verifies the restore byte-for-byte by sha256.

**Round-2 review — 30 more fixes, this time on the FIXES** (2026-09-03, **0 migration** / **0 new MCP tools → hint stays 582**). Line-by-line + security + logic passes over round 1's own diff. The five worth carrying forward:

1. **I wrote a confident module docstring saying Helix cannot see cloud IAM — and it was false for the half with the worst blast radius.** `data_access_bindings` and `data_access_compiled_effects` each carry an **indexed** `principal_email_group_id` FK to `email_groups.id` with `provider_member = group:<addr>`: Helix's OWN BigQuery data-access grants are principal'd on mail groups. Hard-deleting one revokes live analyst dataset access and orphans a binding whose reconcile can never converge — and it is answerable in **two indexed `IN` queries**, while the workbench was telling the operator the check was impossible. Now a `data_access_principal` **BLOCKING** signal carrying the dataset list, with `known_blind_spots` corrected from `cloud_iam_principal` to `cloud_iam_outside_helix` (what is really invisible is a `group:` binding somebody added by hand in the GCP console). **"The platform cannot see X" is a factual claim like any other — verify it before writing it down, especially when it is going on screen as a disclaimer.**
2. **`members.list` returns `type: "CUSTOMER"` members with an `id` and no `email`, and I used "is the email-keyed roster empty" as the last gate before an irreversible hard delete.** That row is how "everyone at pingcap.com is in this group" is expressed. Filter it out by address ⇒ `live_members == []` ⇒ the group is hard-deleted with an audit row asserting it had no members. Worse, the mirrored `direct_members_count` came from the **same** email-keyed reduction (`_refresh_member_count`), so the pool predicate and its "live re-verify" agreed on the same wrong answer — two gates vouching for each other. Anything that COUNTS now counts raw entries (`_live_member_entries`); `_live_roles` stays email-keyed because it looks up roles BY address, which is correct.
3. **`already_absent` means opposite things on `revoke_owner`'s two branches, and round 1 handled it once for both.** Demoting: the patch found nobody, so reporting "demoted to member" tells the operator they are still in a group they are not in — must 409 and force a re-read. Removing outright: "not in the group" IS the requested end state and the driver's documented idempotency contract — a 409 there fails a satisfied request. The raise also sat **before** the mirror cleanup, so after the 409 an `email_group_owners` phantom row survived that **no UI action could clear** (every revoke 409s the same way) while still counting as an offboarding blocker.
4. **The sole-owner self-leave guard read the mirror, and the mirror is wrong in both directions — one of which strands a group.** `email_group_owners` only holds owners resolvable to an `identity_persons` row by `work_email`, refreshed on a 6-hour tick. Mirror shows 2 / Google has 1 (the co-owner was removed in the console) ⇒ the real sole owner walks out and the group lands at **zero owners**, exactly the state the guard exists to prevent, later surfacing on the desk as an unowned group nobody can explain. The other way: an unresolvable external co-owner turned a perfectly safe leave into a hard 409 with advice the user cannot act on. `revoke_owner` had already moved to the live roster — two surfaces answering the same question from two sources is how they start disagreeing. All branches now read Google.
5. **The `aliases` tri-state was flattened by `real_aliases()`, so the careful NULL protection bought nothing.** NULL = never read, `[]` = read and genuinely none. `has_real_alias` tested `real_aliases(row.aliases)` for truthiness and both are `[]` ⇒ a never-alias-synced group was indistinguishable from a confirmed-clean one and could reach `risk: low`, the bucket whose entire meaning is "we checked". NULL now emits an `alias_unknown` **indeterminate** signal — the same treatment the nesting probe gets when it cannot run.

Also fixed: `useIntentKey` returned a fresh object literal per render while a caller had it in a `useEffect` dep array, so the create-group modal **cleared its own form on every keystroke** (and my comment on that hook asserted it was stable); the pinned idempotency key turned "retry just the failures" into a 422 dead end (now reset as soon as the server answers at all, kept only for a transport failure — the one case a replay guard is for); the drawer derived its group from the **filtered** `rows` while every write in it can push that row out of the active filter ⇒ a **blank drawer with silently no-op buttons** right after a success (now a last-resolved fallback plus a banner saying why the table no longer matches); `deleted_record_failed` — deleted on Google, not recorded locally, the most consequential row a batch can produce — was missing from the type, the toast and all three locales, reaching the operator as a raw key in a neutral tag; `add_members(role=OWNER)` audited `member_added` while the UI's 添加负责人 posts exactly there ⇒ the surface built to answer "who made this person an owner" was incomplete (now `owner_added`, and the registry's grep gate was widened to see literals inside a conditional); `grant_owner` returned early on "already an owner on Google" ⇒ the single manual **mirror-repair** path was a pure no-op; revoking an owner notified nobody (new `mail_group_owner_revoked_*` templates ×3 — it announces a change in RESPONSIBILITY: until they are told, the ex-owner still believes they are handling the group's requests); `group_exists` requested write scopes for a pure GET ⇒ **permanently indeterminate** on a readonly-delegated tenant, so soft-delete silently stops forever while the cron reports ok (now `_GROUP_READ_SCOPES`); the config-reference regex anchored only the left side ⇒ this tenant's `<addr>.test-google-a.com` mirrors let `ops@pingcap.com` fabricate a BLOCKING reference in the other direction; new `sync_alias_changed` event (before it, "when did this group gain the alias that now blocks its deletion?" answered only with silence); the nesting probe compared a non-lowercased address against lowercased emails, so a mixed-case row made itself its own parent; `transfer_owner` / `leave` stamped `last_synced_at` while reconciling nothing, and the workbench renders that column as **staleness evidence** beside a member count and alias list those writes never touched; five MCP write tools left `reason` unbounded (21× amplified on cleanup) and two read tools left `search` / `actions` unbounded, plus the `category` enum guard was missing the `other` the HTTP twin accepts; `kind='unknown'` was counted, banner'd and refused by every write path but **unfilterable** ⇒ the banner was a dead end (filter option + "show only these" action); KPI chips left `search` / `suffix` in place ⇒ clicking a tile that reads 133 could show 4 rows; the batch-summary audit + commit ran outside the try that protects an operation whose addresses are **already irreversibly gone** (now wrapped, and "every row correctly refused" is no longer logged as `failure`); `management` was auto-promoted by `RoleRoute` onto `/admin/email-groups/*` while `can_manage_email_groups` has no management tier ⇒ menu entry renders, first paint is a full 403 (route `excludeManagement` + menu into `FORENSIC_ONLY_KEYS`).

**HR's per-row 历史 no longer goes through `/audit/resources/history`.** That endpoint filters on resource_type + id with **no action filter**, and the `data_access` domain writes `data_access.binding.*` rows against `resource_type='email_group'` — so registering the type for HR handed HR another domain's BigQuery topology from behind a mail-group button. It now navigates to this desk's own operation-log tab with `?group=<id>`: constrained to `action LIKE 'email_group.%'` in SQL, so the reach is **gone** rather than merely unrendered, and the timeline arrives with this tab's labels, filters, detail expansion and export. `app/audit/router.py` now differs from main **only by a comment**.

**Post-merge adversarial review — 28 fixes across the three PRs** (2026-09-03, **0 migration** / **+2 MCP tools → hint 582**). Ten independent review angles over the combined diff. The four that would have hurt most, and what they teach:

1. **`add_members(role="MEMBER")` on an existing OWNER demoted them with no owner-count check** — a third door onto ownership, and the only one without the `LAST_OWNER` guard. Reachable by pasting a re-sync address list that happens to include the sole owner: no reason required, no warning, and recorded as `member_added` so an auditor filtering `owner_removed` never sees the group lose its owner. **Owners are now refused on that path.** The lesson is the test: `test_adding_an_owner_as_member_does_not_silently_demote` *asserted the demotion* — the bug had been encoded as the expected behaviour, which is why the suite was green.
2. **`create_mail_group` never set `is_dynamic`**, so a group created at the new admin desk had NULL — and the desk's own `GROUP_KIND_UNKNOWN` gate (added by PR-2 to refuse undetermined groups) then refused every follow-up operation with 409. Create-then-populate, the headline flow, was dead until the next 6-hourly sync. A new gate turned a previously harmless NULL into a blocker; the gate was right, the writer had to catch up.
3. **Five `@idempotent` writes were scoped `("group_id",)` with no caller identity.** `@idempotent` returns the cached envelope *before* the handler body runs, and the RBAC gate for those routes lives in the body — so anyone holding an HR admin's Idempotency-Key got 200 instead of 403, with the group address and per-address results. Now `("group_id", "current_user.id")`, matching `cloud_grant.approve` and every other privileged write here. `web/src/utils/idempotency.ts` had documented this exact hazard; the new code reimplemented the key minter and inherited none of the reasoning.
4. **`_log_group_sync_event`'s SAVEPOINT was discarding the reconcile it was describing.** `Session.begin_nested()` does NOT flush first and `AuditService.log` ends in a session-wide flush, so the group's pending `UPDATE email_groups` was emitted *inside* the savepoint — a rolled-back audit write reverted the rename / revival / tombstone while the summary still counted it and the tick reported `status='ok'`. Fixed with an explicit `await db.flush()` before the savepoint, plus appending to `errors` so a lost log event cannot leave the run green. **`best_effort` protects the SESSION, not the caller's un-flushed work** — flush before you enter it.

Also fixed: `resource_type='email_group'` is written by `data_access` and `soc2_email_group` too, so the log needed `action LIKE 'email_group.%'` as well (HR was reading BigQuery topology out of a verbatim `detail` export; HR reverted out of `_EMAIL_GROUP_HISTORY_ROLES`); `set_group_member_role`'s 404→`ok=True` was treated as success at three call sites (phantom `email_group_owners` row ⇒ phantom offboarding blocker); `remove_alias` validated nothing and collapsed the `aliases` NULL tri-state, which suppressed the cleanup workbench's `has_real_alias` BLOCKING signal; `update_display`'s unchanged-guard was unreachable for a single-field payload (`None == ""` is False) and unbounded on the MCP path (Google renamed, local flush 1406, rollback ⇒ silent drift); `LAST_OWNER` was unserialized check-then-act (now `SELECT … FOR UPDATE`); the cleanup nesting probe read Google's 404-swallowed `[]` as "not nested" (new driver flag `raise_on_missing`), matched config references as unanchored substrings (`bot@` inheriting `tidbcloud-bot@`'s), indexed the whole estate per page load, and probed serially (now bounded `asyncio.gather`); the sync tombstoned groups created between the listing and the DB snapshot and wrote a `sync_removed` row asserting they were gone (now confirmed per candidate with a new `driver.group_exists`); a mass revival burst the log (new `sync_restored_bulk`); a DB failure mid-cleanup lost the whole batch manifest for an operation with no undelete (now isolated per group with a distinct `deleted_record_failed` outcome). Frontend: the drawer was pinned to a pre-refresh row, so reopening 编辑 after a rename re-submitted the OLD name and **renamed the group back on Google**; the cleanup tab's page-scoped counts sat under a pool-wide total; `?? 0` rendered a never-synced member count as "0 members" in the irreversible delete dialog; nine audit actions had no label in any locale. Reuse cleanups: `ExportButton`, `useDebouncedValue`, `_esc`, `listSep`.

**Empty-static-group cleanup workbench** (added 2026-09-03, **0 migration** / **+2 MCP tools → hint 580** / 0 cron / 0 new RBAC gate). [cleanup_service.py](../../server/app/domains/email_group/cleanup_service.py); endpoints on the read router because the list and the delete share the pool predicate and splitting them is how that definition drifts: `GET /manage/cleanup-candidates`, `GET /manage/cleanup-candidates/export.xlsx`, `POST /manage/cleanup`.

**Pool**: `status='active' AND is_dynamic = 0 AND direct_members_count = 0` — **133 groups on prod** out of 720 static (measured 2026-09-03). Spelled with `is_(False)` and `== 0`, never `isnot(True)` / `COALESCE(...,0)`: a NULL kind might be rule-driven and a NULL count means never synced, and either would put a group nobody has verified into a delete list.

**Why it is not a one-click sweep.** "Zero members" is NOT "abandoned". Prod's 133 include `careers@`, `jobs@`, `staffing@`, `news@`, six `gcp-*` groups, `security-log@`, `tidbcloud-bot@`, `jsmbot@`, `no-reply@tidb.io` — externally published contacts, robot/alert destinations, delivery hops and IAM principals. And deletion is a Google **hard** delete: the archive goes, later mail bounces, no undelete. So the workbench informs a per-row human decision rather than automating it.

**Signals**, each with an explicit `level`; row `risk` is derived (`blocking` > `review` > `low`):
| signal | level | source |
|---|---|---|
| `nested_member_of` | blocking | `groups.list(userKey=<group addr>)` — which OTHER groups list it as a member |
| `has_real_alias` | blocking | DB, with Google's `.test-google-a.com` mirrors stripped |
| `data_access_principal` | blocking | two indexed `IN` queries over `data_access_bindings` + `data_access_compiled_effects` — Helix's own BigQuery grants principal'd on this group (added round 2; the docstring used to claim this was unknowable) |
| `alias_unknown` | **indeterminate** | `aliases IS NULL` — the alias list has never been read, which is not the same fact as `[]` (added round 2) |
| `referenced_in_helix_config` | blocking | one scan of `integrations.config_public` + `platform_settings.value` |
| `nesting_unknown` | **indeterminate** | probe skipped or failed |
| `possible_cloud_principal` | warning | name shape only — explicitly a heuristic |
| `marked_deprecated` / `looks_like_test` | info | name/description markers |

**`indeterminate` is the load-bearing idea.** A probe that could not run is a different answer from a probe that came back clean, so such a row stays in `review` and never in `low`. Collapsing the two would let an operator sweep the "low risk" bucket and delete groups whose nesting was never verified. Same rule per address: a single failed probe records *absence of knowledge*, not `[]`.

**A blind spot declared on every response — and it has to be the RIGHT one.** `known_blind_spots: ["cloud_iam_outside_helix", "mail_traffic"]` (corrected 2026-09-03 in round 2; it shipped as `cloud_iam_principal`, which was a confident lie). What Helix CAN see it now queries: its own data-access domain grants BigQuery datasets to `email_groups` rows through an indexed `principal_email_group_id` FK, so a group that is a live BigQuery principal raises `data_access_principal` (**blocking**, with the dataset list). What stays invisible is a `group:` binding a human added by hand in the GCP console — GCP exposes bindings inline as `group:<addr>` and this platform's `list_scope_grants` filters to `user:` only, so no table carries them (verified on prod: **0** rows in `cmdb_cloud_access_grants` name a group address). Emitting a "no cloud reference" verdict would therefore still be a lie for `gcp-organization-admins@`; the name heuristic is labelled as one and the UI says that check is manual. Google has no per-group mail-traffic API either, so "is it still receiving?" is likewise unanswerable. Both facts are in the response, the standing banner, AND the confirm dialog — and the exact set is pinned by `test_response_always_states_its_blind_spots`, so the prose copy in the UI and the array cannot drift apart unnoticed.

**Execute guards**: ≤20 ids per call (a 133-group sweep is seven deliberate batches); reason ≥10 chars (higher than the single delete's 4 — a batch has to read like a sentence somebody wrote); ids de-duped preserving order; **the pool predicate is re-checked per group at execute time**, not trusted from the request; and **live Google membership is re-read immediately before each delete** — the candidate list can be a whole sync interval old, so this is the only guard between "stale list" and "deleted a live group", and an unreadable membership read *refuses* rather than proceeding. A Google delete failure leaves the local row `active` (a tombstone for a group that still exists is the harder reconciliation). One refusal never aborts the batch, and the commit is per group so a mid-batch crash cannot leave Google/Helix drifted.

**Audit**: one `email_group.cleanup_deleted` per group carrying a snapshot (address / name / description / real aliases / created_at) + `reason` + `batch_id` + **`sequence`** — `audit_logs.created_at` is a whole-second DATETIME, so a 20-row batch lands in one or two seconds and cannot be ordered by time; the sequence is what makes the sweep replayable in the order the operator saw. Plus one `email_group.cleanup_batch` summary whose `resource_id` IS the batch id. Because the local row is soft-deleted, "what happened to `careers@`?" stays answerable from the 操作日志 by the address the group used to have — pinned by a test.

**UI** ([EmailGroupCleanupTab.tsx](../../web/src/pages/emailGroup/EmailGroupCleanupTab.tsx)) is built to slow the operator down: risk-coloured rows with the risky ones in front, `blocking` rows **not selectable** (so "select all" cannot sweep them) until an explicit override checkbox is ticked — and un-ticking it drops any already-selected blocking rows, or the guard would be decorative. The confirm dialog lists **every** address (a count alone lets somebody confirm without reading), re-states the irreversibility and the blind spots, calls out the blocking rows by name, requires the reason, and afterwards shows a per-row outcome table (`deleted` / `already_absent` / `refused` / `failed` / `not_found`).

**MCP twins**: `list_email_group_cleanup_candidates` (read) and `cleanup_email_groups` (`mutates=True`). Their descriptions spend most of their length on what a model must NOT conclude: that zero members means abandoned; that `indeterminate` means clean; that a partial result may be summarised as success; and they instruct the model to present candidates with their signals and let a human choose the ids rather than assembling a list itself.

**Static-group administration — members, owners, display, aliases, create** (added 2026-09-03, **0 migration** / **+5 MCP tools → hint 574** / 0 cron / 0 new RBAC gate). The write half of the 邮件组管理 desk ([admin_service.py](../../server/app/domains/email_group/admin_service.py) + [admin_router.py](../../server/app/domains/email_group/admin_router.py), on the same `/api/v1/email-groups/manage` prefix as the read surface).

**Why**: static groups had exactly ONE admin write path — delete. Adding a member, granting or revoking ownership, fixing a name, managing aliases were either owner-only through the all-employee directory or not possible in Helix at all, so HR ran them in the Google admin console — which means **none of it appeared in Helix's audit trail**. The operation log could show that a group was deleted but not that its whole membership had been rebuilt the week before.

Endpoints: `POST /manage/groups` (create), `POST /manage/{id}/members` + `…/members/remove` (batch, per-address results), `POST /manage/{id}/owners` + `…/owners/remove`, `PUT /manage/{id}/display`, `POST`/`DELETE /manage/{id}/aliases`. Every mutating one is `@idempotent`-keyed (a Feishu redelivery or double-click must not add a member twice or fire a second notification) on top of the driver's own idempotency (409 → `already_member`, 404 → `already_absent`).

**Four rules the service holds, each a consequence of Google being the authority for membership** (all pinned by [`test_admin_member_owner_api.py`](../../server/tests/test_email_group/test_admin_member_owner_api.py)):

1. **Google first, then the local mirror.** Every method calls the driver and only then touches `email_groups` / `email_group_owners`, so Helix never claims a change Google refused. The owner mirror IS updated immediately rather than waiting for the 6-hourly sync — an owner granted here must block their own offboarding today, not in six hours — and `google.group_sync` remains the authoritative corrector; the two agree because both derive from Google.
2. **Ownership guards read roles LIVE from `members.list`, never from `email_group_owners`.** That mirror only holds owners resolvable to a Helix person, so an external or departed co-owner is invisible in it. A mirror-based owner count is exactly how "remove the last owner" slips through. `revoke_owner` refuses `LAST_OWNER` (409) off the live count; the generic `members/remove` path refuses owners outright and says which endpoint to use instead, so it cannot become a back door around the guard.
3. **Dynamic groups are refused, and so is `kind='unknown'`.** A manual edit to a rule-driven group is reverted by Google, so accepting one would make Helix report a change that quietly un-happens. An undetermined group *might* be dynamic, so `GROUP_KIND_UNKNOWN` (409) is the only answer that cannot silently no-op.
4. **Partial success is reported as partial.** Batch member ops isolate per address — one typo or Google refusal never discards the addresses that worked, and the response + the audit `detail` carry a row per address (`added` / `already_member` / `role_changed` / `already_absent` / `failed` + the error). A 50-name paste is unusable otherwise, and "3 requested, 2 added" is an unanswerable audit row a month later.

Other deliberate calls:
- **External addresses are allowed.** Company groups routinely carry vendor / contractor / nested-group addresses; refusing anything without an `identity_persons` row would make the tool unable to reproduce the groups it manages. Only resolvable people get a notification (new template `mail_group_member_added`, three languages) — silence for an external address is correct, not a miss.
- **Re-adding an existing OWNER as `MEMBER` reports `role_changed`, never `already_member`.** It is a demotion; an operator pasting a list that happens to include the owner must be told.
- **Display edits use the Directory API**, not Cloud Identity — new driver method `patch_group_directory_display` on the already-delegated `admin.directory.group` scope. Cloud Identity's patch needs the separately-delegated `cloud-identity.groups` WRITE scope, so routing static-group renames through it would have added an ops prerequisite for no benefit. An unchanged edit short-circuits to `outcome='unchanged'` with no Google call and no audit row, so the log does not fill with "renamed X → X" every time somebody opens the dialog and saves. The group's ADDRESS stays immutable — Google has no rename-address operation.
- **`create_mail_group` gained `admin_mode`** rather than a second implementation: the Google create, the freshly-created-group 404 propagation retry, the owner-eligibility gate and the delete-the-Google-group compensation are all subtle and must not exist twice. Admin mode changes exactly two things — no per-requester rate limit (an admin standing up five team lists is the tool working), and the creator is NOT forced to be an owner (`owner_ids` is the complete set, ≥1 required, since HR creating a list *for* a team must not become responsible for it). The audit row carries `via: admin | self_service`.
- **The self-service alias flow now mirrors onto the group's timeline.** `POST /email-groups/aliases` keeps writing its own `email_alias.create` row AND writes `email_group.alias_added` scoped to the group — two resources, two lifecycles. Without the mirror the operation log (which filters `resource_type='email_group'`) silently omitted every alias ever added that way.
- New driver method `set_group_member_role` (`members.patch`) is insert-free on purpose: a demotion must 404 when the person is absent rather than silently ADD somebody the caller believed they were demoting.

**MCP twins** (`mutates=True`, `can_manage_email_groups`): `add_email_group_members`, `remove_email_group_members`, `grant_email_group_owner`, `remove_email_group_owner`, `update_email_group_display`. Descriptions hard-code rules 2 and 4 and name `grant_email_group_owner` as **the only path that can fix an ownerless group** (`transfer_email_group_owner` needs an owner to transfer FROM). Deliberately NO new create/delete tools — `request_email_group` and `delete_email_group` already exist and a second entry point only makes "which one is right" ambiguous.

**邮件组管理 — one admin entry for the whole estate + the operation log** (added 2026-09-03, **0 migration** / **+4 MCP tools → hint 569** / 0 cron / **RBAC gate renamed** `can_manage_dynamic_email_groups` → `can_manage_email_groups`, old name kept as an alias). The 动态邮件组 menu entry became **邮件组管理** at `/admin/email-groups/*`, a host page ([`EmailGroupManagePage.tsx`](../../web/src/pages/emailGroup/EmailGroupManagePage.tsx)) with one route per tab so every view deep-links: `…/groups` (estate), `…/dynamic` (the previous entry's URL, deliberately preserved so old bookmarks land on the matching tab), `…/logs`. `normalizeMenuPath` maps the whole prefix onto the host key so the sidebar highlights on all three.

**Why**: the old entry could only manage the ~40 rule-driven groups. Prod carries **760 active groups = 40 dynamic + 720 static** (measured 2026-09-03), and those 720 had *no admin surface at all* — HR had to work them through the all-employee directory at `/company/mail-groups`, whose actions are scoped to "groups I own". The estate tab is the missing half; the log tab is the missing history.

**New read API** `/api/v1/email-groups/manage/*` ([manage_router.py](../../server/app/domains/email_group/manage_router.py), [manage_service.py](../../server/app/domains/email_group/manage_service.py)) — read-only by design; every mutation keeps living on the surface that already owns it (`router.py` static, `dynamic_router.py` rule-driven) so each write has exactly one implementation and one set of guards:
- `GET /manage/overview` — estate KPI strip in ONE statement (conditional `SUM(CASE …)` aggregates + one owner-exists subquery).
- `GET /manage/groups` — both flavours in one filterable list: `kind` (all|static|dynamic|unknown), `members` (all|zero|nonzero|unsynced), `owners` (all|none|some), `search` (address/name/alias), `suffix`, `include_deleted`, `sort`/`order`.
- `GET /manage/groups/export.xlsx`, `GET /manage/audit-log/export.xlsx`.
- `GET /manage/audit-log` — the 操作日志; `GET /manage/audit-log/actions` — its filter catalog.

**Three semantics that are load-bearing and easy to get wrong** (all pinned by tests in [`test_manage_api.py`](../../server/tests/test_email_group/test_manage_api.py)):
1. **Google counts OWNERS as direct members.** So `direct_members_count = 0` *already implies* "no owner" — prod has 133 static zero-member groups and all 133 are ownerless. Never report the two as independent findings. The consequence for the UI: an empty group is a **cleanup** candidate, while a group with members and no owner is a **stewardship gap** whose fix is assigning an owner, never deletion (459 of 720 static groups are ownerless — that number is a governance backlog, not a delete list).
2. **`direct_members_count IS NULL` means never synced, not zero.** Counting NULL as zero would drop an unsynced group into the cleanup pool. Spelled `> 0` rather than `NOT (= 0)` throughout, because `NOT (NULL = 0)` is NULL and a negation silently drops the row from both buckets.
3. **`is_dynamic` is tri-state.** `NULL` = Cloud Identity detection never ran determinately (missing SA scope) and surfaces as `kind='unknown'` with its own warning banner — such a group is never counted or filtered as static. Prod currently has 0 of these; the guard exists because the detection degrades gracefully by design.

**Operation log is a VIEW, not a second log**: it reads `audit_logs WHERE resource_type='email_group'`, which the domain has written for every mutation since it shipped — so the tab is retroactive from day one. Enriched on read (actor identity, group email) so a directory rename surfaces while audit rows stay immutable. `search` matches the GROUP and **includes soft-deleted rows**, which is the whole point of soft-delete: after a group is cleaned up, "what happened to `careers@`?" is still answerable. The resolved group-id IN-list is capped at 200 and an over-broad term returns `SEARCH_TOO_BROAD` (400) rather than a silently truncated — i.e. lying — log. `email_group` is registered in `audit/router.py::_RESOURCE_HISTORY_PREFIX_ACCESS` (global_admin + auditor + **hr** + management) so the per-row 「历史」 drawer (shared [`ResourceHistoryDrawer`](../../web/src/components/ResourceHistoryDrawer.tsx)) works for the role that owns the desk; without that registration HR falls through to the conservative admin/auditor default and 403s.

**Action filter comes from a hand-kept registry**, [`manage_actions.py`](../../server/app/domains/email_group/manage_actions.py), not `SELECT DISTINCT action` — an action nobody has performed yet is still filterable, and a retired one disappears. Rot is prevented by [`test_manage_actions_registry.py`](../../server/tests/test_email_group/test_manage_actions_registry.py), which greps the source for `action="email_group.*"` literals and asserts **set equality in both directions**; `DYNAMICALLY_BUILT_ACTIONS` is the escape hatch for `f"email_group.join_{status}"`. An unregistered action still renders (category `other`) — the log never hides a row because the registry is stale.

**`google.group_sync` now audits the Google-side changes it discovers** ([google_handlers.py](../../server/app/scheduled_jobs/google_handlers.py) `_log_group_sync_event`): `sync_discovered`, `sync_discovered_bulk`, `sync_restored`, `sync_removed`, `sync_renamed`, `sync_owner_changed`, `sync_dynamic_flag_changed`, all `actor_type='system'` with one shared `trace_id` per tick. Google is authoritative for existence / ownership / name, so the most consequential events in a group's life happen outside Helix; before this the log could not distinguish "nothing happened to this group" from "everything happened somewhere else". **The invariant that keeps it readable: only a real diff writes a row** — a steady-state tick over 760 unchanged groups writes ZERO (pinned by `test_steady_state_tick_writes_nothing`; otherwise ~3,000 rows/day of noise). A greenfield tick collapses to one `sync_discovered_bulk` row past `_SYNC_DISCOVERY_BULK_THRESHOLD` (25). Each write is SAVEPOINT-isolated via `best_effort`, so an audit failure can never lose a reconcile that already mirrored Google's truth locally. An indeterminate dynamic-detection tick writes **no** flip event — the row's value is deliberately left untouched, so there is no transition to report.

**MCP twins** (read-only, `can_manage_email_groups`): `list_email_groups_estate`, `get_email_group_estate_overview`, `list_email_group_operation_log`, `list_email_group_operation_log_actions`. Their descriptions hard-code the three semantics above, because a model that does not know "owners count as members" will confidently report one fact as two findings. `_enum_guard` rejects an out-of-vocabulary filter value at the MCP boundary instead of letting the service's permissive fallback answer a different question than the one asked.


**Tables**: `email_groups`, `email_group_owners` (CASCADE on both sides), `identity_person_email_aliases` (added in [`057_identity_person_email_aliases.py`](../../server/alembic/versions/057_identity_person_email_aliases.py)).

**Routes**: `/api/v1/email-groups/*`, `/api/v1/me/email-aliases/*`, `/api/v1/users/{id}/email-aliases/*`, `/api/v1/me/email-groups` (my memberships) + `/api/v1/me/email-groups/leave`. Join-request + member-management routes: `POST /email-groups/{id}/join-requests`, `GET /email-groups/join-requests/pending`, `GET /email-groups/join-requests/mine`, `POST /email-groups/join-requests/{id}/decide`, `POST /email-groups/join-requests/{id}/cancel`, `GET /email-groups/{id}/members`, `POST /email-groups/{id}/members/remove`.

**Invariants**:
- Google Workspace requires ≥1 owner per group → ownership is a MUST_TRANSFER blocker on offboarding.
- Soft delete via `status='deleted'` (row retained for historical references).
- Aliases: per-person + per-group (Google).

**Self-service membership view + leave** (added 2026-06-09, 0 migration / +2 MCP tools → hint 256 / 0 cron / 0 RBAC). Employees can now see which mail groups they belong to and leave one themselves, on a new "我的邮件组" tab in 个人设置 (`/my/settings?tab=groups`). Membership is read **live from Google** (`GoogleWorkspaceDriver.list_groups_for_user` → `groups.list(userKey)`) because Helix only syncs group metadata + owners, never the full member roster — there is no local membership table. `is_owner` / `sole_owner` are derived by joining the live group emails against the synced `email_group_owners` rows (no extra per-group `members.get` call). **Leave** (`EmailGroupService.leave_group_for_person` → `driver.remove_group_member`, idempotent on 404) refuses the **sole owner** up-front (`SOLE_OWNER_CANNOT_LEAVE` 409 — leaving would orphan the group, which breaks the ≥1-owner invariant; the user is pointed to transfer-owner in the directory). A co-owner who leaves has their local `email_group_owners` row removed in lock-step. Every leave records a completed `EmployeeRequest(request_type='google_group_leave')` (visible on /my/requests) + an `email_group.leave` audit row, and notifies the group's remaining owners (`template_key='mail_group_member_left'`, optional reason relayed). MCP twins `list_my_group_memberships` (read) + `leave_my_email_group` (`mutates=True`). Tests: [`tests/test_email_group/test_group_membership.py`](../../server/tests/test_email_group/test_group_membership.py).

**Dynamic-group + owner-disband — completing the 我的邮件组 lifecycle** (added 2026-06-09, **migration 266** / 0 new MCP tool / 0 cron / 0 RBAC). Two follow-ups so the tab is the full owner+member self-service surface:
- **Dynamic groups (Cloud Identity)** — some groups have rule-computed membership; a member's manual leave is a no-op (Google re-adds / rejects). Migration 266 adds `email_groups.is_dynamic BOOLEAN NULL`; `google.group_sync` now also calls `GoogleWorkspaceDriver.list_dynamic_group_emails` (Cloud Identity `groups.list(parent='customers/my_customer', view='FULL')` → groups carrying `dynamicGroupMetadata`) and stamps `is_dynamic` per group. The tab disables 退出 for dynamic groups (purple 动态组 tag + "成员由规则自动管理,自助退出无效,请联系 IT" tooltip); `leave_group_for_person` also refuses with `DYNAMIC_GROUP_CANNOT_LEAVE` 409 as a server-side backstop. **Ops dependency**: the SA needs the `cloud-identity.groups.readonly` scope in domain-wide delegation. Until that's added the Cloud Identity call 403s → `list_dynamic_group_emails` returns `None` (indeterminate) → sync leaves `is_dynamic` **untouched** (stays NULL = treated as static/leavable, an error line is logged on the sync run). `is_dynamic` is read at every layer as "block only on explicit `True`", so an unconfigured scope never traps a user. [See `project_cloud_identity_dynamic_group_scope_pending` memory.]
- **Owner disband** — the tab's owner rows now expose a 解散 action that reuses the existing `DELETE /email-groups/{id}` flow (owner/HR/global-admin, reason ≥4, Google hard-delete + local soft-delete + audit) — no new endpoint. Sole owners thus have both paths: transfer (directory link) or disband (inline).

**HR dynamic email-group management (Cloud Identity, rule-computed membership)** (added 2026-07-22, **migration 424** / **+5 MCP tools → hint 432** / **+1 cron `google.dynamic_group_drift`** / **+1 RBAC gate `can_manage_dynamic_email_groups`**). HR / global_admin can now fully manage the ~40 Cloud Identity **dynamic groups** (membership computed from a CEL rule over synced HR attributes — department / office / country / job-function). New internal admin API under `/api/v1/email-groups/dynamic/*` ([dynamic_router.py](../../server/app/domains/email_group/dynamic_router.py), [dynamic_service.py](../../server/app/domains/email_group/dynamic_service.py), CEL compile/eval in [dynamic_cel.py](../../server/app/domains/email_group/dynamic_cel.py)): list (rule summary + health/dead-values + member count), detail (incl. `field_catalog` = rule-eligible fields with live value options + per-value counts so a rule references only real, populated values), reconcile (expected-vs-actual membership diff), preview a candidate rule (compiled query + member delta + `large_drop` flag when >30% of members would be removed), create, edit rule, edit display, add/remove alias, toggle the opt-in missing-value drift alert, delete. **Structured rule builder** (not raw CEL): `{field, match_values, include_emails, negate}` → compiled to Cloud Identity query. **Direct-effect governance** (2026-07-22 decision): HR edits take effect **immediately** — no per-group owner, no Feishu approval — but every mutation is audited and IT-admin-notified (never employees; IT-only push rule), and a rule edit that would drop >30% of members is refused unless `confirm_large_drop=true` (the g-all / EMEA all-hands silent member-drop guard). **Cloud Identity driver read-fix**: `groups.list` never returns `dynamicGroupMetadata`, so dynamic-group discovery now uses `groups.search` + `groups.get` ([google_workspace.py](../../server/app/integrations/drivers/google_workspace.py)); new **write** ops on the driver — `create_dynamic_group` / `patch_dynamic_group_query` / `patch_group_display`. **Drift cron `google.dynamic_group_drift`** (daily): re-evaluates each rule against the current user-attribute scan and flags **dead values** (rule terms matching zero active users — the root cause of the silent all-hands drop), dedup-aware via `drift_signature` so IT is re-alerted only when the drift state changes. **MCP twins** (1:1, all gated by `can_manage_dynamic_email_groups`): `list_dynamic_email_groups`, `get_dynamic_email_group`, `reconcile_dynamic_email_group`, `preview_dynamic_email_group_rule` (read), `upsert_dynamic_email_group_rule` (`mutates=True`), `list_dynamic_email_group_members` (read — 2026-08-04). **Members export (2026-08-04)**: `GET /api/v1/email-groups/dynamic/{id}/members/export.xlsx` streams the group's live Google members enriched with the matching person's name / employee-no / job / office / country / employment status (unified `xlsx_export_response` primitive, audited `email_group.dynamic_members.export`); `list_dynamic_email_group_members` is its JSON twin. **⚠ Ops pre-deploy dependency**: the Google SA's domain-wide delegation allow-list must add the **write** scope `https://www.googleapis.com/auth/cloud-identity.groups` (it currently only has `cloud-identity.groups.readonly`); until then all dynamic-group **write** ops 403.

**申请加入 + owner 审批 + owner 成员管理 — completing the directory's join promise** (added 2026-07-13, **migration 397** = `email_group_join_requests` / **+3 MCP tools → hint 413** / 0 cron / 0 new RBAC role). The employee guide long promised "点『申请加入』,走审批;负责人通过后你就成为成员" but no such flow existed — pure misdirection until now. Because a mail-group owner is an **arbitrary employee** (not a fixed Feishu approver node — see `decision_no_third_party_approval`), the approval is **in-Helix**, mirroring the cloud-access grant model ("being an owner is a data-driven duty, not a role"), NOT a Feishu template. New table `email_group_join_requests` (`status pending|approved|rejected|cancelled`, generated `open_request_key` = `CONCAT(group_id,'|',requester_id)` only while pending → UNIQUE index = at-most-one open request per (group, requester), race-proof, re-appliable after a terminal decision). **Flow**: `EmailGroupService.request_join` guards (active + non-dynamic group, requester not already owner/live-member, no dup pending) → notifies every owner (`template_key='mail_group_join_requested'`, carries the reason + the `groups.google.com/a/<domain>/g/<local>` management link). `decide_join` (owner OR `can_delete_any_mail_group`; leaver-gated like SOC2) → on approve calls the new idempotent `GoogleWorkspaceDriver.add_group_member` (role=MEMBER, 409→already_member) then notifies the requester; on reject notifies with the owner's note. `cancel_join` withdraws a still-pending own request. **Owner 待审批 surface**: the directory page (`EmailGroupDirectoryPage`) grows a badge-counted 待审批 drawer (opens on the owner notification's `?review=1` deep link) + the join request also lands in the mobile action inbox via `_provider_email_group_join_approval` (owner-scoped, HR/IT-admin see org-wide, four-eyes self-exclusion — same shape as `_provider_cloud_grant_approval`). **Owner 成员管理**: `list_members` (live `members.list`, enriched with Helix names) + `remove_member` (owner/admin, refuses removing an OWNER — use transfer — and dynamic groups; notifies the removed person) behind a per-row 成员 drawer that also surfaces the Google Groups management link. Requester tracking: new `my_requests` provider `SRC_EMAIL_GROUP_JOIN` (terminates at `approved`), cancellable while pending. MCP: `request_join_email_group` / `list_pending_group_join_requests` / `decide_group_join_request`.
- **Indirect (nested) membership** — `members.delete` 404 on a group the user was shown in ⇒ they're an indirect member; the leave response carries `already_absent=true` and the UI shows a "可能是间接成员,请联系 IT" note instead of a success toast (no proactive per-row detection — that would cost an extra `members.get` per group).

**Create-flow Google propagation retry** (added 2026-05-26). Google Directory API's `groups().insert()` returns 200 before the new group is visible on all backend replicas; calling `members().insert(groupKey=newly-created-email, role=OWNER)` immediately can return `404 Resource Not Found: groupKey` for a few seconds. `EmailGroupService.create_mail_group` wraps the per-owner `set_group_owner` call in a 5-attempt 404-only backoff (0 → 0.5s → 1s → 2s → 4s, ~7.5s max). Non-404 errors still fail-fast — the retry is scoped to the propagation gap on freshly-created groups. The driver's `set_group_owner` itself is NOT made retry-on-404, because `transfer_owner` callers want 404 to mean "group is really gone" (don't paper over a deleted group with retries). Regression in `tests/test_email_group/test_directory_api.py::TestCreateMailGroupPropagationRetry`.

**Group aliases + member count surfaced** (added 2026-05-14, migration 166). The `email_groups` table now carries `aliases JSON` (sorted unique merge of Google's `aliases[]` + `nonEditableAliases[]` — both route to the same group, distinction only matters when deleting an alias) and `direct_members_count INT` (Google's `directMembersCount` — direct members only, nested groups not expanded). Populated on every `run_google_group_sync` tick — Google's `groups.list` response already includes both fields, so no extra API calls. `EmailGroupResponse` coerces `None` (= "never synced") for `aliases` to `[]` at the router edge so the UI iterates uniformly; `direct_members_count` stays nullable on the wire so the UI can render `—` for unsynced rows instead of a misleading "0 members". `list_groups(search=...)` now also matches aliases via `LOWER(CAST(aliases AS CHAR)) LIKE` — the cast-to-text trick is portable across MySQL 8 + TiDB (unlike `JSON_SEARCH` which has subtly different argument shapes), search-input is parameter-bound so the cast doesn't widen the SQL-injection surface. Directory page (`web/src/pages/emailGroup/EmailGroupDirectoryPage.tsx`) renders aliases as copy-on-click tags (visible-3 + "+N" overflow tooltip) and a right-aligned numeric `成员数 / Members` column. MCP `list_my_email_groups` returns the same two fields for AI-client parity (MCP-sync rule).

**Owner-search picker** (added 2026-05-08): `GET /email-groups/owners-directory` returns DISTINCT active owners across all mail groups (`{id, display_name, work_email}`). Powers the owner-filter dropdown's "搜索姓名 / 邮箱" placeholder so name-or-email search actually works. Zero new PII surface — the same `work_email` is already returned for every owner of every group via the public `GET /email-groups` listing's `EmailGroupOwnerResponse.work_email`. The endpoint is a search-convenience aggregation that lists ONLY people who already appear as an owner somewhere; it does NOT enumerate the full active-employee directory the way `/auth/users` does. The transfer-target picker on the same page deliberately stays on `/reference-data/people-picker` (no email exposure) since transferring to a non-owner is legitimate but would otherwise require widening the people-picker's PII contract for non-mail-group callers.

### 12.8.1 Cloud cost facts & FinOps analytics (production-grade, migrations 139 + 140)

**Why** — the legacy billing surface (`cmdb_billing_monthly` + `cmdb_cloud_billing_daily`) only captured tenant-month + scope-day grain. "Which Azure resource group inside subscription 7bc8ab17 spent what last month" had no answer except "open the Azure portal." Every cloud's billing API exposes line-item granularity (Azure Cost Management `ResourceId` grouping, Aliyun BSS `DescribeInstanceBill`, AWS `GetCostAndUsageWithResources`, Tencent `DescribeBillDetail`, GCP BigQuery export); this section adds the persistence + analytics layer that exposes it.

**Tables**:
- [`cmdb_cost_facts`](../../server/app/domains/cmdb/models.py) — line-item ground truth. One row per (date × resource × usage_type). Carries the multi-dim shape (resource_group, resource_id, resource_type, region, service, usage_type, JSON tags), USD + native amount, usage quantity / unit, and **denormalised allocation columns** (`cost_owner_person_id`, `cost_owner_department`, `cost_center`, `allocation_rule_id`, `auto_owner_source`) populated at ingest time so analytics queries don't re-walk the rule ladder per row. BIGINT auto-increment PK + sha256(natural-key) `fact_key UNIQUE` for idempotent upsert (NULL marker = U+0000 to avoid colliding with any literal provider value). FK to `integrations` is `RESTRICT`. Composite indexes: `(date, integration_id)`, `(resource_id, date)`, `(integration_id, resource_group, date)`, `(integration_id, service, date)`, `(cost_owner_person_id, date)`, `(cost_center, date)`, `(auto_owner_source, date)` (migration 145 — powers the 未分摊 KPI tooltip + recompute summary), `(scope_id, date)` (migration 181 — pins every per-scope read path to an index-range scan AND lets TiDB plan the cron's pre-window DELETE without secondary filtering; the DELETE itself still wraps in PR #380's `DELETE … LIMIT 1000` chunking re-applied in Round-39 because the index bounds rows scanned, not row-payload total memory). **`usage_quantity` is `Numeric(30, 6)` (widened from `Numeric(20, 6)` by migration 144, 2026-05-10)** — GCP storage SKUs report cumulative byte-seconds at the daily-aggregate level (one `Storage Machine Image` row was 1.57e15 byte-seconds = ~18 GB stored 1 day), overflowing the 14-integer-digit ceiling and killing every per-scope GCP cost-facts UPSERT batch with `(asyncmy.errors.DataError) (1264, "Out of range value for column 'usage_quantity' at row 1")`. Money columns (`amount_usd` / `amount_native`) stay at `Numeric(14, 6)` — they don't need this width and a wider money column would change the finance-aggregate display layer.

**Two-stage attribution (migration 145, 2026-05-11)** — `cost_owner_person_id` is now stamped by a two-stage chain, recorded in `auto_owner_source ∈ {rule, scope_owner, none}`:

  1. **Rule layer** (admin overrides): walk `cmdb_cost_allocation_rules` by priority. First match wins → `auto_owner_source='rule'`, `allocation_rule_id` set. This is the override path — RG split-out, tag-based chargeback, etc.
  2. **Scope-owner fallback** (the default): if no rule matched AND `fact.scope_id` resolves to an active `cmdb_cloud_scopes` row whose `owner_person_id` is set, inherit that owner. `auto_owner_source='scope_owner'`. This is the "every dollar lands on a billable scope; every active scope has an owner; ergo default attribution works without a single admin-authored rule" chain — covers AWS subscriptions, GCP projects, AliCloud sub-accounts, Azure subscriptions, Tencent / UCloud standalone IAM scopes.
  3. **Unattributed**: neither matched → `auto_owner_source='none'`, surfaces under the dashboard's 未分摊 bucket.

  Applies symmetrically to `ingest_cost_facts` (new fact rows) and `recompute_allocation` (admin "重算最近 90 天" button — re-stamps existing rows after a rule edit OR after a scope-ownership change). Pre-fix the recompute walked rules-only and reported `命中 0 条 / 更新 0 条` on a tenant with zero rules even when scope owners were set; post-fix it returns `{scanned, matched_by_rule, matched_by_scope, still_unallocated, updated, matched=rule+scope}` so the toast can render "命中 N (规则 R + 默认归属 S),仍未分摊 U". Production state at fix time: 10,502 facts / $7,332.99 over 90 days, 100% scope_id coverage on all 3 producing clouds (Azure / GCP / AliCloud Intl), 100% scope.owner_person_id fill rate on those scopes — fallback attributed every dollar after one recompute pass.

**Manager-scoped cost view** (added 2026-05-10 alongside `apikey.budget_check`): `GET /api/v1/cmdb/cost/subordinates` (and matching MCP tool `cost_subordinates_breakdown`) returns total + by-person + by-service + by-department breakdowns scoped to the caller PLUS every direct + transitive report. BFS reuses the asset-domain canonical helper `_resolve_subordinate_ids` (`employment_status != 'left'` filter on descendants; explicit guard on the root in the cost endpoint blocks a left employee with a still-valid JWT from pulling their own historical spend). No `can_view_finance` gate — the response is by-construction caller-scoped, so every authenticated active employee can call it (a non-manager simply sees their own row). Powers the `/my/cloud/department-cost` page rendered for every authenticated user.
- [`cmdb_cost_allocation_rules`](../../server/app/domains/cmdb/models.py) — priority-ordered chargeback ladder. Match-* fields are nullable AND-ed predicates; assign-* targets one of person / department / cost_center. **`match_provider` value contract**: bare cloud-slug as stored in `integrations.provider` (`azure`, `aws`, `aliyun`, `alicloud_intl`, `tencent`, `gcp`, `ucloud`) — NOT the driver-module suffix `_cloud` form. The chargeback engine compares `rule.match_provider == fact.provider` literal; the allocation-rule modal in `CloudCostAnalysisPage.tsx` must keep its dropdown values aligned with this list. PR #226 shipped the modal with `_cloud`-suffixed values; corrected in the 2026-05-09 follow-up — silent zero-match rules.

**Driver method** — [`CloudDriver.list_cost_facts(start, end, *, scope=None, cny_per_usd=None, principal_tag_keys=None) -> list[CostFact]`](../../server/app/integrations/driver_base.py). Implementations on Azure / Aliyun / AWS / Tencent / GCP (see capability matrix above). The `cny_per_usd` kwarg is resolved by the cost-ingestion service when `requires_cny_fx=True` (Aliyun / Tencent); USD-native drivers (AWS / Azure / GCP) accept the kwarg to match the contract and `del` it. **The signature is pinned by [`test_every_cloud_driver_accepts_cny_per_usd_kwarg`](../../server/tests/test_cmdb/test_cost_service.py)** — when PR #229 added the kwarg it forgot to update the USD-native drivers' overrides; the cron blew up the next morning with `unexpected keyword argument 'cny_per_usd'` on every USD-native integration. The contract test introspects the registry so a future driver added without the kwarg fails CI before it can ship.

**Tencent `DescribeBillDetail` cost lives in `ComponentSet`, NOT a top-level field (2026-06-03 fix)** — the Tencent driver's [`_list_billing_daily_sync` / `_list_cost_facts_sync`](../../server/app/integrations/drivers/tencent_cloud.py) read each row's CNY amount via the shared helper `_bill_detail_real_cost_cny(row)`, which **sums `row.ComponentSet[].RealCost`** (fallback `Cost` per component). A `DescribeBillDetail` `BillDetail` object has **no** `RealTotalCost`/`TotalCost` attribute — those belong to a *different* Tencent API, `DescribeBillSummaryByProduct` → `SummaryOverview` (used by the monthly [`providers/tencent.py`](../../server/app/domains/cmdb/providers/tencent.py), which is correct). The pre-fix code read the SummaryOverview field name off `BillDetail` rows; `getattr` returned `None` for every row → fell back to `"0"` → `cmdb_cloud_billing_daily` wrote all-zero days and `cmdb_cost_facts` dropped every line via `if native == 0`. Symptom: 腾讯云 账单/概览 hero, by-account ranking and cost facts silently empty despite a healthy connector and correctly-parsed service names. A full SDK-introspection audit of every cost driver confirmed this was **Tencent-only** — the other CNY drivers (Aliyun `DescribeInstanceBill.PretaxAmount`, UCloud `ListUBillDetail.AmountReal`) read genuinely-existing top-level fields. Lesson for the next driver: when copying a cost-field name across Tencent billing APIs, verify it against the *target* API's response model (`uv run python -c "from tencentcloud.billing.v20180709 import models as m; print(sorted(vars(m.BillDetail()).keys()))"`), because each API nests cost differently.

**Tencent billing is attributed by billing cycle, not 扣费时间 (2026-06-03 follow-up)** — the same two methods query **one cycle per month via `req.Month="YYYY-MM"`** (NOT `BeginTime`/`EndTime`) and attribute each row to its usage day via `_bill_detail_usage_day(row, cy, cm)` = `FeeBeginTime` (fallback `BillDay`→`PayTime`), clamped into the queried cycle month. `_bill_cycle_months(start, end)` enumerates the cycles a window touches; the old `_split_by_calendar_month` (a `BeginTime`/`EndTime` cross-month workaround) was removed. **Why:** the `RealCost` fix above made amounts non-zero, exposing that grouping by `PayTime` mis-attributed monthly settlements — Tencent settles CDN/COS on the **1st of the following month**, so e.g. May's ¥5,390 CDN settlement (FeeBeginTime=2026-05-01, paid 2026-06-01) landed under 2026-06-01, over-stating June 35× vs the console (¥161.50). Prod-verified: `Month=2026-05` sums to the console 费用趋势/按计费周期 May figure to the fen (¥12,782.47). The window now only selects *which cycle months to (re-)ingest*; the daily cron's trailing window re-pulls the current (and, at a month boundary, the previous) whole cycle — idempotent via upsert, and it catches late-settled monthly charges. **Operator note:** a code change to Tencent cost attribution requires DELETING the integration's old `cmdb_cloud_billing_daily` + `cmdb_cost_facts` rows before re-backfilling — dates that had data under the old attribution but none under the new one would otherwise keep stale values (upsert only overwrites returned dates).

**Tencent cost_facts aggregates to the fact-key grain before emitting (2026-06-03 follow-up)** — `ingest_cost_facts` UPSERTs by `compute_fact_key` = (integration, date, scope, resource_id, usage_type) with **REPLACE** semantics (re-ingest idempotency). But `DescribeBillDetail` returns **multiple sub-line-item rows that share that grain** (a COS bucket on one day → ~4 rows differing only in ItemCode / sub-period). Emitting them individually made the last UPSERT win and silently dropped the rest — prod 2026-05 COS real ¥3,344 stored as ¥30.51 (99% undercount; 4037 rows → 523 grains, 434 multi-row). `_list_cost_facts_sync` now **sums to `(day, resource_id, usage_type)` in the driver** (the exact key grain; integration/scope constant per call), merging tags and keeping grain-consistent service/region/resource_* fields, so every emitted `fact_key` is unique and carries the full amount → cost_facts reconciles to billing_daily / the console. `billing_daily` was unaffected (sums per-day, no per-resource keying). Tencent-driver-local; other drivers group server-side and don't produce same-grain duplicates (re-evaluate per-driver if a similar undercount surfaces). This is also why `compute_fact_key` did NOT gain a `service` dimension — the collisions are *within* one service, so the cure is summing at the grain, not widening the key.

**`principal_tag_keys` (2026-05-10)** opts AWS-SG-style accounts into IAM-principal-tag granularity (one fact per (date × service × principal-tag-value) cell with `tags[<key>]=<owner>`) instead of the default per-resource shape. Resolved by the cost-ingestion service from `platform_settings.cost_facts.aws_principal_tag.<integration_slug>` (string OR JSON list of strings) — empty / missing setting falls through to the default per-resource path. Used for accounts where the primary cost driver has no taggable billable resource (Bedrock on-demand `InvokeModel` is the canonical case — the foundation model is shared across customers, so resource-level user-defined tags don't reach Cost Explorer; AWS exposes a parallel cost-allocation surface for that case via **IAM principal tags**, which surface in CE under the `iamPrincipal/<Key>` dimension once activated in Billing Console → Cost allocation tags — NOT `aws:PrincipalTag/<Key>`, which is the IAM-policy evaluator namespace and is silently rejected by CE GroupBy). Today only the AWS driver implements the mode; Aliyun BSS / Tencent CAM / Azure CM / GCP have no equivalent dimension and accept-and-`del` the kwarg for signature uniformity. Migration **142** seeds `cost_facts.aws_principal_tag.aws-sg = "Owner"`. The matching applicant-attribution rule lands at `helix_autogrant.bedrock` time: a priority-10 `CmdbCostAllocationRule` keyed on `match_tag_key="Owner", match_tag_value=<applicant_email>, assign_person_id=<applicant>` written in the same durable commit as `granted=True`, mirroring the Azure OpenAI auto-grant pattern. Bedrock auto-grant tags the IAM user with `Owner=<work_email>` (was `Owner=<project_name>` pre-2026-05-10 — collapsed multiple applicants on the same project into one bucket and broke per-employee chargeback).

**Bedrock per-AK spend resolver cascade (2026-05-12, audit fix)** — [`apikey/spend.py::_resolve_bedrock_owner_tag_value`](../../server/app/domains/apikey/spend.py). The per-AK budget panel cannot recompute the Owner tag value from `iam_user_arn` alone (the autogrant tagged IAM users with `Owner=<email>`, not `Owner=<username>`). The resolver reads the actual value AWS has on the IAM user via a 3-step cascade: (1) **`cmdb_iam_users.tags['Owner']`** — authoritative mirror, handles BOTH the current `Owner=<email>` schema AND historic `Owner=<project>` rows; (2) **bound `IdentityPerson.work_email`** for the assignee — what the autogrant writes today; (3) **`parse_iam_user_name(arn)`** legacy fallback for very old rows tagged `Owner=<username>`. The `(unallocated)` sentinel is never returned — matching against it would attribute every untagged AWS principal's cost to the AK. When the cascade returns nothing (no IAM binding, no email, no parseable arn), the panel surfaces `_CAVEAT_BEDROCK_NO_OWNER_TAG` with backfill-script instructions distinct from `_CAVEAT_BEDROCK_NO_IAM_BINDING` so admins see actionable text. When the cascade resolves a value BUT the AK matches no facts AND the integration has Bedrock-family `Owner=(unallocated)` rows in the window, the panel surfaces `_CAVEAT_BEDROCK_OWNER_UNALLOCATED` — admin needs to activate the `Owner` cost-allocation tag in AWS Billing Console (forward-looking only; historic days stay `(unallocated)` after activation). Production audit 2026-05-12 found 3/3 active Bedrock AKs were impacted; backfill script [`server/scripts/backfill_bedrock_owner_tags.py`](../../server/scripts/backfill_bedrock_owner_tags.py) re-tags AWS IAM users, mirrors into `cmdb_iam_users.tags`, writes missing allocation rules, and stamps `detail.bedrock.{owner_tag_value, allocation_rule_id}` for the rebind / revoke paths.

**Revoke-disables-allocation-rule contract (2026-05-12)** — [`ApiKeyService._disable_allocation_rules_on_revoke`](../../server/app/domains/apikey/service.py). `revoke_key` after cloud-side teardown succeeds but before flipping `status=revoked`, every `detail.<bucket>.allocation_rule_id` stored on the assignment (Bedrock / AOAI / Bailian) gets `enabled=False` with a description suffix `[disabled by revoke of <req#> on <UTC-date>]`. Disable, NOT delete: forensics + the dashboard's "未分摊" KPI rely on the rule row remaining inspectable. The revoke audit row's `detail.allocation_rules_disabled` field carries the summary `[{bucket, rule_id, status, rule_name}]` so SREs can reconstruct the chain. Idempotent: `status='already_disabled'` on retry (no re-stamping of the description suffix), `status='not_found'` if the rule was admin-deleted (revoke continues; cloud teardown is past the point of no return). Pre-2026-05-12 rules accumulated indefinitely and silently mis-charged departed employees' email tags on any future AWS principal sharing the value.

**AOAI autogrant idempotent rule write (2026-05-12)** — [`azure_openai_autogrant.py`](../../server/app/domains/apikey/azure_openai_autogrant.py) now does a name-uniqueness pre-check before inserting the `CmdbCostAllocationRule` row (mirror of `bedrock_autogrant.py:467-506` since PR #220). An admin retrying the autogrant via `/admin/api-keys` after clearing `detail.azure_openai.granted=False` no longer adds a duplicate priority-10 rule against the same RG (which would have both matched every fact and only the lower-id one would stamp the owner — silent duplication bloating the rules table).

**AWSProvider tag_iam_user (2026-05-12)** — [`AWSProvider.tag_iam_user(user_name, tags)`](../../server/app/domains/cmdb/providers/aws.py) wraps `iam.tag_user` (upsert-shaped). Public surface for future autogrant retag flows and admin retag UI. `NoSuchEntity` is re-raised so callers can distinguish "user deleted between read and call" from transient errors; the backfill script uses boto3 directly (the providers package's eager driver imports form a circular chain that's only safe when the entrypoint is `app.main`).

**Service** — [`server/app/domains/cmdb/cost_service.py`](../../server/app/domains/cmdb/cost_service.py) (~1500 lines): `ingest_cost_facts` (chunked MySQL upsert keyed on `fact_key`; `tags`/`raw` JSON columns receive Python dicts directly; oversize raw is replaced with a `__helix_truncated__` marker dict), `recompute_allocation` (UTC-anchored, re-stamps trailing 90-day window after a rule edit OR scope-owner/dept change; scans in 10k-row batches and flushes changes as **one `UPDATE … WHERE id IN (…)` per distinct target tuple per batch** — NOT per-row, which timed the nightly cron out at 1800s on a ~500k-row re-stamp, fixed 2026-05-27), `breakdown` / `top_resources` / `timeseries` / `forecast_month_end` / `detect_anomalies` (pure stdlib `statistics`, no numpy; all UTC-anchored — anomaly window anchored to **yesterday** because cron writes facts for yesterday at 04:00 UTC and including a 0-cost today distorts the median/IQR), allocation rule CRUD (`SELECT FOR UPDATE` row-locked, `assign_person_id` validated to exist, `match_tag_value` without `match_tag_key` rejected, **`rule.match_provider` compared case-insensitively** for defence in depth, every CRUD writes an `audit_logs` row), `top_resources` keys its sparkline result dict by `(integration_id, resource_id)` not `resource_id` alone (cloud resource ids aren't unique across integrations — keying by id alone silently merged two tenants' data), CSV export streams via `stream_facts_csv` + `session.stream(...)` with a self-managed session (`StreamingResponse` so 100k-row dumps don't materialise; defangs formula-injection: `=`, `+`, `-`, `@`, tab, CR prefixed with apostrophe), and `sync_cost_facts_for_integration` orchestration. Department leaf for person → cost_center fallback is derived via direct `IdentityOrgMembership` join (NOT the lazy `org_memberships` relationship — would die in async sessions).

**Cron** — `cmdb.cost_facts_sync` handler. Provisioned per cloud integration as `cmdb.cost_facts.{slug}` (daily 04:xx SH, minute hashed from slug to avoid stampede) by [`scheduled_jobs_provisioning._build_lifecycle_rows`](../../server/app/domains/cmdb/scheduled_jobs_provisioning.py) — alongside `scopes` / `roles` / `grants` / `billing_daily`. Worker boot's `reconcile_all_cloud_integrations` backfills missing rows on existing integrations. **Migration 140** also one-shot-seeds the rows on the prod TiDB so the cron is live without waiting for a worker reboot. Migration 139's original seed loop was DOA — it filtered on `"billing" in integration.capabilities` but no cloud driver declares that capability — and stays in place as dead code; migration 140 does the real seed. Backfill via `config={"start_date":"...", "end_date":"..."}`. Handler raises on `status ∈ {failed, partial_failure}` so the failure-notifier fires (driver-load error, every-scope error with zero upserts); `ok` (incl. all-scopes `not_supported` for stub drivers) and `skipped` (integration disabled / missing) are non-failure outcomes that return without raising.

**Scope-type filter (2026-05-11)** — both `sync_cost_facts_for_integration` and `sync_billing_daily_for_integration` filter the per-integration scope query to `scope_type IN TENANT_LEVEL_SCOPE_TYPES` (`aws_account` / `azure_subscription` / `gcp_project` / `aliyun_account` / `standalone_iam`; defined in [`cmdb/models.py:63`](../../server/app/domains/cmdb/models.py)). Sub-tenant scope rows — today only `azure_resource_group`, minted by the Azure OpenAI auto-grant flow with the RG name in `external_id` — are NOT queryable units of any cloud's cost API and would land 400s (Azure returns `InvalidSubscriptionId` because the driver builds `/subscriptions/{external_id}/providers/Microsoft.CostManagement/query`). Pre-fix prod symptom: every per-tick Azure Foundry cron fired with `partial_failure` and 5–10 RG rows in the error list even though the parent subscription's facts ingested fine. RG-bound spend is still attributed to the applicant — autogrant writes a priority-10 allocation rule keyed on `match_resource_group=<rg_name>` alongside the RG `cmdb_cloud_scopes` row, and the parent subscription's facts hit the rule at ingest time. Driver-level defence in depth: the Azure driver's `list_cost_facts` / `list_billing_daily` / `list_billing_by_dimension` raise `NotImplementedError` when `scope.scope_type != "azure_subscription"`, so any future caller that forgets the filter gets a clean error (and the admin HTTP path `/cmdb/billing/by-dimension?scope_id=<rg>` returns 501, not a raw Azure 400). Pinned by `test_sync_cost_facts_skips_sub_tenant_scope_rows` ([test_cost_service.py](../../server/tests/test_cmdb/test_cost_service.py)).

**The same filter is REQUIRED on every per-scope driver fan-out, not just cost (2026-07-30)** — the 2026-05-11 fix above covered the two cost crons and stopped there; the IAM fan-out kept iterating every active scope, so `cmdb.grants_sync` went `partial_failure` on **every run from 2026-07-29 onward** (`helix-aoai-20260729-5b25-pingcap-csp: roleAssignments list 400 InvalidSubscriptionId`) the moment the AOAI autogrant minted its next RG row. `sync_grants_for_integration` and `sync_roles_for_integration` (both of its scope queries — the pre-check that decides whether to auto-run scopes_sync, and the work loop) now filter to `TENANT_LEVEL_SCOPE_TYPES`; the Azure driver's `list_scope_grants` / `list_scope_roles` raise `NotImplementedError` via the shared `_require_subscription_scope` helper and `grant_access` returns `ok=False`. **Why it could never self-heal**: autogrant-minted scope rows carry `last_seen_sync_job_id IS NULL`, which scopes_sync's mark-and-sweep deliberately excludes — the row is permanent, so a bad row = a permanently red cron, not a transient error. **Rule for the next per-scope driver call you add**: if the driver puts `scope.external_id` in a provider URL path or account id, restrict the caller's scope list to `TENANT_LEVEL_SCOPE_TYPES` — and on the access-control plane, **name what you skipped**. The two IAM loops partition in Python (not in SQL like the cost crons) and return `scopes_skipped_non_tenant: ["<scope_type>:<external_id>", …]` in the sync summary plus a `logger.info`, because `scope_type` was free-form admin input on the manual-provision rescue path until 2026-07-30: a typo'd value on a REAL tenant scope would otherwise drop it out of grants sync with zero signal (nothing gets revoked — the drift sweep is gated on `synced_scope_ids` — its discovered grants just go stale forever). The other end of that hole is now closed too: `CloudScopeRequestService.manual_provision` rejects a `scope_type` outside `TENANT_LEVEL_SCOPE_TYPES` with `422 CLOUD_SCOPE_TYPE_INVALID` instead of materialising a scope row that all four fan-outs silently skip.

**`scope_type` is driver-owned and self-heals on re-discovery (2026-07-30, same day, found by the field above).** `sync_scopes_for_integration`'s `on_duplicate_key_update` now refreshes `scope_type=info.scope_type`. It had been omitted alongside the *deliberate* omissions (`status` / `budget_usd_monthly` / `alert_rules` are admin-tunable and must survive a sync) — but `scope_type` is not configuration, it is the driver's classification of what the cloud side is, and it gates all four fan-outs. Consequence of the omission, caught live minutes after the IAM filter deployed: prod scope `97a6bfd7` (Azure sub `7f070020-…`, `data-integration-team-internal-azure`) was created 2026-07-08 by `manual_provision` with a hand-typed `scope_type='azure'`, and three weeks of scopes_sync runs refreshed `display_name` / `raw_data` / `last_synced_at` around the wrong value without ever correcting it. The new IAM filter then dropped a **real subscription with 921 roles and 7 live grants** out of grants/roles sync — and 921 was 3% of that integration's 34,146-row catalog, i.e. **under** the 20% drop-tolerance floor, so the roles mark-and-sweep would have soft-deleted all 921 without tripping a guard. The one prod row was normalised by hand; any future one heals on the next cron tick. Pinned by `test_scopes_sync_self_heals_a_stale_scope_type` (mutation-verified). Two second-order effects the filter also fixes: tenant-wide drivers (`grants_scope_wide=False`, Aliyun/Tencent RAM) tag one enumeration onto `scopes[0]` and spray it across `scopes[1:]`, so an RG row in that list collected grants it does not represent; and the roles sweep's every-scope-failed / drop-tolerance denominators no longer include a scope that structurally cannot contribute roles. Pinned by [`test_rg_scope_not_iam_addressable_2026_07_30.py`](../../server/tests/test_cmdb/test_rg_scope_not_iam_addressable_2026_07_30.py).

**Sub-tenant scopes are not grantable, enforced at four points (2026-07-30)** — an `azure_resource_group` row is active, owned (by the API-key applicant) and `environment='dev'`, which satisfied every predicate of the requestable-scopes picker: it surfaced in the employee "申请云账号权限" list as `helix-aoai-… · AI API · REQ-…` with an empty role dropdown, and a grant on it could only fail at dispatch with ARM 400 — after the owner approved. The invariant now lives in (1) the picker query `GET /cmdb/scopes?requestable=true` ([`lifecycle_router.py`](../../server/app/domains/cmdb/lifecycle_router.py)), (2) the service gate `_refuse_ungrantable_scope_type` → `400 CLOUD_SCOPE_NOT_GRANTABLE` on `submit_access_request` / `owner_add_member` / their shared resurrect path (exact mirror of `_refuse_prod_self_service`; the MCP twin `submit_cloud_access_request` inherits it through the same service chokepoint), (3) the grants/roles sync loops, (4) the driver guards. `web/src/api/cloudLifecycle.ts` had documented (1) since PR #251 — the comment was aspirational until this PR.

**Natural-key contract** — `compute_fact_key` hashes the cloud-side immutable `external_id` (Azure subId, GCP projectId, Aliyun member uid, AWS linked-account, Tencent ownerUin), NOT the Helix-side `cmdb_cloud_scopes.id` UUID. The local DB id is mutable: it's only materialised after `scopes_sync` lands the row, so a fact ingested before scopes_sync caught up would hash with NULL and a re-ingest after the catch-up would hash with the new UUID → duplicate row instead of an idempotent upsert. Pinned with `test_ingest_no_duplicate_after_scopes_sync_catchup`.

**Routes** — `/api/v1/cmdb/cost/*` (mounted via [`cost_router.py`](../../server/app/domains/cmdb/cost_router.py)): `breakdown`, `resources`, `timeseries`, `forecast`, `anomalies`, `allocation-rules` (CRUD with Pydantic `extra='forbid'` PATCH, UUID-validated path param), `allocation-rules/recompute`, `backfill` (rejects reversed dates), `export.csv`. Read RBAC: `can_view_finance` (global_admin / finance / auditor / management). Write RBAC (rules / recompute / backfill): `can_manage_cloud`. Default 30-day window, max 90-day for ad-hoc reads (365 for export).

**Scope detail page FinOps Dashboard redesign (2026-05-26, frontend-only)** — admin [`CloudScopeDetailPage`](../../web/src/pages/cloud/CloudScopeDetailPage.tsx) and employee [`MyCloudScopeDetailPage`](../../web/src/pages/cloud/MyCloudScopeDetailPage.tsx) both adopt a Hero + 6-tile KPI ribbon + 9-tab layout. Four new shared components under [`web/src/components/cloud/scope-detail/`](../../web/src/components/cloud/scope-detail/): (1) **`ScopeHeroCard`** — Provider Logo + name + status/env/Owner pills + ID copy + 4 meta items + role-aware `actions` slot. (2) **`ScopeKpiRibbon`** — 6 tiles (cost-30d / budget % with progress / member / pending / alert / last sync) derived from already-loaded scope + grants + daily, zero extra round-trip. (3) **`ScopeOverviewPane`** — new default "Summary" tab, 4 summary cards + 30d mini daily ECharts + member chip cloud. (4) **`ScopeAlertsPanel`** — replaces the admin-only `SetAlertsModal` popup AND employee-only `OwnerAlertsPanel` with a single inline "Alerts" tab on both surfaces. Tab order: Summary (default) / Billing / Members · N / Pending (admin&pending>0) / Access requests / History · N / Governance / Alerts / Config. `ScopeBillingPanel` is now lazy-mounted inside the Billing tab (was previously always inline mid-page) — saves a `/billing-detail` round-trip on default landing. Indigo-600 / Slate token palette aligned with [`App.tsx`](../../web/src/App.tsx) `customTheme`. i18n keys: `cloudLifecycle.scopeDetail.*` (Hero + KPI + Overview + Tabs) bilingual. No backend / migration / MCP / RBAC / driver / cron changes.

**Per-scope month-anchored billing detail (2026-05-15)** — `GET /api/v1/cmdb/scopes/{scope_id}/billing-detail?month=YYYY-MM` mounted on the existing cmdb lifecycle router. Powers the shared `ScopeBillingPanel` component, embedded inline on both the employee detail page `/my/cloud/scopes/:id` (`MyCloudScopeDetailPage`) and the admin detail page `/admin/cloud/scopes/:id` (`CloudScopeDetailPage`). (2026-05-16: replaces the legacy `SubscriptionBillingDrawer` — the drawer cramped the layout and hid the owner-action affordances; row clicks on the employee `/my/cloud/grants` page and the admin `cloudOverview` Subscriptions tab now navigate to the detail page where the panel lives alongside transfer / add-member / alerts / settings tabs.) Single round-trip returns: scope identity + month summary (total + budget% + daily avg + linear month-end projection) + zero-filled daily trend + trailing 6-month history (always anchored at the current calendar month — independent of the `month` query, so the bar chart stays a stable long-trend context view as the user clicks around past months in the picker) + top-12 cost-by-service with "other" bucket + top-20 resources with 14-day sparkline + members list. Service module: [`scope_billing_detail.py`](../../server/app/domains/cmdb/scope_billing_detail.py). RG-typed scopes auto-route via `(integration_id, resource_group)` keyed off the shared `_RG_SCOPE_TYPES` frozenset (same routing decision `/cmdb/billing/daily` makes). AuthZ mirrors `/billing/daily` to the line — scope membership / ownership OR cloud_admin / finance / auditor; parent-subscription inheritance for RG children uses the same `status != closed` tombstone filter so a decommissioned parent can't grant residual read. Member-roster role redaction for plain employees preserves the disclosure gate `/my-subscriptions` enforces. Pinned by [`test_scope_billing_detail.py`](../../server/tests/test_cmdb/test_scope_billing_detail.py) (9 tests). **Per-resource monthly breakdown (2026-07-21)** — payload gained `resource_monthly_history: {months[6], resources[]}` (`_resource_monthly_history` in `scope_billing_detail.py`): trailing-6-month spend broken out per `(integration_id, resource_id)`, top-8 + `__other__` fold, aligned to the same window as `monthly_history` so the columns reconcile. This is the "how much did each Foundry spend each month" view — a Foundry (Azure `Microsoft.CognitiveServices/accounts`) has no inventory table, so per-resource grouping over `cmdb_cost_facts` IS the canonical per-Foundry monthly source. Frontend renders it as a stacked bar + month-matrix table in `ScopeBillingPanel`; gated on `summary.breakdowns_available` (empty on the billing_daily fallback path). +1 test in `test_scope_billing_detail_fallback.py`.

**`/cmdb/my-subscriptions` month rework (2026-05-15)** — same endpoint now accepts `?month=YYYY-MM` and returns `cost_month_usd` sourced from `cmdb_cost_facts` (matching the drawer to the cent), in addition to the legacy `cost_30d_usd` from `cmdb_cloud_billing_daily` kept on the wire for MCP/script consumers that haven't migrated. Tenant scopes filter by `scope_id`; RG scopes filter by `(integration_id, resource_group)` per the standard routing. Response meta echoes `{month, month_is_partial}`.

**`/cmdb/overview/admin` cost ranking (2026-05-15)** — same endpoint now accepts `?month=YYYY-MM`. Response `subscriptions.cost_ranking` carries `{month, is_partial, fleet_total_usd, top[20]}` (each top row: `scope_id, display_name, external_id, scope_type, provider, integration_name, owner_person_id, budget_usd_monthly, usd, share`). Drives the Subscriptions tab's "top spenders" table; row click navigates to `/admin/cloud/scopes/:id` (which embeds `ScopeBillingPanel`). Deleted-scope rows keep their `usd` in the ranking (with null display fields) so the fleet total doesn't silently drop spend.

**`/cmdb/overview/admin` + `/cmdb/analytics` department filter (2026-06-03)** — both admin read endpoints now accept `?department=<org-leaf>` (exact `org_node_name` match, the same semantic the Users-list filter + `cmdb_cost_facts.cost_owner_department` use). `admin_overview_service.resolve_department_person_ids(db, dept)` resolves the leaf → the `identity_persons.id` set whose **primary** non-deleted org membership leaf equals it (returns `None` when no filter, `[]` when the dept has no members → `IN ()` narrows to zero, never leaks the fleet total). The overview endpoint narrows subscriptions/IAM by **owner** (`owner_person_id IN pids`), grants + grant-based alerts by **holder** (`CmdbCloudAccessGrant.person_id IN pids`), and the per-scope cost ranking by `cost_owner_department == dept`; the response `filter` block gained a `department` key. `CmdbService.get_analytics(department=…)` applies the same person-id set as an **additive** owner filter (composes AND with owner/integration scope, independent of the manager-scope `owner_scoped` path so billing is NOT suppressed); owner-attributable models (servers/DNS/domains/LB/CDN/IAM — anything with `owner_person_id`) narrow, the lone ownerless model today (`CmdbObjectStorage`) reports zero under the filter, and the tenant-level monthly billing trend stays department-agnostic (per-department cost lives in the billing tab's own drill-down). Frontend: global [`CloudDepartmentFilter`](../../web/src/pages/cloud/CloudDepartmentFilter.tsx) (sources `/auth/departments`, search-as-you-type Select) sits beside `CloudTenantFilter` in the [`CloudOverviewPage`](../../web/src/pages/cloud/CloudOverviewPage.tsx) toolbar and threads `selectedDepartment` into both API calls. 0 migration / 0 cron / 0 RBAC role / 0 MCP-tool. Tests in [`test_admin_overview.py`](../../server/tests/test_cmdb/test_admin_overview.py).

**`GET /cmdb/scopes` department filter (2026-06-03)** — the scope-list endpoint (powering the Cloud Overview's `ScopeLookupCard` "云子账号/订阅查询" search) also accepts `?department=<org-leaf>`, narrowing to scopes whose `owner_person_id` is in `resolve_department_person_ids(db, dept)` (empty set → `sa.false()` → zero rows; unowned scopes drop out). Reuses the same resolver as the overview/analytics filters (subtree roll-up since #709, so a parent node rolls every descendant up). Closes the gap where this lookup ignored the page's global filters while every other surface on the page honored them — the frontend now threads **both** `selectedDepartment` AND `selectedTenantId` → `SubscriptionsTab` → `ScopeLookupCard` → `cloudLifecycleApi.listScopes({department, integration_id})` (the tenant param was already supported backend-side; only the FE prop chain was missing it).

**MCP** — `cost_breakdown`, `cost_top_resources`, `cost_forecast`, `cost_anomalies` (4 tools, all read-only, gated on `can_view_finance`); plus `get_cloud_scope_billing_detail` (2026-05-15, hint 140→141) for the per-scope drawer payload (scope member/owner OR cloud_admin/it_admin/finance/auditor). Per the MCP-sync rule, parity with the HTTP surface in the same PR. **Subscription filter on drilldown (2026-07-21)** — `GET /cmdb/cost/{breakdown,timeseries}` (and the `cost_breakdown` MCP tool) gained an optional `scope_id` param: narrows to one subscription/account/project via `_scope_filter_clauses` (reuses `scope_billing_detail._fact_filter` routing, so a drilldown total for a scope agrees to the cent with that scope's billing tab). Combine with `group_by=resource_id` for the per-Foundry view inside one subscription. UUID-validated; unknown scope → 400. No new MCP tool (param add only → hint unchanged).

**Historical backfill (2026-05-15)** — one-shot `server/scripts/backfill_cost_facts.py` walks every enabled cloud integration in `_CHUNK_DAYS` (30) chunks across a configurable window (default trailing 6 months, hard ceiling ~14 months per the same retention bound the HTTP backfill enforces), invoking `sync_billing_daily_for_integration` + `sync_cost_facts_for_integration` per chunk (same idempotent ON DUPLICATE KEY UPDATE path the daily cron uses) and triggering one `recompute_allocation` over the full window at the end. `--prod` requires `HELIX_PROD_DB_URL_ASYNC` so an operator can't run prod with a stale `DATABASE_URL`. See `docs/ops/cloud-lifecycle-ops.md` for the operator playbook.

**Frontend** — [`web/src/pages/cloud/CloudCostAnalysisPage.tsx`](../../web/src/pages/cloud/CloudCostAnalysisPage.tsx) — single component with 5 sub-tabs (Overview embedded `CloudBillingPage`, Drilldown, Resources, Allocation, Forecast & Anomaly). Mounted into `CloudOverviewPage` as the existing `账单` tab so deep links survive.

**Invariants**:
- Idempotent re-ingestion (UNIQUE `fact_key` with U+0000 NULL marker); concurrent ingest doesn't dup-insert.
- Drivers MUST NOT fabricate dimensions — NULL means "the provider didn't expose this for this line item." Empty string means the provider returned an empty string verbatim (rare); analytics treats them differently.
- Drivers convert native currency to USD. **No silent fallbacks.** Aliyun + Tencent declare `requires_cny_fx=True`; the cost-ingestion service ([`sync_cost_facts_for_integration`](../../server/app/domains/cmdb/cost_service.py)) resolves the CNY→USD rate once per run via [`app.core.fx_rate.resolve_cny_per_usd`](../../server/app/core/fx_rate.py) (single source of truth = platform setting `ai_spending.cny_to_usd_rate`, refreshed weekly by `ai_spending.cny_rate_refresh`; per-integration `config_public.cny_per_usd` is an optional ops-pinned override) and passes it as a `cny_per_usd` kwarg to `list_cost_facts`. Drivers receive a resolved float — they never read FX from DB themselves (kept stateless because the sync billing-API call runs in a worker thread via `asyncio.to_thread`). If the resolver can't produce a rate the cost-ingestion service fails the run with a per-integration `failed` status; drivers self-defend with a `cny_per_usd is None or <= 0` guard for direct callers that bypass the service layer. Azure / AWS / GCP get USD natively from the provider; rows with non-USD currency AND no provider-side USD column persist with `amount_usd=0` for native-currency analytics.
- Allocation rules: at least one of `assign_person_id` / `assign_department` / `assign_cost_center` non-NULL; `match_tag_value` requires `match_tag_key`; `match_integration_ids=[]` means match-any (NOT match-none).
- Department + cost_center are denormalised onto each fact at ingest; the admin "重算分摊" button (calls `/recompute`) propagates rule edits into historical rows.
- `unallocated_usd` is informational — a SUBSET of `total_usd`, NOT an additional bucket. Frontend math: `total = sum(groups) + other_usd`.
- Out of scope (deferred): AWS CUR + Athena (>14-day historical line-item), multi-currency presentation layer, RI/Savings-Plan utilization analysis, finance-system reconciliation.

### 12.9 AI Spending

**4-bucket model (Phase 2 refactor, 2026-05-12)**: the dashboard is the sum of four physically disjoint buckets — Seats, Bailian, Foundry, Bedrock (plus a reserved Vertex bucket for GCP) — each backed by a single authoritative source so the same dollar can never appear in two buckets:

| Bucket | Source | Subject granularity |
|---|---|---|
| **Seats** | `software_monthly_bills` + asset-level `software_seat_allocations × plan price` (active month) JOIN `asset_software_catalog WHERE category='ai'` | Per-employee per-software |
| **Bailian** | `bailian_usage_daily` (nightly Aliyun BSS sync) | Account-level only — BSS gives no per-API-key split; surfaced as a `data_caveat` on the bucket card |
| **Foundry** | `cmdb_cost_facts WHERE integration=Azure AND service ILIKE ANY ai_spending.service_whitelist[azure]` | Per-RG (via `apikey_assignments.cloud_scope_id`) |
| **Bedrock** | `cmdb_cost_facts WHERE integration=AWS AND service ILIKE ANY ai_spending.service_whitelist[aws]` | Per-IAM-user (via the `aws-principal:Owner=<x>` resource_id encoding written by `aws_cloud` driver) |
| **Vertex** | `cmdb_cost_facts WHERE integration=GCP AND service ILIKE ANY ai_spending.service_whitelist[gcp]` | Per-project (when GCP integration ships) |

**Service whitelist** is a tenant-editable `platform_settings.ai_spending.service_whitelist` JSON document (per-provider list of substring patterns, case-insensitive match). Resolved at request time by [`app.domains.ai_spending.whitelist`](../../server/app/domains/ai_spending/whitelist.py) — admin edits propagate without a deploy. Aliyun is intentionally absent (Bailian is its own bucket; treating any Aliyun service as "cloud AI" would double-count). The settings page (`AiSpendingSettingsPage`) exposes the editor; payloads pass through `validate_whitelist_payload` (length / count caps; unknown provider → 400).

**Retired in Phase 2 (PR #254)**:
- `cmdb_cloud_scopes.is_ai_dedicated` column — **dropped** (migration 153). The "100% AI subscription" flag is replaced by per-service whitelist matching at the cost-fact row level. No more "everything in this Azure tenant is AI."
- `cmdb_ai_resources` + `cmdb_ai_resource_cost_daily` tables — **dropped** (migrations 151 + 152). The parallel ledger duplicated `cmdb_cost_facts` and routinely drifted; the dashboard now reads `cost_facts` directly.
- `ai_spending.azure_openai_resources_sync` scheduled_jobs row — **deleted** (migration 154). Its handler is gone; only caller was the now-dropped `cmdb_ai_resources` table.
- `PATCH /chargeback/{resource_pk}/owner` endpoint and the admin owner-override drawer — owner overrides now flow through the `cmdb_cost_allocation_rules` ladder. Per-fact reassignment is no longer a thing.

**Tables (current)**: `ai_spending_summaries`, `bailian_usage_daily`. AI cost facts live in `cmdb_cost_facts` shared with the cloud-billing dashboard — same row, two readers.

**Routes**: `/api/v1/ai-spending/*` — `overview` (returns `buckets[]` + `grand_total`), `breakdown`, `detail`, `trend`, `chargeback` (returns `AiBucketAttribution`), `summary`, `movers`, `forecast`, `by-bu`, `settings` (GET + PATCH including `service_whitelist`); `/api/v1/dashboard/ai-spending` (insights side).

**Invariants**:
- Dedup: source-of-truth for "is this a SaaS AI seat" is `asset_software_catalog.category='ai'`. Source-of-truth for "is this cost-fact row AI" is the per-provider whitelist (`service_whitelist[provider]`). The two never overlap.
- Bucket disjointness: each cost row appears in exactly one bucket. Seats live in their own ledger; Bailian rows have `integration.provider='aliyun'` which is never in any whitelist; Foundry/Bedrock/Vertex are mutually exclusive by `integration.provider`.
- LLM prompt PII: `_hash_user_id()` pseudonymises user ids before they enter the Bailian DashScope prompt (third-party). The server de-pseudonymises at render time by re-hashing known users.
- Summarization: cron `ai_spending.monthly_summary_generate` calls LLM (configurable model) with aggregated usage + cost → cached `ai_spending_summaries` (bilingual zh + en).
- CNY rate: `ai_spending.cny_rate_refresh` (Mondays 05:00 UTC) fetches the latest CNY→USD rate from **frankfurter.dev** (ECB-published, free, no auth) into `platform_settings.ai_spending.cny_to_usd_rate`. Same value backs cloud-cost USD totals via [`app.core.fx_rate`](../../server/app/core/fx_rate.py) — single source of truth across the platform. A single transient fetch failure soft-skips (back-off, retry next week); if the stored rate is older than 14 days (two missed runs) the cron raises so failure_notifier alarms — silent staleness on a value driving every USD report would be a real reporting-integrity issue. Reads from `_get_cny_rate` / `resolve_cny_per_usd` apply a plausibility band (0 < rate < 10 USD-per-CNY) to reject hijacked / fat-fingered values. Chargeback rendered in both USD and CNY.
- Read RBAC: `can_view_ai_spending` = global_admin + finance + auditor. Mutate: `can_manage_ai_spending` = global_admin only.
- BU filter (2026-05-03): every read endpoint accepts `?bu=<name>` resolving to the top-level org node via `app.identity.bu_helpers.bu_for_user_ids`. Rows without a `user_id` (dedicated-scope full bill, keyword-match) are dropped when a BU filter is set — they cannot be attributed to a specific BU. The `/by-bu` endpoint INTENTIONALLY ignores the filter (its whole point is the cross-BU comparison).
- **Phase 2 — 4-bucket refactor (2026-05-12, PR #257)** retired the per-resource attribution ladder, the `is_ai_dedicated` scope flag, the `cmdb_ai_resources` parallel ledger, and the `PATCH /chargeback/{...}/owner` endpoint. The new model identifies AI cost at the `cmdb_cost_facts.service` level via the per-provider service whitelist (migration 150); per-AK attribution is product-dispatched (Foundry by RG, Bedrock by IAM-user principal-tag, Vertex by GCP project, Bailian honest "BSS-only" caveat) by [`app.domains.apikey.spend`](../../server/app/domains/apikey/spend.py). The 4 buckets are physically disjoint by table / WHERE-clause; the dashboard's grand total is their sum, not a deduplication of overlapping sources. Earlier per-resource ladder fixes (RG-aware resolver, dedicated-scope residual split via `cost_owner_person_id`, autogrant driver-init kwarg fix, UUID schema typing) are preserved by the cost-allocation rule layer that survived the rewrite — the per-AK product helpers in [`apikey/spend.py`](../../server/app/domains/apikey/spend.py) consume those same attributions but no longer round-trip them through a per-resource table.
- Movers: `get_movers(period, dimension, limit, bu)` returns top jumpers + droppers vs `_shift_period(period, -1)`. Threshold to count as a mover: `|delta_usd| ≥ 1.0` to suppress noise. `delta_pct` is `null` when previous=0 (frontend shows "NEW" instead of ∞%).
- Forecast: linear MTD × remaining-days projection. Past months collapse to actuals (days_remaining=0). Naïve assumption acknowledged in code comment — Bailian month-end spikes typically come in 5–10% above linear.
- **Owner-attribution ladder per bucket (PR #263)** — every row that lands in `BucketRow.user_id` carries an `auto_owner_source` enum that names the step that matched. Surfaced to the drawer + chargeback so admins can tell "real personal usage" from "scope-owner fallback":
  - **Foundry / Bedrock / Vertex**: read from `cmdb_cost_facts.auto_owner_source` (stamped at ingest by the cost-allocation rule walker — `rule` / `scope_owner` / `none`). The fact's `resource_id` is also carried through to the detail-row DTO so the drawer can show the underlying Azure resource ID / AWS ARN / GCP resource link rather than just the bucket label.
  - **Bailian** (new in PR #263 — pre-fix every row had `user_id=None` and the whole bucket collapsed to "aggregate" in by-user breakdowns): `_bucket_bailian` now walks a 3-step ladder per `bailian_usage_daily` row — (a) `api_key_pk → bailian_api_keys.owner_person_id` = `api_key_owner` (highest-fidelity; BSS doesn't expose the AK dimension today but the path is ready for when DashScope per-AK rows materialise), (b) `aliyun_account_id → cmdb_cloud_scopes.external_id` (scope_type='aliyun_account') = `scope_owner` (the canonical production case), (c) both miss = `none`. Single batched JOIN per step, no N+1. When step (a) matches, the AK's assignment_id + request_number are also carried through `BucketRow.raw` so the drawer can link the row to its AK without a second roundtrip.
  - **Seats**: always `null` source — seat allocations are 1:1 with people by construction; there's no fallback ladder.
- **Detail drawer drill-down (PR #263)** — [`AiSpendingDetailRow`](../../server/app/domains/ai_spending/schemas.py) carries `auto_owner_source` + `resource_id` + `ak_assignment_id` + `ak_request_number` + `ak_owner_label`; the frontend `DrillDrawer` renders a "归属构成" summary card at the top showing the share of each `auto_owner_source` value, and a 6-column table (date / bucket / **owner-source tag** / integration·scope·RG / service·resource / AK holder / amount). `_build_ak_link_map` JOINs Foundry rows by (integration, RG) → `api_key_assignments.cloud_scope_id` and Bedrock rows by (integration, IAM-user) → `api_key_assignments.iam_user_arn` to resolve the AK holder.

### 12.10 API Key

**Tables**: `api_key_providers`, `api_key_assignments`, `api_key_release_requests`, `apikey_assignment_members`, `apikey_budget_alerts_dedup`, `apikey_expiry_alerts_dedup`.

**Routes**: `/api/v1/apikey/*`.

**Self-service release flow** (Helix-internal approval, 2026-05-20 rewrite): employee submits `POST /apikeys/assignments/{id}/release-request` → inserts an `api_key_release_requests` row in `pending`, parks the assignment in `pending_release`, pushes a Feishu / email PUSH notification (not approval card) to `requester.manager_person_id` linking to `/my/todos?tab=apikey-release`. The manager (or any global_admin) clicks Approve / Reject in the Helix Web UI (or via MCP) — calls `POST /apikeys/release-requests/{id}/approve|reject`. Approve synchronously runs `ApiKeyService.revoke_key(actor_role=self_with_approval, current_user=requester)`; reject reverts the assignment to its pre-release status. No Feishu approval template, no callback round-trip — admins do NOT need to configure a `flow_key=api_key_release` mapping. Approval / cancel / list / detail endpoints are gated by `check_sensitive_endpoint_rate_limit` for defence-in-depth.

**Apply-approval visibility surface** (2026-06-17, **MCP-synced**, hint 308→309): the API-Key *apply* (申请) approval is decided in the Feishu「审批」app (`approval_type=api_key_request`), so it never lived in the Helix「我的待办」page — only API-Key *release* did, which confused approvers ("我有待办但列表为空"). `GET /api/v1/apikeys/apply-approvals` (`ApiKeyService.list_pending_apply_approvals_for_approver`) scans `pending_approval` assignments, resolves each `detail.feishu.instance_code`'s live pending-approver set (`FeishuApprovalClient.list_pending_approver_emails`, same call 催办 uses) and returns only the applies **待我审批** (scan cap 80 / Feishu concurrency 5, best-effort degrade). Read-only — the decision still happens in Feishu (no approve/reject in Helix, per the no-third-party-approval decision); Feishu's `create_instance` returns no per-instance web URL, so the tab shows full apply details + a "请在飞书审批处理" pointer rather than a fabricated jump link. Backs the「API Key 申请审批」tab on [`MyTodosPage`](../../web/src/pages/my/MyTodosPage.tsx) + MCP `list_my_pending_api_key_apply_approvals`. Related 催办 fix same PR: [`request_detail.py`](../../server/app/domains/me_universe/request_detail.py) `_detail_employee` now populates `feishu_instance_code` from `detail.feishu.instance_code` (was hardcoded `None`), so a 催办 on an `employee_request` (software seat / API-key apply / asset) notifies the **current Feishu pending approver** instead of broadcasting to manager + all IT admins, and points the recipient at Feishu审批 instead of the requester's own `/my/requests` page.

**Invariants**:
- Request → approval workflow. On approval, assignment status → `active` + `provisioning_task` created (if auto-grant configured). Admin fills `key_value` manually OR auto-grant driver populates.
- Auto-grant: AWS Bedrock ([`bedrock_autogrant.py`](../../server/app/domains/apikey/bedrock_autogrant.py)) creates dedicated AWS IAM user + access keys; Aliyun Bailian ([`bailian_autogrant.py`](../../server/app/domains/apikey/bailian_autogrant.py)) creates RAM user. Both bind via `iam_user_id`, `iam_user_arn`, `iam_integration_id`, and `cloud_scope_id` for chargeback. Azure OpenAI / Microsoft Foundry ([`azure_openai_autogrant.py`](../../server/app/domains/apikey/azure_openai_autogrant.py)) is *RG + Foundry-project-scoped* — every approved request mints (1) a fresh `Microsoft.Resources/resourceGroups` under the standing PingCAP Azure OpenAI subscription (`7bc8ab17-59fb-44c1-92ca-61fd2b814735`, override via `cloud.azure_openai.default_subscription_id` platform_setting), (2) a Microsoft Foundry **resource** inside it (`Microsoft.CognitiveServices/accounts` of `kind=AIServices`, `allowProjectManagement=True`, `SystemAssigned` identity, `S0`, api-version `2025-06-01`) — Microsoft consolidated the legacy `kind=OpenAI` "Azure OpenAI Service" surface under Foundry; the autogrant minted that legacy shape briefly in PR #233 (2026-05-10) and was replaced with the AIServices+project shape later that day to match the Foundry portal's "Create a project" wizard, (3) a Foundry **project** (`Microsoft.CognitiveServices/accounts/{name}/projects/{project}`, api-version `2025-06-01`) underneath, then polls each parent until `provisioningState=Succeeded`, reads `KEY1` from the parent account (Foundry projects share keys with the account — the project name is part of the inference URL, not the auth material), and stores the bundle. Bundle ships dual SDK env vars: `FOUNDRY_PROJECT_ENDPOINT` + `FOUNDRY_API_KEY` (Foundry `azure-ai-projects` SDK constructor — no env-var auto-discovery, names match Microsoft's docs convention) **and** `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT` + `AZURE_OPENAI_API_VERSION` (legacy `openai` python `AzureOpenAI` — DOES auto-discover; sourcing the bundle is enough). The new RG is mirrored as a `cmdb_cloud_scopes` row of type `azure_resource_group` (migration 141 adds the `parent_scope_id` self-FK; subscription is the parent) with `is_ai_dedicated=True` and `owner_person_id=<applicant>`; `apikey_assignments.cloud_scope_id` FKs to it. A `cmdb_cost_allocation_rules` row is auto-written (priority 10) keyed on `match_integration_ids=[<sub integration>]` AND `match_resource_group=<rg name>` → `assign_person_id=<applicant>` so every Azure cost line item under the RG attributes back to the applicant from the next `cmdb.cost_facts_sync` cron tick onward. SP needs `Cognitive Services Contributor` + `Contributor` at the subscription scope; missing permissions surface as a 403 in `detail.azure_openai.error` and the assignment stays in `approved` for manual fill. **Race-defence (added 2026-05-10 round-2 audit)**: provider-name match is whitespace-normalised via [`normalize_provider_match_name`](../../server/app/domains/apikey/models.py) (`re.sub(r"\s+", " ", name.lower()).strip()`) so admin typos like `'Azure  OpenAI'` (double space — caused the AK-20260509-EF8A prod incident), NBSP, full-width space, or tab don't silently bypass autogrant — same fix applied to Bedrock + Bailian matchers. Every error branch clears `in_flight=False` so transient ARM 503s don't permanently wedge the assignment (deterministic naming → ARM PUT idempotent on retry); the `in-flight-needs-manual-recovery` short-circuit is reserved for the genuinely-stuck case of a mid-call **process crash** (no `error` field written). Concurrent-callback `cmdb_cloud_scopes` UNIQUE collisions resolve via SELECT-then-INSERT pre-check that reuses the existing scope row if found.
- Auto-grant post-actions (2026-05-06): both flows now (a) call [`upsert_local_iam_user`](../../server/app/domains/cmdb/iam_upsert.py) so the freshly-minted IAM/RAM user appears on `/cloud/iam-users` immediately rather than waiting for the next IAM sync (idempotent against the sync job's `(integration_id, user_name, sub_account_id)` UQ), and (b) dispatch a `apikey.ready` notification via `NotificationService.dispatch` to the assignee (station inbox + Feishu card + email, durable via `notification_outbox`; failure is logged but does not roll back the durable cloud+DB state). The `apikey.ready` template lives in `_DEFAULT_TEMPLATES`. Bailian `key_value` is now encrypted at rest via `_encrypt_key_value` (parity with Bedrock; the legacy `_decrypt_key_value` plaintext fallback covers historical rows).
- **Auto-grant failure is never silent** (2026-08-19, 0 migration / 0 API / 0 MCP) — every failure branch in the three auto-grant helpers routes through [`autogrant_alerts.record_autogrant_failure`](../../server/app/domains/apikey/autogrant_alerts.py): audit row `api_key.autogrant_failed` (`kind` + `stage` + scrubbed error) plus one notification card (`apikey.autogrant_failed` template) to **every active global_admin and nobody else** — the requester is deliberately not a new recipient, since they cannot act on it and their `apikey.ready` mail still arrives once IT fills the key. Before this, a failed grant wrote `detail.<kind>.error` and returned a status string that no caller read, no page rendered and no channel carried: the assignment sat `approved` with a NULL `key_value` while 「我的申请」 showed 已通过. That is how the Bailian workspace-resolution break (below) ran for four days before a human asked why a colleague had no key. Failure branches also `commit()` before alerting — several used to leave the diagnostic in a session the caller might never flush. Best-effort throughout: an alert that raises must not turn a fillable-by-hand grant into a 500 on the Feishu callback (which Feishu retries).
- Reveal: `POST /reveal` decrypts `key_value` (validity check: `valid_from ≤ now ≤ valid_until`). Employee-facing surface is the Reveal modal in [`UserItUniverseTab.tsx`](../../web/src/pages/admin/UserItUniverseTab.tsx) on `/me?tab=apiKeys` — parses the `KEY=VALUE` bundle into a Descriptions table with copy buttons + a default-open Collapse "How to use these credentials" panel that switches on `provider_name` (Bailian → DashScope SDK + ENV snippets + explicit "no console" warning; Bedrock → boto3 snippet pinned to `ap-southeast-1`; generic fallback for legacy admin-filled `sk-...` tokens). Per-cloud setup guides live under `/help#bailian-employee` / `/help#bedrock-employee` / `/help#ai-api-key-manual` (anchor IDs are also referenced from the autogrant notification body).
- Unified self-service: `employee_request_id` backlinks to `employee_requests` (migration [`030_unify_employee_requests.py`](../../server/alembic/versions/030_unify_employee_requests.py) + [`031_backfill_api_key_employee_requests.py`](../../server/alembic/versions/031_backfill_api_key_employee_requests.py)).
- PAT expiry warning cron: `auth.pat_expiry_warning` (handles both PATs and API keys).
- **Per-Key MTD spend endpoint** (2026-05-12, rewritten — PR #251) — `GET /api/v1/apikeys/assignments/{id}/spend?month=YYYY-MM` returns `{bucket, data_available, data_caveat, mtd_usd, budget_usd, percent, threshold_state, daily_series, last_fact_date}`. The query dispatches per upstream product, anchored on the AK's bindings:
  * **Foundry** (`Azure OpenAI` provider) — sums `cost_facts WHERE integration_id=AK.cloud_scope.integration_id AND resource_group=AK.cloud_scope.external_id AND service ILIKE '%Foundry%'|%OpenAI%'`. Captures only the AK's dedicated RG; infra cost in the RG (storage / network / monitoring) is NOT counted — that flows to the RG owner via the cloud billing dashboard.
  * **Bedrock** (`AWS BedRock` provider) — sums `cost_facts WHERE integration_id=AK.iam_integration_id AND service ILIKE '%Bedrock%' AND resource_id LIKE 'aws-principal:Owner=<iam_user>|%'`. The synthetic resource_id encodes the AK's IAM user name (parsed from `iam_user_arn`); requires the cost-allocation tag `Owner` to be activated in AWS Billing console — when not, the AWS driver writes `(unallocated)` and the query correctly attributes $0.
  * **Vertex AI** (`Google Vertex AI` provider) — sums `cost_facts WHERE integration_id=AK.cloud_scope.integration_id AND scope_id=AK.cloud_scope_id AND service ILIKE '%Vertex AI%|%Generative AI%'`. GCP cost ingest stamps the project as `scope_id`, not `resource_group`, so the match is integration + service + scope.
  * **Bailian** (`Alibaba Cloud Model Studio`) — returns `data_available=False` with a structured caveat. Aliyun BSS exposes Bailian cost only at the account level; per-key data is not technically resolvable, and the panel must render the caveat rather than a misleading $0.

  **Auth**: applicant themselves OR `can_manage_assets` (asset_admin / it_admin); anyone else 403. **Per-AK semantics**: each AK answers for itself — pre-PR-251 the panel summed every fact whose `cost_owner_person_id` matched the applicant, which conflated unrelated scope-owner-fallback attribution into a single key's bar and produced the NexusCRM AK-20260426-204D 127.5% false-positive over-budget incident (May 2026 prod, RG had zero usage; applicant happened to own two unrelated Azure / GCP subscriptions whose entire $510 of Microsoft Fabric / GCP project spend was bucketed under his Azure Foundry AK). The new bucket dispatch isolates each AK to the cost it actually drives.

  Threshold ladder: `ok` < 80%, `warning` 80%–<100%, `over` ≥100%, `unknown` when `data_available=False` (panel renders caveat instead of percent bar). `daily_series` is zero-filled across the month. Shared helper [`compute_assignment_spend`](../../server/app/domains/apikey/spend.py) backs both this endpoint AND the `apikey.budget_check` cron's threshold compare — they agree to the cent by construction. Tests pinned in [`test_assignment_spend.py`](../../server/tests/test_apikey/test_assignment_spend.py) (14 cases covering each bucket × bound/unbound × empty/over-threshold). Powers the row-expand `<AssignmentSpendPanel>` on `ApiKeyManagementPage` + the matching MCP tool `get_api_key_spend`.
- **Per-Key spend detail endpoint** (2026-05-18) — `GET /api/v1/apikeys/assignments/{id}/spend/detail?month=YYYY-MM` returns the same fields as `/spend` PLUS `monthly_series` (6 calendar-month totals oldest-first, current month is MTD-to-`today_shanghai`) and `by_model` (per-`cmdb_cost_facts.usage_type` rows for the requested month, sorted by `mtd_usd` desc, columns: `model, mtd_usd, usage_quantity, usage_unit, effective_unit_price_usd, percent_of_month`). The same filter recipe via [`_resolve_bucket_filter`](../../server/app/domains/apikey/spend.py) drives both `/spend` and `/spend/detail` — sum-of-rows in `by_model` reconciles to `mtd_usd` to the cent (regression pinned by `test_detail_mtd_matches_basic_endpoint`). `effective_unit_price_usd = mtd_usd / usage_quantity` is a billing-derived average rate (input/output/cache-hit SKUs land on different rows; units differ; this is NOT the official per-token list price) — `null` when quantity is zero. NULL `usage_type` rows collapse to `"(uncategorised)"`. Buckets that can't resolve per-AK numbers (Bailian, missing IAM, missing RG, unknown provider) return empty `by_model` + 6-row zero-filled `monthly_series` so the UI chart still has a domain. Powers the new [/me/api-keys/:assignmentId](../../web/src/pages/my/MyApiKeyDetailPage.tsx) detail page (dual ECharts: daily area for selected month + 6-month bar) plus the model-breakdown table; admin [`/admin/assets/api-keys`](../../web/src/pages/asset/ApiKeyManagementPage.tsx) request_number column links to the same page. MCP tool `get_api_key_spend_detail` ships in the same PR (`_REGISTERED_TOOLS_COUNT_HINT` 148 → 149).
- **Cost-owner column UI** (2026-05-11) — `ApiKeyManagementPage` renamed the "IAM / RAM 用户" column to "成本归属" / "Cost Owner" with tri-state rendering: `IAM` tag (Bedrock / Bailian / Tencent / UCloud — `iam_user_id || iam_user_arn` set), `RG / 订阅` tag (Azure OpenAI auto-grant — `cloud_scope_id` set, `cloud_scope_display_name` denormalised on the response), or red `未分摊 / Unattributed` badge with hover-tooltip when neither is bound. The old header was a lie for any RG-mode key; the new header tells the operator what the column actually represents — the chargeback target. The unattributed state is the prompt for the admin "重新绑定" action.
- **Per-assignment edit (budget + notify_recipients)** (2026-05-11, migration 146; self-service render points extended 2026-05-21) — `PATCH /api/v1/apikeys/assignments/{id}` updates `monthly_budget_usd` (≥ 20) and / or `notify_recipients` (tri-state `null` / `[]` / `[<uuid>, ...]`). Authz: applicant OR `can_manage_assets`. `notify_recipients` is a JSON column on `api_key_assignments`; the `apikey.budget_check` cron reads it once per assignment per tick (no fan-out query) and dereferences against `identity_persons` at notification dispatch time so a deleted person id silently drops out. Audit row `apikey.assignment.updated` captures before / after for both fields with `actor_was_admin` / `actor_was_owner` flags. Frontend: shared [`ApiKeyEditModal.tsx`](../../web/src/components/apikey/ApiKeyEditModal.tsx) component is rendered from **four** sites: `ApiKeyManagementPage` (admin path), `UserItUniverseTab` (admin on `/admin/users/:id` + owner-self on `/me`), `MyApiKeysPage` (owner-self on `/me/api-keys`, 2026-05-21), and `MyApiKeyDetailPage` (owner-self on `/me/api-keys/:id`, lazy-fetches the assignment via `getMyApiKeys()` because the spend-detail payload doesn't carry `notify_recipients`, 2026-05-21). The modal itself renders the `suppress` switch only when `canManageAssets(useAuthStore.user)` is true — non-admin owners never see the toggle (backend 403s their `notify_recipients=[]` anyway) and get an `unsuppressNotice` info alert when viewing an admin-suppressed row (saving restores the default chain via `notify_recipients=null`, the None-branch in service.py skips the manager-chain validation). MCP tool `update_api_key_assignment` ships in the same PR per the MCP-sync rule.
- **Shared-access roster** (2026-05-25, migration 200, **MCP-synced**) — API Keys are typically issued for a team. The Owner (`ApiKeyAssignment.assigned_to`) maintains a small list of teammates on `apikey_assignment_members(id, assignment_id FK CASCADE, person_id FK CASCADE, added_by FK SET NULL, added_at)`; every listed person gets read access to the same surfaces the Owner sees — `GET /apikeys/assignments/{id}/spend`, `/spend/detail`, `/reveal`, inclusion in `GET /apikeys/my`, the new `GET /apikeys/assignments/{id}/members`. Owner-only mutations (transfer / release / extend / edit budget / manage members) stay exclusively with the Owner. The unified authz helper `ApiKeyService.is_owner_or_member` is the single source of truth the read paths consult. **Owner transfer auto-handover**: when `apply_transfer` flips `assigned_to`, the old Owner is auto-added to the roster (preserves visibility through the handover) and the new Owner is removed from the roster if they happened to already be a member (avoid "owner of yourself" UI confusion). **Leaver sweep**: `LeaverBlockGuard.flip_to_left` calls `ApiKeyService.cleanup_leaver_memberships` (mirrors the `strip_person_from_alert_cc` pattern) to drop a left employee from every roster row at HRIS flip, audited per-assignment as `apikey.member.leaver_strip`. **PAT reveal carve-out**: members reveal via PAT (the shared-access workflow demands it); only non-member admin-on-behalf-of still requires a browser session. New audit actions: `apikey.member.add`, `apikey.member.remove`, `apikey.member.leaver_strip`; existing `apikey.key.reveal` audit detail gains `actor_role` (owner / member / admin) so forensic replay can attribute reveals. HTTP trio `GET / POST / DELETE /apikeys/assignments/{id}/members[/  {person_id}]` (owner-only POST/DELETE, sensitive-endpoint rate-limit); MCP trio `list_api_key_members` / `add_api_key_member` / `remove_api_key_member` (`_REGISTERED_TOOLS_COUNT_HINT` 175 → 178). Frontend: new [`ApiKeyMembersCard`](../../web/src/components/apikey/ApiKeyMembersCard.tsx) mounted on `MyApiKeyDetailPage` between charts and model-table; `MyApiKeysPage` gains a `viewer_role`-driven 身份 / Role column (Owner / Member Tag) and hides Owner-only action buttons on Member rows. Response schemas extended: every `ApiKeyAssignmentResponse` now carries `members[]` + server-resolved `viewer_role` (`owner` / `member` / `admin` / `none`); `GET /spend/detail` also carries `viewer_role` so the detail page can gate the "manage members" Card without a second fetch.
- **Fleet analytics / Reports tab** (2026-06-08, bounded-query spend projection 2026-08-02, **MCP-synced**) — `GET /api/v1/apikeys/analytics?month=YYYY-MM` ([`analytics.py`](../../server/app/domains/apikey/analytics.py)) powers a third **报表 / Reports** tab on [`ApiKeyManagementPage`](../../web/src/pages/asset/ApiKeyManagementPage.tsx) (alongside Assignments + Providers). Three axes: **分布** (by provider / status / cost-attribution binding / department / top owners — cheap Python reductions over the full `api_key_assignments` population + a one-shot primary-`identity_org_memberships` department lookup, no lazy relationship loads), **费用** (committed monthly-budget pool summed over effectively active/approved keys, PLUS actual MTD spend through [`analytics_spend.py`](../../server/app/domains/apikey/analytics_spend.py), a bounded set of provider-bucket binding lookups and SQL aggregations that preserves the per-key panel/budget-cron attribution rules without per-assignment queries), **量** (6-month created/revoked trend, validity buckets 7d/30d/expired/no-expiry, extension pressure, revoke breakdown by actor-role + reason-code, shared-roster stats). Spend is bounded to the live-or-revoked-in-window subset (`active/approved/expired/pending_release` + `revoked` with `revoked_at >= month_start`); large-table reads are restricted to exact `(integration, scope/RG/profile/owner)` pairs, Owner identity is case-normalized, and query count depends on provider buckets rather than fleet size. Honesty about gaps is first-class: Bailian missing linked-key data / missing-IAM / missing-RG keys land in `spend.unresolvable_*` with a per-bucket breakdown instead of silently under-counting `total_mtd_usd`; `data_complete=false` drives an info banner. Auth: `can_manage_assets` OR `management` OR `auditor` (read-side admin, same as `/spend`). Read-only: no expiry sweep, commit, or audit row; overdue active rows are projected as expired for distribution/budget purposes and the scheduled job owns durable transition/audit. Frontend [`ApiKeyAnalyticsTab.tsx`](../../web/src/components/apikey/ApiKeyAnalyticsTab.tsx) renders ECharts donuts/bars/line + top-spender/owner/department tables. MCP tool `api_key_analytics` ships in the same PR (`_REGISTERED_TOOLS_COUNT_HINT` 251 → 252).
- **Provider-aware rebind label** (2026-05-11) — `ApiKeyManagementPage` actions column branches on `getBindingMode(record)`: `'iam'` (Bedrock / Bailian / Tencent / UCloud with `iam_user_id || iam_user_arn`) keeps the existing rebind-IAM-user modal; `'scope'` (Azure OpenAI auto-grant with `cloud_scope_id` only) renders a read-only "重新绑定 RG / 订阅" affordance whose tooltip points to `/cmdb` for scope-owner reassignment (no rebind-scope endpoint ships in this PR — scope re-binding is a CMDB scope-owner concern, not an IAM-user swap); `'none'` (pure SaaS, OpenAI direct) hides the rebind icon entirely. Pre-PR the column rendered "重新绑定 IAM 用户" unconditionally — a lie for every Azure OpenAI row in prod, and the modal it opened was useless for an RG-mode key.

### 12.11 Bailian (Aliyun DashScope)

**Tables**: `bailian_workspaces`, `bailian_api_keys`, `bailian_usage_daily` (per-account granularity from [`087_bailian_usage_per_account.py`](../../server/alembic/versions/087_bailian_usage_per_account.py)).

**Routes**: `/api/v1/bailian/*`.

**Invariants**:
- Workspaces + API keys synced via Aliyun OpenAPI by cron (`bailian.workspace_sync`, `bailian.api_key_sync`).
- Usage from Aliyun BSS Billing API (`bailian.usage_sync`): one row per `(date × model × aliyun_account_id)`. Token counts often 0 (BSS doesn't expose them); rely on `cost_*` fields.
- Owner attribution: workspace + API key both have `owner_person_id` (changeable).
- **Which workspace auto-grant mints into** (resolution order, hardened 2026-08-19). intl ModelStudio `CreateApiKey` is workspace-scoped, so the grant MUST resolve exactly one: (1) `cloud.bailian.default_workspace_id` platform setting — a configured id matching nothing **fails loud** rather than falling through to another project; (2) `BailianWorkspace.is_default=True` (two or more ⇒ ambiguous, fails); (3) **the workspace in the integration's own Bailian region** (`bailian_region` / `default_region_for_provider`) — the same region value `create_api_key` / rotation / revoke already route on; (4) the single workspace when there is only one; else fail, naming every candidate (`ws-a(ap-southeast-1), …`) in the error. Rule 3 exists because ModelStudio is **per-region** and each region carries its own 「默认业务空间」: when the per-region sync (PR #2772, 2026-08-15) started discovering cn-beijing + us-east-1 alongside ap-southeast-1, the tenant went from one workspace row to three, rule 4 stopped firing, and **every Bailian auto-grant died silently** until AK-20260819-346E was noticed four days later. `is_default` is **Helix-owned**: intl `ListWorkspaces` returns no `isDefault` field, so `sync_workspaces` deliberately does not write the column on update — it used to, resetting the admin's choice to False on every nightly run, which meant "mark one as default" undid itself within a day. `reissue_bailian_api_key` uses the same resolver, and falls back to `detail.bailian.user_name_attempted` for the RAM user name so a grant that died after CreateUser does not stamp `iam_user_arn` with a phantom `bailian-reissue`.
- **`PATCH /api/v1/bailian/workspaces/{id}/default`** (2026-08-19, cloud_admin + `require_browser_session`) — the only surface that writes `is_default`; exclusive per integration (setting one clears its siblings, since two defaults read as ambiguous to the resolver). Audit `bailian.workspace.default.update` records the cleared siblings. Frontend: 「自动发放默认」 column + 设为默认 / 取消默认 on [`BailianAdminPage`](../../web/src/pages/bailian/BailianAdminPage.tsx). Before this the flag was reachable only by hand-editing the DB, which made the auto-grant error's own remediation advice ("mark exactly one `is_default=True`") unactionable.
- Soft delete via `deleted_at`.
- **存量纳管 (adopt orphan keys, 2026-06-17)**: a Bailian key created directly in the Aliyun console (or any path that bypassed the Feishu 申请 flow) lands in `bailian_api_keys` with `assignment_id IS NULL` — it has an owner once an admin pins one via 编辑负责人, but no `api_key_assignments` row, so it is invisible in the central "API Key 管理" registry **and** in the owner's "我的 API Key" self-service view. [`app/domains/apikey/bailian_adopt.py`](../../server/app/domains/apikey/bailian_adopt.py) `adopt_orphan_bailian_keys()` mints an `ApiKeyAssignment` (`status='active'`, `request_number='AK-ADOPT-<apiKeyId>'`, `detail.source='adopted'`, `key_value`=NULL since Helix never held the console key's plaintext) for every live owned-but-orphan key and back-links `bailian_api_keys.assignment_id`. Runs as the tail step of `bailian.api_key_sync` (pure DB reconciliation, isolated so a failure doesn't lose the sync result) → durable against future console-created keys. Idempotent on the `request_number` unique key + the back-link; a lost back-link is re-attached. Owner-less orphans are skipped (counted in `skipped_no_owner` so admins know to pin an owner). The "存量纳管" gold tag in the FE (`apikey.admin.adoptedTag`) marks adopted rows so nobody mistakes them for approved requests. **Revocable**: the adopted row's `detail.bailian` carries a delete-handle (`adopted=True` + numeric `api_key_id` + workspace external id + `integration_id`) so 回收 (`_revoke_cloud_resources`) actually calls `DeleteApiKey` in Aliyun — its condition is `api_key_id and (granted or adopted)`. It deliberately omits `iam_user_name`: a console key's RAM sub-account is shared across many keys, and the RAM-teardown branch stays gated on `granted`, so revoking an adopted key deletes only that key, never the shared sub-account. A `pass-2` self-heal in the adoption reconciler backfills `detail.bailian` onto already-linked adopted rows that predate the handle (`enriched` counter). **Registry-retire on revoke (2026-06-18, 0 migration)**: 回收 deleting the real key on Aliyun ALSO retires the `bailian_api_keys` mirror row — after `DeleteApiKey` succeeds, `_revoke_cloud_resources` calls [`BailianService.mark_api_key_deleted_on_revoke(integration_id, api_key_id)`](../../server/app/domains/bailian/service.py) in the SAME transaction as the status flip, setting `status='deleted'`/`deleted_at=now` keyed on `(integration_id, api_key_id)` and recording the result in the revoke audit (`cloud_revoke.attempted` `kind='bailian.api_key.registry'`). This hook is load-bearing because `sync_api_keys` deliberately NEVER soft-deletes `assignment_id IS NOT NULL` rows (autogrant rows carry RAM AccessKey IDs absent from the ModelStudio listing), so without it a revoked adopted key would linger `active`/un-deleted in the registry forever after its upstream key is gone. Idempotent: missing row → `matched=False` (no-op, revoke still completes); already soft-deleted → `already_deleted=True`.

### 12.12 Me Universe

Read-only operational aggregation endpoint `/api/v1/users/{user_id}/it-home` (`user_id=me` for self). It retains the indexed operational joins for assets, seats, permissions, API keys, mail groups, and cloud grants. The complete lifecycle catalogue is deliberately a second, on-demand read: `GET /api/v1/users/{user_id}/holdings` returns `holdings` / `holdings_failed_types`, grouped in the UI as access in use, resources/services accountable to the person, and equipment. This prevents an everyday graph load from triggering the exhaustive mover/offboarding scan, while both routes share target resolution and `MeUniverseService.assert_can_view`. The shared read passes through `core/coordinators/person_holdings_coordinator.py`, so Me Universe never imports a lifecycle domain directly; scan failures are data, not an implicit empty result. Powers `/me` and `/me/:userId` (admin/auditor/manager view).

**Assistant/MCP companion (2026-08-30):** `get_my_it_inventory` is the self-scoped, read-only AI entry to that same catalogue. It returns `access_in_use`, `accountabilities`, `equipment`, and `failed_categories`; the latter must be presented as an unavailable data category, never as zero holdings. The Feishu assistant discovers it from the ordinary read-only MCP bridge, preserving MCP runtime identity, audit, result scrubbing, and the no-write filter rather than taking a second direct database path. `_REGISTERED_TOOLS_COUNT_HINT=543`.

**我的 IT 足迹 (personal recap)** (2026-06-21, 1 endpoint / +1 MCP → hint 322 / 0 migration). `GET /api/v1/me/footprint` ([footprint.py](../../server/app/domains/me_universe/footprint.py) `build_my_footprint`) — the warm, story-shaped counterpart to the utilitarian IT-home. **Self-scoped** (no target param → no IDOR), read-only, cross-domain aggregate over existing tables: tenure (`hire_date`, days/years in `resolve_timezone(person)` local date, `join_rank` = ordinal among everyone who joined strictly earlier — **historical truth incl. leavers** so old-timers keep their place — and current non-left headcount), devices (total-ever from `asset_operations`+`asset_history` held actions, current count, first device, longest-held current device; **never names a previous owner** by product decision), software seats (current active + distinct-plan ever), active permissions, support tickets (total / resolved / first-asked), and a curated milestone timeline (join → anniversary marks 1/2/3/5/7/10/15/20 → first device → first ticket). Milestone copy is language-agnostic (`title_key`+`params`). **MCP** `get_my_it_footprint` (1:1, read). **Frontend**: pull-only page [`FootprintPage.tsx`](../../web/src/pages/me/FootprintPage.tsx) at `/me/footprint` (entry in the **avatar dropdown menu**, moved off the top-level sidebar 2026-06-22) — distinct indigo "journey" palette reusing `celebrationTheme` SHADOWS/RADIUS, hero + equal-height stat tiles + first/longest device cards + milestone timeline; blank-slate states never dead-end. First of the "有温度/有爱" employee-experience series.

**装备图鉴 (device gallery)** (2026-06-21, 1 endpoint / +1 MCP → hint 323 / 0 migration). `GET /api/v1/me/device-gallery` ([device_gallery.py](../../server/app/domains/me_universe/device_gallery.py) `build_my_device_gallery`) — collection-card view of every device the caller has ever held (current + returned). **Self-scoped**, read-only; never names a previous owner. Service tenure per device is computed by replaying the device's `asset_operations` ownership trail (confirmed assignment/transfer/claim/return/disposal) as a **state machine** that tracks the current holder and sums every interval where it was the caller's — so multi-stint ownership (assigned → returned → re-assigned) counts both; **borrow events are excluded** from span math (a loan doesn't change ownership). The open final interval extends to now only when the live row still says it's the caller's (`_aware()` coerces MySQL naive-on-load datetimes to UTC so spans never mix naive/aware). A playful rarity tier (rookie <1y / veteran 1-3y / elder 3-5y / legend 5y+) is earned by tenure, self-only (no ranking). **MCP** `get_my_device_gallery` (1:1, read). **Frontend**: pull-only page [`DeviceGalleryPage.tsx`](../../web/src/pages/me/DeviceGalleryPage.tsx) at `/me/devices` (entry in the **avatar dropdown menu**, moved off the top-level sidebar 2026-06-22) — collector summary band + card grid (category emoji, rarity ribbon, service time, in-use/returned tag), current devices first; cross-linked from the footprint's device section. Second of the "有温度/有爱" series.

**入职同期 (cohort)** (2026-06-21, 1 endpoint / +1 MCP → hint 324 / 0 migration). `GET /api/v1/me/cohort` ([cohort.py](../../server/app/domains/me_universe/cohort.py) `build_my_cohort`) — the colleagues who joined Helix on the caller's exact `hire_date`, a gentle "you didn't arrive alone". **Self-scoped** (resolved from the caller's own hire date). Active same-day colleagues are listed (name / employee_no / department / avatar); departed cohort-mates are **counted, not named**. `is_anniversary_today` lights up on the cohort's shared MM-DD anniversary (Feb-29 → Feb-28 on non-leap, celebration-scanner convention). No new exposure — same-day names are already in the org directory. **MCP** `get_my_cohort` (1:1, read). **Frontend**: pull-only page [`CohortPage.tsx`](../../web/src/pages/me/CohortPage.tsx) at `/me/cohort` (no nav leaf — reached via a "入职同期 →" link beside the footprint's join-rank badge) — cyan hero (amber on the anniversary) + member avatar grid; solo/leaver states stay warm. Third of the "有温度/有爱" series.

**入职第一天仪式感 (Day-1 welcome)** (2026-06-21, 1 endpoint / +1 MCP → hint 325 / 0 migration). `GET /api/v1/me/welcome` ([welcome.py](../../server/app/domains/me_universe/welcome.py) `build_my_welcome`) — a new hire's "welcome aboard" page wrapping the onboarding machinery in a sense of occasion. **Self-scoped**, read-only. Aggregates person basics (employee_no / job_title / work_location / department), the manager card (fetched with `selectinload(org_memberships)` so `primary_department` doesn't lazy-load during serialisation → MissingGreenlet), the gear assigned to OR reserved for the hire (`reserved_for_person_id`, the 备机闭环 earmark — the unboxing moment), an onboarding snapshot reusing `team_service._pending_blocker_labels` (single-sourced blocker classification), and starter signals (seat/permission counts, has_avatar). `is_new` (hired within −90…+30 days) lets the frontend dial up the ceremony. **MCP** `get_my_welcome` (1:1, read). **Frontend**: page [`WelcomePage.tsx`](../../web/src/pages/me/WelcomePage.tsx) at `/me/welcome` (no top-level leaf; reached from the **我的入离职** page `MyLifecyclePage` welcome banner, merged 2026-06-22) — pink/violet hero, a **one-time per-session confetti** for recent hires (reuses the easter-egg `celebrate()` + `oncePerSession`, reduced-motion respected), basics + manager + gear-unboxing + first-week checklist (ticks from starter signals) + explore chips; tenured staff get a quieter "where you started" recap. Fourth of the "有温度/有爱" series.

**Helix 此刻 (employee pulse) — REMOVED 2026-06-22.** Originally shipped (2026-06-21) as an ambient non-sensitive 广场 strip (`GET /api/v1/dashboard/pulse` `build_employee_pulse` + `HelixNow.tsx` + MCP `get_helix_pulse`). Removed per product decision — "Helix 此刻" is now **leadership-only** (the 运管驾驶舱 below). The endpoint, `build_employee_pulse`, the `HelixNow.tsx` strip, the `get_helix_pulse` MCP tool, and the `helixNow.*` i18n were all deleted; only `build_cockpit` / `GET /dashboard/cockpit` / `get_helix_cockpit` / `CockpitPage` remain.

**Helix 此刻 · 运管驾驶舱 (cockpit)** (2026-06-21, 1 endpoint / +1 MCP → hint 327 / 0 migration). `GET /api/v1/dashboard/cockpit` ([app/dashboard/pulse.py](../../server/app/dashboard/pulse.py) `build_cockpit`) — the leadership/IT full-picture counterpart to the employee 广场 pulse. **Admin-gated** (`global_admin` / `management`; the router + MCP tool both enforce it) since it includes cost. Six labelled metric sections: people (headcount / new this month / onboarding+offboarding in progress / leavers blocked), assets (total / assigned / idle / held-by-leaver / warranty expiring 30d), cost (active seats + monthly software spend, summed across active seats by plan billing_cycle), cloud (servers / domains), ops backlog (provisioning open / open transfers / open tickets), compliance (open incidents). Every metric carries a `label_key` + `tone` (neutral/good/warn/bad). **MCP** `get_helix_cockpit` (1:1, read, admin-gated). **Frontend**: [`CockpitPage.tsx`](../../web/src/pages/admin/CockpitPage.tsx) — meets the leadership-page bar: equal-height cards, every number labelled inline (口径平铺, no bare number grids, no hidden-in-hover), tone-coloured, money via `formatMoney`. **Merged into 运营洞察 2026-06-22** (user feedback — "no need for its own 二级菜单"): no longer a standalone ops-cockpit leaf; instead rendered as the **first tab「Helix 此刻」of [`InsightsPage`](../../web/src/pages/admin/insights/InsightsPage.tsx)** (`/admin/insights?tab=cockpit`) via a new `embedded?: boolean` prop (suppresses its own `PageHeader`, shows a lightweight 更新于… line; the cockpit tab is excluded from the range Segmented + export button). Legacy `/admin/pulse` route now `Navigate`-redirects to `/admin/insights?tab=cockpit`. Sixth of the "有温度/有爱" series.

### 12.13 Auditor

**Tables**: `auditor_mfa_status`, `auditor_privileged_review_cycles`, `auditor_privileged_role_holders`, `auditor_control_status`, `auditor_public_exposure_finding`, `auditor_secret_rotation_status`, `auditor_signin_event`, `auditor_pat_audit_finding`, `auditor_cert_expiry_record`, `auditor_risk_score_history`, `auditor_evidence_packs`. Plus seeded controls + first-run job triggers from migrations [`093`](../../server/alembic/versions/093_security_auditor.py) + [`094`](../../server/alembic/versions/094_auditor_quarterly_auto_open.py).

**Routes**: `/api/v1/auditor/*`.

**Sub-features (9)** + their cron handlers:
- MFA enrollment → `auditor.mfa_sync_daily`
- Privileged role review → `auditor.privileged_review_auto_open_quarterly` (Q1/Q4 auto-open)
- Public exposure scan → `auditor.public_exposure_scan_daily`
- Secret rotation → `auditor.secret_rotation_check_daily`
- Sign-in anomaly → `auditor.signin_anomaly_score_hourly`
- PAT audit → `auditor.pat_audit_daily`
- Cert expiry → `auditor.cert_expiry_check_daily`
- Risk score → `auditor.risk_score_recompute_hourly`
- Evidence packs → `auditor.evidence_pack_worker` (zips on demand)

**Invariants**:
- MFA snapshot overwritten each sync; historic state in audit log + control_status.
- Privileged review cycle materializes ALL holder rows on creation (snapshot of who held what at start). Decision flow: pending → approved | revoked. On revoke, system enforces removal + records `revocation_trace_id`.
- Read RBAC: `can_view_security`. Mutate: `can_manage_security` (auditor mutates — that's the auditor's job).
- Auditor adapters in [`integration_adapters.py`](../../server/app/domains/auditor/integration_adapters.py) translate driver capabilities → auditor data (e.g. Google MFA endpoints, Feishu MFA, AWS SSO admin list).

### 12.13.1 Security quiz (安全每日一题, 2026-06-21; qtypes + exam import 2026-06-22)

`security_quiz` domain (migration 313; **`qtype` + exam-bank import in migration 314**). A gamified daily security-awareness quiz — seventh of the "有温度/有爱" series. **Question types** (`secquiz_questions.qtype`, default `single` so legacy rows stay single-choice): `single` (one correct key), `multiple` (`correct_key` is the sorted concatenation e.g. `ABD`; an answer is correct only on an exact set match), `boolean` (true/false, two fixed options keyed `T`(对)/`F`(错)). `service._normalize_keys` is the canonical compare for both the stored answer and the correct key; `submit_answer` stores/returns the normalised key; `_validate_payload` enforces per-type rules and returns the canonical `correct_key`. Migration 314 imports 31 exam-bank questions (21 single + 6 multiple + 4 boolean, `source='import'`, idempotent). Tables `secquiz_questions` (bank: prompt + JSON options + qtype + correct_key + explanation + category/difficulty/status/source) / `secquiz_daily` (UNIQUE(quiz_date) question-of-the-day assignment) / `secquiz_answers` (UNIQUE(person_id, quiz_date) — one shot/day, drives streaks + board). Migration seeds 8 curated starter questions + an enabled `secquiz.assign_daily` cron row. [service.py](../../server/app/domains/security_quiz/service.py): daily assignment picks the least-recently-used active question (nested-savepoint race-safe; the player path lazily ensures it so a missed cron never empties the page); answering is idempotent (UNIQUE-guarded); streaks computed from consecutive correct dates; all-company leaderboard by total correct + caller rank; compliance coverage (30-day participants / headcount) as the auditor's awareness-training evidence; admin CRUD; AI generation via the org LLM connector (`BailianLLMClient.chat_json`, graceful no-op without a key). Endpoints: player `/security-quiz/{today,answer,me,leaderboard}` (any signed-in user); admin `/security-quiz/admin/{questions,questions/{id},…/status,generate,coverage}` (`global_admin`; coverage also `auditor`). Cron `secquiz.assign_daily` (imported in worker + main). **MCP**: `get_todays_security_quiz` / `answer_security_quiz` (mutate) / `get_security_quiz_leaderboard` → hint 330. **Frontend**: [`SecurityQuizPage`](../../web/src/pages/me/SecurityQuizPage.tsx) (`/me/security-quiz`, reached from the **header icon bar** SafetyCertificate icon — moved off the top-level sidebar 2026-06-22 — answer → reveal + explanation → streak, correct fires the shared celebration confetti, leaderboard) + [`AdminSecurityQuizPage`](../../web/src/pages/admin/AdminSecurityQuizPage.tsx) (`/admin/security-quiz`, under Security & Compliance — coverage band + question CRUD with a **question-type selector** (single/multiple/boolean) + AI generate). The player page renders multiple-choice as a multi-select and true/false as 对/错; the admin list shows a 题型 column. Tests `tests/test_security_quiz` (new CI bucket entry; `test_security_quiz_qtypes_2026_06_22.py` covers the new types).

### 12.14 Smaller domains (added to the map 2026-06-11)

Nine packages that shipped over time without a domain-map entry. Each follows the standard shape (router + service + models, envelope, audit, RBAC):

- **`sso`** — SAML SSO grant register + app catalog. Tables `sso_grants` (per-person grant register, Feishu-approved, leaver-handover blocker `RES_SSO_GRANT`) + `sso_apps` (the SAML app catalog — Helix never calls Google APIs for it, no public API exists; see migration 127). Cron `sso.execute_pending_google_grants` (every 5 min) materialises approved Google grants into `sso_apps` rows + IT notify. Admin page `/admin/sso-apps`. **Removal lifecycle (2026-09-05, migration 603)**: `sso_grants.removal_*` — `removal_status` ∈ `not_applicable / pending / confirmed / verified / contradicted / unverified_legacy`; `revoke()` opens a `manual_notify` provisioning task (`target_type='sso_grant'`, evidence required to complete, admins included) for the system owner (IT fallback; the offboarding bulk path marks it `notified` silently when there is no owner — R59F-C). [`sso/removal.py`](../../server/app/domains/sso/removal.py) owns the lifecycle: cron `sso.removal_reconcile` (daily 08:40 Asia/Shanghai) re-opens a missing task, reminds on the `sso.removal.reminder_ladder_days` ladder (3/7/14, then every 7; IT copied from the 2nd rung), opens a `sso_removal_overdue` compliance incident at `incident_after_days` (auto-resolved on confirmation), and for Google-IdP grants verifies against Admin Reports' SAML login audit (`GoogleWorkspaceDriver.list_saml_login_events`, scope `admin.reports.audit.readonly`): login after revoke → `contradicted` (confirmation voided, task re-opened, owner+IT paged); name-aligned history + no login ≥ `verified_after_days` after confirmation → `verified`; IdP account gone → `verified`; app name never seen in the IdP's history → `app_name_unmatched` (recorded, status unchanged). Legacy rows are `unverified_legacy` (no backfilled tasks); admins open review tasks in bulk. Endpoints `GET /sso/grants?removal=…`, `GET /sso/grants/removal-summary`, `POST /sso/grants/removal/open-tasks`, `POST /sso/grants/{id}/removal/verify`; MCP `sso_removal_summary` / `open_sso_grant_removal_tasks` / `verify_sso_grant_removal`. Self-attestation `sso_grant` items now route through this (no more `manual_required`); `/me` IT home lists SSO grants (approved + removal still open).
- **`domain_registration`** — registered-domain register (`domain_registrations`, migration 102): who registered which external domain, Feishu-approved intake, leaver-handover blocker. Distinct from `cmdb_domains` (registrar/DNS truth lives in CMDB; this is the people-accountability register).
- **`vpn_application`** — Feilian Full-Mode VPN access flow (`vpn_applications`, migration 105). Feishu-approved; deliberately **no** leaver handover (access dies with the account).
- **`announcement_request`** — company-wide announcement intake (`announcement_requests`, migration 106), Feishu-approved, surfaced under `/admin/announcements`.
- **`ai_tool_application`** — AI function/product security-review intake + public registry (`ai_tool_applications`, migration 107).
- **`feed`** — 动态广场 (migrations 235 / 238): `feed_posts` / `feed_comments` / `feed_likes` / `feed_blessings` / `feed_celebrations` / `feed_announcements` / `feed_mentions` / `feed_attachments` + `identity_person_preferences` (celebration opt-out). Birthday/anniversary celebration pipeline via 3 crons (`feed.scan_today_celebrations` / `feed.notify_celebrants_and_leads` / `feed.expire_pinned_announcements`); author edit history with audit-only previous bodies; route `/feed`.
- **`policy_notices`** — 制度中心 (migrations 254 / 271): `platform_policy_notices` — bilingual title + Feishu doc URL + 摘要/关键字, with separate publish / header-ribbon-rotation switches. Header ribbon + `/admin/policy-notices` (admin) + 制度中心 library view.
- **`feed` anonymous community extension (migration 521).** `author_visibility` is a presentation-only `named|anonymous` marker on posts/comments: normal DTOs omit anonymous author PII, but the original author FK remains for ownership and audit. Anonymous posts are text-only, company-wide and cannot mention people; comments on them are forced anonymous. `feed_reports` provides employee reporting, HR/global-admin content hiding, and a browser-session + reason + audit-gated author reveal.
- **`security_awareness`** — 安全宣贯 (migration 596): two tables. `secaw_materials` is the hosted content library — comic / article / link, up to three languages in **one `localized` JSON column** (the shape is enforced in `schemas.LocalizedContent`, `zh` required because the `app.core.locale` fallback chain terminates there); image columns hold **S3 object keys, never URLs**, presigned per read by [`presign.py`](../../server/app/domains/security_awareness/presign.py) (one resolved config + one botocore client per batch, and every key re-parsed against the awareness purpose immediately before signing — minting a presigned URL *is* the authorisation decision). `secaw_impressions` is the **append-only exposure ledger**: person × material × surface × time, carrying the `material_version` and the `lang` that actually rendered. **Language is resolved in exactly one place** — `service.pick_block` (`ja → en → zh`) returns `(rendered_lang, block, is_fallback)` and the frontend never touches the per-language map, because the ledger records what was shown and two deciders would make that record a guess. **The ledger is what makes the domain exist**: before it, "prove this employee was told about phishing" had no answer (`platform_security_tips` records nothing and its read endpoint does not even take a person_id). Three invariants protect it, each pinned by a test: a material that has ever been shown cannot be deleted (409 → archive instead), `published_at` stamps only on the FIRST publish, and only *content* edits bump `version`. The exposure for "opened the material" is written by the **read route** (`GET /security-awareness/materials/{id}`, idempotent inside a 30-minute window) rather than by a client beacon — a compliance claim that depends on the browser remembering to report is a claim that silently under-counts; an admin previewing a draft records nothing. Surfaces: employee `/me/security-learning` (+ `/:id`), mobile `/m/security-learning` (+ `/:materialId`, **the primary surface** — pinned by default in the apps catalog), admin `/admin/security-awareness` (which previews through the employee renderer itself, not a second one). Upload purpose `awareness_material` — read is workforce-wide (`WORKFORCE_READABLE_PURPOSES`), write is gated to the security desk (`_PURPOSE_WRITE_GATES`, the first per-purpose write gate in `storage_router`). 2 MCP tools, both deliberately **without** an impression write. Campaigns landed in 597 (see the note directly below); later PRs in this theme add the ambient bubble and a compliance control reading coverage off these ledgers.

> **安全宣贯 · 活动与逐人台账** (2026-09-03, migration 597 / +1 cron / +2 MCP→hint 590 / 0 新 RBAC 角色). `secaw_campaigns` + `secaw_campaign_targets` 把素材变成一次可运营、可举证的触达。**两档强度是一个布尔列**:必修走 `CAT_SECURITY`(关不掉)+ 进 `/my/todos` + 算覆盖率,运营走**新增的** `CAT_AWARENESS`(可关 / INFO 桶 / 不驱动红点)—— 两个 template_key 而非一个带 flag 的 key,因为分类是按 template_key 决定的,而分类决定员工能不能关。**受众复用 `app.core.person_filters`**(Identity Sync 推面和 Directory/SCIM 拉面同一个引擎),前端复用同一个 [`PersonFilterEditor`](../../web/src/components/PersonFilterEditor.tsx) —— 一个引擎配两个编辑器正是两个面开始对不上的方式;本域只加候选池加载器(问的是「谁该读一份安全通知」,不是「谁该被投影进下游目录」)和 `exclude_person_ids` 叠加层。🔴 为此新增窄门 [`require_person_filter_lookup`](../../server/app/core/dependencies.py)(`require_admin_read` + `security_officer`)**只给那四个 lookup 用**:`PersonFilterEditor` 把 lookup 错误 `.catch(() => {})` 吞掉,所以安全员原本会拿到一个看起来正常、部门树是空的、圈不到任何人的表单 —— 而放宽 `require_admin_read` 本身是错的方向(它还管着离职流程、建号队列、通知外发台账、集成元数据)。**发送分阶段提交**(置 `sending` 提交 → 台账提交 → `enqueue_then_drain` 逐人提交 → 回填并落 `sent`),**状态翻转本身就是锁**(带状态条件的 UPDATE)。**投递真值只从 outbox 读**,台账刻意无 `delivered_at`;管理端因此印四个数(发出/送达/打开/确认)而不是一个完成率 —— 四步失败的原因完全不同,一个百分比会把人送去修错的东西。确认幂等且时间戳不可移动;已结束的活动不再接受确认(结束时的漏斗已进审计日志);已发出的活动只能改名字与截止日期;发过的活动不能删。员工的确认控件长在**素材页本身**(桌面在内容上方 / 移动端在底部拇指区),不做活动落地页 —— 那会在推送和内容之间插一次点击。

> **安全宣贯 · 环境位** (2026-09-03, migration 598 / 0 cron / 0 新 MCP). 右下角吉祥物气泡([`ITServiceDeskMascot`](../../web/src/components/ITServiceDeskMascot/ITServiceDeskMascot.tsx))从「轮播一行纯文本、什么都不记录」升级成宣贯的**环境位**。[`ambient.py`](../../server/app/domains/security_awareness/ambient.py) 回答「这个人现在该看什么」,并给它配口粮:**每人每天一个稳定的小集合**(由 `(person, date)` 的确定性哈希派生 —— 每次加载重算会让气泡随导航乱洗牌,读起来是噪音不是信息;哈希按人不同,所以全公司不会同一个早上盯着同一张卡)、**每人每天新素材上限**(默认 1)、**同一素材对同一人的展示上限**(默认 3)、**点开过的直接出局**。「今天已展示过的」不受日上限约束 —— 上限管的是**新想法**的节奏,否则达到上限那一刻气泡会突然空掉,看起来像故障。🔴 **`GET /security-awareness/bubble` 刻意什么都不写**:浏览器收到一份轮播列表不构成「其中任何一条进过谁的眼睛」的证据,客户端在真正**渲染**时才 `POST /impressions`(与素材详情路由正好相反 —— 那边是一个人打开了页面,那个动作本身就是事件)。三层降级:素材(可点,缩略图 + 服务端挑好的一句钩子)→ 管理员写的纯文本提示 → 问候语,所以气泡永远不空、旧功能零回归。频控三个键在 `platform_settings`,**入口放在它管的内容旁边**(素材库页上的一张卡)—— 只存在于通用键值表里的阈值是没人会去调的阈值;读取端对坏值退回默认并记 warning,因为这个组件挂在每一个已登录页面上。

> **安全宣贯 · 覆盖率与催办** (2026-09-03, migration 599 / +1 cron / +1 MCP→hint 591). [`compliance.py`](../../server/app/domains/security_awareness/compliance.py) 是这个域存在的理由变成一个数字的地方。**口径**:只算**必修**且 `completion_mode='ack'` 的活动(运营档不要求任何人做任何事);分母是**当时真的发给了谁**(取自逐人台账,不是今天的在职人数 —— 三月份的活动是一个三月份的事实);整体**按人加权**不按活动平均(否则 3 人的活动和 480 人的活动等权);**百分比向下取整**(89.6 撞 90% 阈值不该被凑成通过);**发给 0 人是 `fail` 不是 vacuous pass**。控制项状态取自**最近一次**必修活动而不是历史平均 —— 平均奖励数量,五条容易的通知会淹掉一条答得很差的必修。新增框架要求 SOC2 `CC2.2` + ISO27001 `A.6.3`(此前目录里没有:标准里一直有,只是拿不出证据)。证据导出 `.../campaigns/{id}/evidence.csv` 读**快照列**(转岗走的人是以旧团队成员的身份被触达的)、姓名与邮箱**分两列**、带 BOM、对 `= + - @` 开头的单元格转义(一份证据文件是最不该在被打开时执行点什么的东西)。[`reminders.py`](../../server/app/domains/security_awareness/reminders.py) 复用 `app.notifications.cadence` 并**只拉长阶梯**(7/7/14,尾部 14 天),第一次催在发出后 7 天,三周后给主管**一张**摘要卡。🔴 催办的 template_key 必须在 `CAT_SECURITY` 的 `exact` 里:它以 `security_awareness.` 开头,会被 `CAT_AWARENESS` 前缀吞成「可关」,那样员工恰好能静音掉唯一在告诉他还有必修没做的消息 —— 上线前把四个 key 的分类打印出来逐条核对才发现。

> **安全宣贯 · 主题全量复审的沉淀** (2026-09-03, **0 迁移**). 五个 PR 合完之后的整体复审,17 处修复 / 20 条回归测试 / 全部经过变异验证。留下三条会复发的教训:
> 1. 🔴 **一条规则如果只由「按钮藏起来了」来执行,它就不是规则。** 小测「取代」确认按钮的设计在两个前端都实现了,但 `acknowledge` 端点和 `acknowledge_security_awareness` MCP 工具一直直通 —— 而 PR5 恰恰以「替员工答题产出的是假证据」为由拒绝提供答题工具,那份假证据从确认这一侧照样能造出来。闸移进 `CampaignService.acknowledge`(`quiz_verified` 只由判分通过的提交路径置位),并且**放在幂等返回之后**,这样事后给素材加题不会把已经照做的人重新变成欠着的。审新功能时固定问一句:**这条规则除了那个被藏起来的控件之外,还有什么在拦?**
> 2. 🔴 **客户端只能被信任去上报服务端真的看不见的那一件事。** `POST /impressions` 原来接受 surface 全集,包括驱动已读标记 / 参与看板 / 徽章事实的 `detail` —— 等于允许任何人不打开材料就把自己标成读过。收窄为 `CLIENT_REPORTABLE_SURFACES = {bubble}`。判据不是「这个值合法吗」,而是「**如果客户端在这里撒谎,哪些数字会跟着变**」。
> 3. 🔴 **`<a href>` 指向 `/api/` = 401,而且工具链全绿。** Helix 的 JWT 在内存里由 Axios 拦截器挂上,浏览器导航不带它。审计域踩过一次(`api/auditor.ts::downloadEvidencePack` 的 docstring 记着),安全宣贯的证据 CSV 又踩了一次 —— 这个域存在的那个可审计产物点了就是 401。已配 lint 闸 [`web/scripts/check-api-href.mjs`](../../web/scripts/check-api-href.mjs)(接进 `pnpm lint`,第 13 道):`href` / `window.open` / `location.href` 指向 `/api/`,以及**构造**这种 URL 的 `…Url(): string` 助手,都会红。
>
> 其余修复分四类:**算错的数**(点开率的分子取了全部打开者而非气泡触达者的交集 → 可以超过 100%;证据 CSV 的 `reminders_sent` 只数人工 resend、自动阶梯一次不算 → 被 cron 推过四次的人显示 0);**会 500 的守卫**(删素材只查曝光,漏了两条 RESTRICT 外键 → MySQL 1451;`deletable` 现在由服务端算,控制台不再亮出唯一结局是报错的按钮);**把人晾住的状态**(归档素材会让活动里的 stragglers 走到 404、cron 还在催 —— 下架是关于资料库的决定,不撤销已发出的义务;一次通过只销掉最急的一个活动,而小测拒绝给已通过的人重新出题,于是另一个再没有控件能清);**没有二次确认的对外动作**(resend 会给所有未确认的人再发一遍且在催办阶梯之外,是这一行里唯一没有 Popconfirm 的)。另外修掉一条**顺序敏感却没有 ORDER BY** 的测试:返回顺序取决于优化器选哪条索引,而那取决于表统计,也就是取决于同一 session 里别的测试插了多少行 —— 单跑绿、混跑红的经典形态。

> **安全宣贯 · 素材小测与载体联动** (2026-09-03, migration 600 / 0 cron / **0 新 MCP**). [`quiz.py`](../../server/app/domains/security_awareness/quiz.py):给素材挂两三道题,**答对就是确认** —— 素材挂了题,确认按钮就被小测取代,两个控件对应同一个主张等于允许一个人在不演示理解的情况下声称理解。通过标准是**全对**不是百分比(为一份短材料写的两三道题撑不起有意义的部分分,而「3 道对 2 道也算」比它取代的那个确认更弱);重试不限次、答错立刻给解释;**答案永不出服务端**。题库复用 `secquiz_questions`,判分归一化与安全每日一题域用同一套语义并有测试把两个实现钉在一起。🔴 **每一次尝试都存**,`attempt_no` 显式存一列而**不从 `created_at` 推**(秒级 DATETIME 分不出「答错→马上重来」的先后)—— 「哪几道题大量的人**第一次**答错」是安全团队最直接的改稿输入,管理端小测抽屉下半部分就是这个读数。积分经 [`awareness_points_coordinator`](../../server/app/core/coordinators/awareness_points_coordinator.py)(跨域规矩;每素材一次、金额刻意小、排在确认之后,**钱包写入在 SAVEPOINT 里** —— 裸 `except` 保证不了「不牵连确认」:failed flush 会让调用方整个事务失效,吞掉之后载着确认的那个 commit 照样抛 `PendingRollbackError`,2026-09-03 修)。徽章 5 枚,事实按 `detail` surface 去重人数计 —— 奖励被动曝光等于奖励开着一个标签页。**「阅读情况」是独立 Tab,与覆盖率刻意分开**:覆盖率是关于必修阅读的合规主张,把自愿阅读混进去就是让一份没人必须读的漫画去承担审计陈述;看板最有用的一列是**点开率**(在气泡里见过的人当中有多少点开了),而它算不出来除非曝光台账记的是**去重人数**而不是次数。🔴 另开窄的只读 `GET /admin/security-awareness/question-bank`(`can_view_security`,只列 active):挂题选择器需要题库,而 `/security-quiz/admin/questions` 的闸不含 `security_officer` —— 没有去放宽那个闸,因为那会把每日一题题库的**写**权限当副作用一起给出去。同 PR2 的 lookup 洞同源。

> 🔴 **安全每日一题 · 按员工语言出题,以及「选项 key 是契约」** (2026-09-07, migration **628**(`secquiz_questions.localized`)/ 0 cron / 0 新 MCP / 0 新 RBAC). #607 的另一半:宣贯素材当时补了语言,而**题库整个只有中文** —— 题干、选项、解析都在只装一种语言的平面列里,英文/日文读者拿到的是中文题,而他的对错进排行榜、进部门擂台、进合规证据。[`localization.py`](../../server/app/domains/security_quiz/localization.py) 是这个域现在唯一回答「用哪种语言问」的地方。**形状**:`localized = {"en": {prompt, options, explanation}, "ja": {…}}`,**中文不在里面** —— 平面列继续当基线(与 `secaw_materials.localized` 三语都在 JSON 里不同:这一列加在已有 309 行上,搬一次等于重写每一行 + 排行榜/擂台/素材小测/MCP/装备墙每一个读者;而 `correct_key` 与语言无关,必须紧挨着唯一一份权威选项表)。
>
> 🔴 **选项 key 是契约,文案不是。** `correct_key` 记的是选项 key(`"A"` / `"ABD"` / `"T"`),读者是从**我们渲染给他的那一份**列表里选的 —— 所以 key 集合与基线不一致的 block 不是这道题的译文而是**另一道题**,拿它渲染等于「用读者没见过的选项判他对错」。`block_for` 在读路径拒掉并回退基线选项(题干仍用译文,读者至少读得懂题),`validate_block` 在写路径把同一条规则前移成 422,种子侧 `seedbank/i18n_shape.build()` 直接抛 —— 三处同一条规则,判卷逻辑因此**完全不用动**。
>
> **语言由服务端决定一次**:`?lang=`(前端 i18next 当前语言)→ `Accept-Language` → 本人 `preferred_language`(`core.locale.resolve_lang`)。第三级不可省:MCP 面与将来的通知卡片都落在那里,回退中文基线就是在每个非浏览器面上把原 bug 重造一遍。客户端**拿不到 per-language map**(选项与答案键必须同源解析);缺语言回报 `is_fallback_lang`,桌面 + 移动端各渲染一句「今天这道题还没有你所选语言的版本」。素材小测走同一套解析(`MaterialQuizService.questions_for(lang=)`,`lang=None` 保持中文基线 —— 管理台与错题率报告要看的是作者写的原题,不是某个读者的视角)。
>
> ⚠️ **中文改了、译文没跟着改 = 比没译文更糟。** `update_question` 里若 `prompt`/`options`/`correct_key` 变了而调用方没显式带 `localized`,存量译文被**丢弃**(留着会让英文读者看旧题面、被新答案键判分),并记一条 info 日志。管理台因此有三个语言 tab,en/ja 的选项按基线 key 逐个渲染、占位文字是对应中文原文(让选项 C 的译文紧挨选项 C 的中文 —— 「对得上号」唯一能被人眼审出来的排法),保存时总是显式回传三种语言状态。
>
> **内容不进仓库(用户决定,2026-09-07):** 译文 / 新题 / 素材 / 问答第一版是 7 个数据模块 + 迁移 628(10,430 行数据对 1,833 行功能),改成一份仓库外的 JSON 导出(`~/.helix-content/security-content-2026-09-07.json`)+ [`scripts/load_security_content.py`](../../server/scripts/load_security_content.py):四步「不存在才写」、幂等、每条译文再过 `validate_localized`;pymysql 同步驱动,本机对 TiDB 可直跑。导出内容:309 道存量题的 en+ja(按 `prompt` 匹配,题干全库唯一)+ **28 道新题**(AI 使用/研发供应链/云/事件上报/合规隐私/出行远程,原题库这六面一条都没有)+ **12 条三语宣贯素材**(`article`,按中文标题幂等;刻意不是 `comic` —— 漫画每一页是 S3 对象,脚本造不出来)+ **35 条答复库条目**(22 员工可见 + 13 条 `employee_visible=0` 合规证据口径,带 `backing_control_keys`)。🔴 **写答复前把线上 109 条 `question_zh` 全读了一遍,初稿 13 条撞车全部删掉** —— FAQ 里的近义重复不是「更多内容」,是更差的检索。**代价**:内容不进 review 与版本历史,重建环境要多跑一次脚本。
>
> `/security-quiz/admin/coverage` 现在同时回报语言覆盖率(`per_lang_pct` / `fully_localized`,**只算 active 题** —— 归档题不会再被指派,进分母只会让缺口看起来永久更差)。与参与率同一个 payload 是刻意的:「94% 的人答了题」在其中三分之一用非母语作答时,是一句比看起来更弱的合规主张。

> **安全宣贯 · 英文漫画落地,画面不再只有中文** (2026-09-07, **0 迁移** / 0 cron / 0 新 MCP / 0 新 RBAC). #607 只能翻标题和摘要,画面原样沿用中文(模型重画不了漫画),所以线上 30 条已发布漫画对英文读者是「英文外壳 + 读不懂的画面」。`secaw_materials.localized` 本来就按语言存到画面这一层(`image_keys` / `cover_key` 在各语言 block 内,`pick_block` 解析出一个 block、只签它自己的 key)⇒ 这是**数据操作**:[`scripts/load_awareness_comic_artwork.py`](../../server/scripts/load_awareness_comic_artwork.py) 把 25 张英文长图上传到 `awareness_material` 前缀,**21 条合并**(改 en / ja 的 `image_keys` **和** `cover_key` —— 封面是卡片缩略图,只换内页会得到「英文正文 + 中文缩略图」)+ **4 条新建**(AI 工具 / 公共 WiFi / 移动介质 / 居家办公,线上无中文版)。
>
> 🔴 **映射不能模糊匹配**:25 个标题只有 7 个与线上完全一致,其余差在空格 / 标点 / 用词,而线上有**四条**不同的社交主题漫画 —— 「差不多」会把英文页挂到错的那一篇上。映射表人工核过写死在脚本里,**解析不到即报错,不跳过**。**日文指向英文画面**(用户决定;与 `core.locale.FALLBACK_CHAIN` 自己的理由一致 —— 对日本读者英文比中文近);**只有英文画面的 4 条三种语言都指向它**,zh / ja 的标题摘要另写,所以卡片读得懂、只有页面是英文。
>
> ⚠️ **刻意没动 `mt_langs`**:线上这一列现在都是空的(读者看不到机翻提示,是接手前的状态)。加回徽章现在是**错的** —— 漫画版提示说的是「图片仍为中文」,而这正是本次唯一变假的一句;要恢复得先把文案拆成「标题机翻」与「画面外语」两件事,那是代码改动。**幂等**:上传的 key 以源文件名结尾,`en.image_keys` 已以它结尾就跳过(第二遍 skipped=25)。合并后仍有 10 条漫画只有中文画面。

> **安全宣贯 · 缺失语言的 AI 补齐,以及「告诉读者这是机器翻的」** (2026-09-05, migration 607 / +1 cron / 0 新 MCP / 0 新 RBAC 角色). 员工反馈「英文系统里右下角气泡是中文的」。**先记住排查结论,别再改语言链路**:前端把 i18next 当前语言传给 `/security-awareness/bubble`,`service.pick_block` 按 `catalog.LANG_FALLBACK` 解析,`is_fallback_lang` 也一直在回报 —— 缺的是内容(30/46 条已发布素材只有 `zh`)。[`translation.py`](../../server/app/domains/security_awareness/translation.py) 用平台共享的 AI/LLM 连接器(`ai_spending.llm.api_key`,本域不要自己的凭据)把中文 block 译成缺的语言,**每种语言两次调用**:标题 + 摘要走 `chat_json`,正文走 `chat_text` —— 让模型把 markdown 塞进 JSON 字符串正是 `BAILIAN_LLM_OUTPUT_TRUNCATED` 的标准配方,而截断出来的东西长得像一份翻译。正文超 6000 字(`MAX_BODY_CHARS`,由模型 ~8k 的**输出**上限定,不是输入窗口)直接报错让人手翻。🔴 **不改中文原文、不改发布状态、不译图片** —— 漫画的 `image_keys` / `cover_key` 原样复制(同一批 JPEG),所以英文读者拿到**英文标题 + 中文画面**;这比中文标题 + 中文画面好,**但只有在告诉读者的前提下才诚实**,于是四个读取面(库卡片 / 详情页 / 移动端详情 / 气泡)都带 `machine_translated`,详情页渲染成提示且**对漫画换一条文案**明说图片仍是中文。🔴 **`machine_translated` 与 `is_fallback_lang` 是两句不同的话**:后者说「这不是你的语言」,前者说「这就是你的语言,但机器写的」;合并会让英文卡片告诉英文读者它是中文的。四个端点:`GET translation-gaps`(不调模型,给控制台一个数字 + 有没有配 key)/ `POST materials/translate-missing`(一键补齐,封顶 25)/ `POST materials/{id}/translate`(**`persist=false` 是编辑器的路径** —— 跑模型、回填表单、不落库,否则作者取消抽屉后译文还留在库里)/ `POST materials/{id}/translation-reviewed`(最常见的复审结果是「没问题」,不产生编辑;没有这条出口,标记会永远挂在恰恰翻得好的那些译文上)。cron 每小时 10 条,`outstanding` 写进 summary;**没配 key 而确实有缺口时 raise** —— 返回体面的 skipped 会让 `runs.status='ok'`,连接器空着多久这个任务就健康多久。

> 🔴 **安全宣贯 · 发送是「请求内上膛 + 请求外投递」,以及为什么状态要在 fan-out **之前** 落定** (2026-09-07, **0 迁移** / 0 新 cron / 0 新 MCP / 0 新 RBAC). 生产上第一次全员发送(416 人 × 飞书卡片+邮件)暴露了同一处代码里的四个缺陷,四个都只在「受众足够大」时才出现,所以五个 PR 的测试全绿。**先记住这几条结论,别再把 fan-out 放回请求里**:
>
> 1. **`POST /campaigns/{id}/send-now` 现在是 202,只上膛,不投递**(`/resend` 同样 202,理由相同 —— 上线第一天「还没确认的人」就是绝大多数受众;但拒绝走同步的 `require_resendable`,202 之后在后台 409 等于一次静默无效的点击)**。** 它 `schedule()` + commit,然后把整段 fan-out 交给 [`core.background.spawn`](../../server/app/core/background.py) 上的 `run_campaign_dispatch_in_background`(**自带 session**,并 `SET SESSION innodb_lock_wait_timeout = 120`,形状抄的是资产盘点的 `run_start_notifications_in_background`)。原来是 `await svc.dispatch(...)`,416 人跑了 **15.5 分钟**,而 nginx `/api/` 的 `proxy_read_timeout` 是 **300s** —— 管理员在第 5 分钟拿到一个通用失败 toast,而发送还在跑。**接着最危险的一步是人的下一步**:抽屉没关(`destroyOnHidden`),重开再点一次走的是 `create` 而不是重试 ⇒ **第二个活动 + 416 份重复必读**。丢任务也不丢发送:活动是先 commit 成 `scheduled` 再 spawn,十分钟一跳的 `security_awareness.campaign_dispatch` 是它的地板。
> 2. **`sending` 是个短状态,状态在 phase 1(台账落库)之后立刻落定 `sent`。** 每一个员工面的读都闸在 `status == 'sent'`(`my_pending` / `pending_all_for_material` / `mark_opened` 经 `CAMPAIGN_LIVE_STATUSES` / `acknowledge` / 催办阶梯 / 合规报告),所以「发完才翻状态」意味着在整段 drain 期间,**卡片已经到手的人打开素材看不到确认按钮**,`acknowledge` 还会回 409 `SECAW_CAMPAIGN_NOT_OPEN`。实测那次:15.5 分钟的窗口、264 个人握着一张要求他们确认、而平台不让他们确认的卡片,他们的 `opened_at` 也一并丢了(那一列的写入同样闸在 live 状态上)。投递真值本来就不在这张表里(`SecawCampaignTarget` **刻意没有** `delivered_at`),所以「台账落库即 sent」是这个状态本来的含义。
> 3. **`queued_at` / `last_sent_at` 逐人落,且与该收件人的 outbox INSERT 同一个事务。** 靠 [`fanout.enqueue_then_drain`](../../server/app/notifications/fanout.py) 新增的 `on_enqueued(env, outbox_id)` 钩子(在那一行 commit **之前**调用)。原来是 drain 跑完之后一条批量 UPDATE:drain 死在 90% 时 **416 行全是空的**,于是漏斗读 0、而「谁已经发过」无从判断。**原子性是重点**,不是顺手:分开写就会出现「有 outbox 行没有戳」或反之,而拿戳当「谁还没发」的依据就会漏发或重发。
> 4. **卡死自愈 + 重复发送闸。** `dispatch()` 的补偿从 `except Exception` 改成 `except BaseException`(**`CancelledError` 是 BaseException** —— 部署换容器 / 客户端断连,正是真实发生的那一种,原来直接跳过补偿),并且**只把还停在 `sending` 的活动放回 `scheduled`**:已经 `sent` 的活动放回去会让下一跳重走全体受众、每人再发一份。`dispatch_due` 每跳先扫 `status='sending' AND updated_at < now - STALE_SENDING_AFTER`(15 分钟)重新上膛 —— 在此之前**全仓没有任何一处把活动移出 `sending`**,`dispatch_due` 只捞 `scheduled`,所以一个卡住的活动就永久卡住:覆盖率冻在 0、`close()` 永远拒绝(它要求 `sent`)。恢复跑只发 `queued_at IS NULL` 的人,且**不**加 `resend_count`(没人催办)。`schedule()` 另有 `_refuse_duplicate_send`:同素材 + 同受众 + 同强度 + 同完成口径 + 同渠道,30 分钟内已经发过 ⇒ 409 `SECAW_CAMPAIGN_DUPLICATE_SEND`(错误文案报出上一个活动的名字);**刻意不把活动名算进指纹**(算进去等于「你有没有顺手改标题」,重复提交轻易绕过),故意的第二波传 `force=true`。**窗口按两次触达的时刻算**(`sent_at`,未发的用 `scheduled_at`),不是按孪生行的 `updated_at` 距今多久 —— 后者两头都错:改个标题就让一个三个月前的活动封住半小时,而同素材同受众**排在下周**的第二波(调度器收日期就是为了这个)反而被拒。孪生状态含 `scheduled`,所以「同一个 09:00 上膛了两次」也拦得住 —— 那种按状态过滤的版本原理上看不见(两个都还没发)。
>
> 同一轮把 `enqueue_then_drain` 的返回值改诚实了:`failed` 现在**同时**数「写不进去」和「送不出去」,`enqueued + failed == len(envelopes)` 恒成立。原来只数后者,4 个人的扇出会返回 `{"enqueued": 3, "delivered": 3, "failed": 0}` —— 只有拿 `enqueued` 和自己传进去的列表长度相减的调用方看得出丢了人,而全仓只有 `me_universe.wrapped_push` 一处真减了(那行手写减法同步删掉,否则双数)。**新增调用方直接读 `failed` 即可。**
>
> 🔴 **给这个钩子写测试要注意**:宣贯那边的用例把 `enqueue_then_drain` 整个换成了替身,所以它们只能证明**派发侧传了一个正确的钩子**,证明不了**原语真的会调它** —— 「不调 `on_enqueued`」这个变异让宣贯的用例全绿。契约那一半必须打在真原语上(`tests/test_notifications/test_fanout_rollback_revive.py`:逐行调用一次 + 拿到的就是刚写的那行的 id + 钩子抛异常时该行连同 outbox 一起回滚且只算那一个人失败)。

> **测试侧的两条同等重要的结论**:① 这个套件的 fan-out 替身必须**履行 `on_enqueued` 契约**(`conftest.fanout_double`),`AsyncMock(return_value={...})` 会让台账全空 —— 而且顺带查出一条**自证式断言**:`_fanout_patch(enqueued=2)` 喂进去的 2 又被 `assert summary["employee_pushes"] == 2` 读回来,当时库里只有 1 个人,那条断言在任何受众规模下都成立(含 0)。② `/send-now` 变 202 之后,**spawn 出来的任务会和每一条断言赛跑**(它在自己的 session 上抢走活动,于是测试自己那次 dispatch 拿到 `skipped: not_scheduled`,台账读出来是空的或半写的 —— 实测 27 个用例红),所以套件用 autouse 的 `spawned_dispatches` fixture 把 `spawn` 换成记录器,再由 `conftest.send_now` 显式驱动两半。

- **`platform_tips`** — security-tips ribbon (`platform_security_tips`, migrations 100 / 598): admin-managed rotating tips broadcast via the IT-service-desk mascot bubble; `/admin/security-tips`. **Now the FALLBACK tier of that bubble** — awareness material wins when the person has any (see the ambient-surface note above), and these show when they are up to date. `text_ja` added in 598 and deliberately left un-backfilled (NULL = not translated; the renderer falls back ja → en → zh, and copying the English in would disguise "untranslated" as "translated" forever).
- **`soc2_email_group`** — SOC 2 email-group attestation surface (service-only package; no own tables — reads the email-group + auditor data) backing the auditor's SOC2 email-group review page + help topic `soc2-email-group`.

### 12.15 Bounty — external white-hat vulnerability reports (2026-08-24, migration 533)

**Tables**: `bounty_researchers`, `bounty_reports`, `bounty_report_events`, `bounty_severity_tiers`, `bounty_reply_templates`, `bounty_mail_intake`, `bounty_report_attachments`.
`bounty_reports.system_id` (migration 542) is an optional FK into `registry_systems` (§12.16) — read through
`core/coordinators/system_registry_coordinator.py`, never by importing the registry's models.

**Routes**: `/api/v1/bounty/*` (30 endpoints — reports, assignment, triage, severity, remediation, money, correspondence, researchers, config, AI).

**RBAC**: reads `can_view_security` (global_admin / auditor / management), writes `can_manage_security` (global_admin / auditor). Menu: 安全合规 → 漏洞赏金 (`/bounty`).

**Invariants** — the ones that are easy to break and expensive to get wrong:

- **Three orthogonal axes, never one status column.** `status` (triage) / `remediation_status` / `payment_status` each have their own machine in [`constants.py`](../../server/app/domains/bounty/constants.py) and their own transition guard. A valid finding can be fixed-but-unpaid or paid-but-unfixed; the budget-burn, fix-rate and false-positive reports each read exactly one axis. Collapsing them forces the operator to misstate one to record the other.
- **Helix never emails the researcher** (decision 2026-08-24). `preview_reply` renders subject+body for the operator to copy into the existing thread in their own mailbox; `log_reply` is an *attestation* that stores the verbatim text as a `reply_logged` event. There is no outbound mail driver call anywhere in this domain. Auto-ingesting the researcher's mail would need a new Gmail DWD scope (`gmail.readonly`) — the shipped delegation is **`gmail.send` only**.
- **No banking data.** Payout tracking is amount / date / `payment_reference` / operator. Account numbers stay in the mail thread and finance's own system. There is no column to put them in, deliberately.
- **The researcher's identity and every money field are `internal` visibility**, filtered at the query level by `service.list_events(internal=...)` and `serializers.serialize_report(include_sensitive=...)`. The assigned business owner sees the technical substance only — that is what PR2's confirmation page reads.
- **The business owner never sets a triage status.** `record_owner_response` writes `owner_decision` + parks the report in `in_discussion`; scope and payout stay the security team's call. `OWNER_SETTABLE_REMEDIATION` deliberately excludes `wont_fix` / `risk_accepted`.
- **Severity stamps the SLA from the report's arrival date**, not from triage day (`fix_due_date = reported_at + tier.fix_sla_days`), so a slow triage cannot silently buy the fix more time. The deadline is denormalised onto the report: a later edit of the tier's SLA does not retroactively move deadlines already given.
- **Budget burn counts committed money, not just paid.** `PAYMENT_COMMITTED` (pending + requested) is reserved alongside `paid`; counting only `paid` reads healthy right up to the moment finance settles six pending rewards. Over-budget **warns and never blocks** — refusing to record a real payout would only put the ledger at odds with reality.
- **Closing must not strand money.** `_assert_closable` 409s when a confirmed report still has a committed reward: that figure was promised to a researcher in writing, so it has to be paid or explicitly waived first. Rejecting a report (duplicate / out-of-scope / false-positive) releases committed money; it never touches money already `paid`.
- **Paid is terminal.** A paid report's amount cannot be edited and the report cannot be soft-deleted — it is the receipt for that expense.
- **Attribution is a snapshot, not a join.** `assignee_name` / `assignee_department` are stamped at assignment so the per-team chart does not silently re-attribute two-year-old reports when someone changes department, and keeps working after the person is soft-deleted. `load_assignable_person` eager-loads `org_memberships` — `IdentityPerson.department` is a property over that relationship and raises `MissingGreenlet` on a lazily-loaded instance.
- **Self-healing seeds.** Migration 533 seeds the ladder + template library from [`catalog.py`](../../server/app/domains/bounty/catalog.py), the single source of truth also used by "restore default". `config_service._seed_*_if_empty` re-seeds only a **completely empty** table (a metadata-created schema, a scratch environment) using `ON DUPLICATE KEY UPDATE` on the natural key, so two concurrent requests racing an empty table cannot 1062 and poison the transaction. It never re-adds a row an operator deliberately deleted while others remain.
- **Reply rendering can only under-substitute, never raise.** [`rendering.py`](../../server/app/domains/bounty/rendering.py) regex-substitutes an explicit 15-token vocabulary; anything else (a JSON blob in a pasted PoC, a future token) is handed back verbatim. A referenced token with no value renders empty **and is reported** in `missing_variables`, which the console uses to disable 「已发送·记入时间线」 — a letter reading `a reward of USD ` is the worst thing this screen could emit. A line whose entire content was tokens that rendered empty is dropped, so an unset signer name does not leave a blank line inside a signature block.
- **Builtin tiers and templates are editable and disable-able but not deletable** — a deleted tier orphans `severity` on historical reports and drops them out of every chart. A custom tier is deletable only when no report references it.
- **The owner-facing surface is `/api/v1/me/bounty/*`** ([`self_router.py`](../../server/app/domains/bounty/self_router.py) / [`self_service.py`](../../server/app/domains/bounty/self_service.py)), authorised by **self-scope**: no role gate, and `load_my_report` re-checks `assignee_person_id` on every call. A report that exists but belongs to someone else answers **403 with an explanation** rather than 404 — the common case is a Feishu card that outlived a reassignment, and "not yours any more, ask the security team" beats something that reads like a broken link. Narrowing is enforced at the query layer (`include_sensitive=False`, `internal=False`), never in the frontend.
- **The owner cannot rule on scope, price or risk.** Their submission writes `owner_decision` and parks the report in `in_discussion`; `OWNER_SETTABLE_REMEDIATION` excludes `wont_fix` / `risk_accepted` and the schema rejects them, so accepting a security risk cannot happen by clicking a button on an employee page. The confirmation page says so and offers the comment box instead.
- **Pushes are named-recipient only and have no cron.** [`notify.py`](../../server/app/domains/bounty/notify.py) fires `bounty.owner_confirm_requested` / `..._reminder` at exactly the person the security team assigned — no manager, no skip-level, no walk-up when they are unreachable — and the verdict push goes to active `global_admin` holders only. One human action produces one push; 催办 is throttled to 12h per report. Because nothing is scheduled, deploying this cannot batch-notify a backlog. Owner-facing payloads carry neither the researcher's identity nor the reward.
- **`notified` means delivered, not recorded.** `dispatch` writes the outbox row and dead-letters an all-channels-failed send *without raising*, so `return True` after it would render 已通知 for a card nobody received. `notify_owner_assigned` reads `result.delivered_channels` and logs the per-channel errors when nothing landed.
- **The card lands on the page with the controls.** `/my/bounty/:id` (mobile `/m/bounty/:id`, registered in `contracts/mobile-deep-links.json`) carries the three questions, the remediation buttons and the comment box. A landing page with nothing to click is the documented reason a worklist sits at zero rows — see the cloud-efficiency triage post-mortem.
- **Two funding pots, never averaged.** `budget_track` ∈ {`product`, `it`} is an explicit column on the report (default `product`), because the pots have different owners and different approval paths — a combined bar cannot answer "how much of MY budget is left". `config_service.get_budget_config` returns per-track config and still reads the pre-539 flat shape as the product track, so an un-migrated environment keeps its ceiling instead of silently reverting to the default. `update_budget_config` patches only the tracks it is given: a full-object PUT would blank the pot the operator did not touch. Re-tracking is allowed **after payment** (a mis-filed track is more common than a wrong amount) and is logged with `already_paid` on the event.
- **Fiscal years are named after the year they END in** (`fy_label` → `FY27` for 2026-04 ~ 2027-03, changed 2026-08-24). The stored `fiscal_year` integer is still the START year, so nothing was re-bucketed; only the one formatter moved, which is why finance and security cannot drift apart on it again.
- **Every rate in [`analytics.py`](../../server/app/domains/bounty/analytics.py) states its own denominator, and they differ on purpose.** `valid_rate` divides by **decided** reports (an undecided submission is not evidence either way, and counting it makes an active area look worse than a dormant one). `on_time_rate` divides by **`measurable`** — fixed reports that actually had a deadline — not by `fixed`: a fix with no `fix_due_date` has no punctuality answer, and dividing by it reported "0% on time" over backfilled history where nobody had set an SLA. A report with no severity is neither valid nor invalid; it is undecided and leaves both sides of every fraction.
- **`awaiting_our_reply` exists because the failure mode is silence.** Volume and budget charts cannot show a report that sat 90 days while the researcher chased twice — that is the real 2026 history this report was written against. Measured from `reported_at` to `last_replied_at`, never-answered rows sorted first, repeat submitters flagged (leaving a long-term contributor waiting costs more than leaving a first-timer).
- **A duplicate must name its referent** (migration 544). `duplicate_of_id`
  (self-FK) OR `duplicate_note` (free text) — one is required when the status
  becomes `duplicate`, neither is sufficient alone to be mandatory, because
  the original is usually NOT another Helix row (an internal issue, a commit,
  an email predating the domain). Guards: no self-reference, no A↔B cycle
  (which makes "which is the original" unanswerable in both directions), and
  the pointer is cleared when the verdict changes — a paid report still
  labelled "duplicate of BB-x" is a contradiction a researcher will quote
  back. The referent also lands in the status event's `meta`, because "why
  was mine rejected" is asked months after the decision. The shipped
  duplicate template renders `{duplicate_reference}` (leading space and
  brackets included, so the sentence works with or without one) instead of
  the old hardcoded `{cve_id}`, which produced "related to ," on the majority
  of duplicates that carry no CVE.
- **AI is advisory only.** [`ai.py`](../../server/app/domains/bounty/ai.py) parses a pasted mail into intake fields and drafts an opening paragraph; it never decides severity, picks a template, or touches money, and it persists nothing. No platform LLM key → `BAILIAN_LLM_NO_KEY` 503 and every field stays hand-fillable. The parser's own-domain filter drops `pingcap.com` / `pingcap.net` addresses so it cannot mistake our security alias for the submitter, with a regex fallback when the model declines to answer.

- **Report list search covers the researcher** (2026-08-25). `?search=` matches code / title /
  affected system / CVE / assignee **and** the researcher's email, display name and handle, on any
  fragment. Reached by subquery on `bounty_researchers`, keeping `_apply_report_filters` join-free —
  a join added by one filter branch is a trap for the next branch that wants the same table. Not a
  widening: the list already serialises with `include_sensitive=True`.
- **`serialize_report` carries `fiscal_year_label`** (2026-08-25). The stored integer is the START
  year (2026 = 2026-04 ~ 2027-03) and the company says "FY27" — `core/fiscal_year.fy_label` is the
  platform's single formatter for this (centralised 2026-08-24 after a budget page showed "FY2026"
  next to a figure everyone called FY27). Routes pass `fy_start_month` — read **once per request**,
  never per row — and the client falls back to the integer when the label is absent rather than
  inventing one.
- **The detail drawer omits fields that have no value** (`OptionalRow` in `pages/bounty/bountyShared.tsx`).
  On a real report ten of sixteen cells rendered `'—'` at full grid width, which is why the page read
  as one undifferentiated block. Empty stays visible ONLY where empty is the news and carries an
  action: unassigned, ungraded, no reward decided, and a `confirmed` finding with no fix deadline.

- **Reward letters are per severity tier** (2026-08-25, migration 545). `catalog.REPLY_TEMPLATES`
  ships `reward_critical` … `reward_informational` plus `payment_sent`; each states
  `USD {amount_usd}` and asserts nothing about impact beyond the tier. The retired
  `reward_confirmed` froze one past reply's verdict ("low-impact business area", "open source
  and publicly accessible") and was pre-selected for **every** confirmed report, so operators
  rewrote it by hand and the amount was lost in the rewrite. It also solicited
  `bank account information`, contradicting the no-banking-data decision.
- **`catalog.suggested_template(status, severity)`** replaces a status-only lookup for confirmed
  reports. Confirmed **and ungraded** falls back to the acknowledgement on purpose: there is no
  honest reward letter before a tier exists, and pre-selecting one renders an empty amount.
  `meta.suggested_template_by_severity` carries the map to the client.
- **A missing `amount_usd` refuses the render** (`service._assert_money_letter_has_money`,
  422 `BOUNTY_REPLY_AMOUNT_REQUIRED`). The renderer's `missing` list stays advisory — correct for
  a signature line, wrong for money, because the operator *copies the preview* into their mail
  client. Judged on the template's **declared tokens**, not the rendered text: after substitution
  the figure is indistinguishable from prose, and a hand-typed amount must not be blocked.
  Non-money letters are unaffected — an unrewarded report is the normal state of a new report.
- **Deleting a builtin template needs the catalogue entry gone too.** `restore_builtin_template`
  (singular) exists and reads the catalogue, so a migration that only deletes the row leaves the
  config page able to resurrect it. No FK points at `bounty_reply_templates`
  (`last_reply_template_key` is a plain `String(64)`), so retiring a key never damages history.

- **Data-completeness read: `GET /bounty/analytics/data-gaps`** (2026-08-25). Ownerless registry
  systems **with the count of open reports each one blocks**, split into never-assigned vs
  owner-has-left because the fix differs; ungraded; unlinked, split into "names a host" (one click
  from linked) vs "names nothing" (needs a human to read it); never-replied with the oldest silence.
  Four blunt numbers rather than a health score — a score lets 6-of-8-ownerless average away
  against something green. Backed by
  `system_registry_coordinator.systems_needing_an_owner` (registry side) and
  `bounty_coordinator`-style counting on the bounty side, so neither domain reads the other's tables.
- **`bounty_reports.system_not_applicable`** (migration 546). Distinct from `system_id IS NULL`:
  null means *nobody has looked*, the flag means *somebody looked and no internal system applies*
  (open-source product finding, CI workflow). 44 production reports are in the latter state and
  would otherwise sit in the gap forever — a number that cannot reach zero teaches people to
  ignore it. Setting it writes the operator's reason onto the report's timeline, because "who
  decided this was an open-source finding" is what gets asked later and a boolean cannot answer it.
- **Backfill endpoints** `POST /bounty/backfill/{severity,link-system,no-system}` +
  `GET /bounty/backfill/candidates`. Bulk is allowed for **internal fields only** — a wrong grade
  is visible in Helix and correctable, thirty letters with one bad sentence are not, so replies
  stay one at a time by decision. Every write **skips rows somebody already decided**
  (`already_graded` / `already_linked` / `already_marked`) and returns the skip reasons, which the
  console reports verbatim: "applied 12" while silently skipping 3 is how a feature gets written
  off as broken later.
- **AI suggestions read the TITLE only** (`ai.suggest_from_titles`). `raw_report` is empty on all
  149 rows — the FY27 import deliberately kept researcher correspondence out of the database — so
  there is no body. The UI says so, because a suggestion whose basis is invisible gets trusted more
  than it deserves. An unconfigured or failing provider degrades to **no suggestion, never a
  guess**: adoption is a human act, which is the lesson from the HRIS sync that created 26 accounts
  for system users because a loose predicate asked nobody.
- **`serialize_report` carries `days_silent`.** The only dimension in this corpus that ranks
  priority objectively (2 … 295 days); computed server-side so three clients cannot drift on it.

- **The console lands on a matrix, not a list** (2026-08-25). `GET /bounty/analytics/matrix`
  returns ownership rows × six stage columns (`need_reply` / `need_grade` / `need_assign` /
  `await_owner` / `need_fix` / `money` — the set the platform owner signed off; renaming one is a
  product decision). Rows group by registry system when linked, by hostname when a host is named
  but unregistered, and into one `unattributed` band when the report names nothing; banded
  product → IT → unattributed. **Heat is the OLDEST wait in the cell**, bucketed server-side at
  7/30/90/180 days so two pages cannot disagree about what "urgent" means.
  Aggregated in Python over a narrow column projection rather than six `GROUP BY` round-trips:
  six separate aggregates would each have to agree about the stage definitions, and that drift is
  what makes a dashboard stop being believed.
- **A cell can be impossible rather than empty** (`na: true`). "Assign to the system owner" cannot
  exist while the system has no owner; rendering it as `0` invites the reader to conclude the work
  is done, which is the shape of the bug that left 97 reports un-routable while the console looked
  healthy. **An impossible cell is also excluded from its column total** — a header that disagrees
  with the cells beneath it (92 over a column of hatching) is how a dashboard loses trust. Both
  halves are mutation-tested.
- **`?ungraded=true` on the report list.** The "not graded" column drills through a real predicate;
  the first attempt used `severity=__none__`, and a sentinel inside an `IN` list matches nothing,
  which on screen is indistinguishable from a broken filter.
- **The drill-down states its truncation.** The list route caps at `page_size=100`; when the cell
  counts more, the header reads "showing the first N of M" rather than silently showing fewer rows
  than the cell promised.

- **Matrix drill-down: `GET /bounty/analytics/lane?row=<key>`** (2026-08-25). One ownership row as
  a left-to-right pipeline with real cards. Scoped to a single row deliberately — a full swimlane
  board needs horizontal scrolling at 8 systems and is unusable at 20. **No drag-and-drop, and the
  UI says why**: grading pulls a reward tier and a fix deadline, assigning notifies a colleague and
  starts a clock, confirming is the business owner's judgement. A board that looks draggable and is
  not would be worse than one that clearly is not.
- **`analytics._bucket_for` / `analytics._report_stages` are module level, not closures.** The matrix
  and the lane share them by construction, and a test asserts the observable consequence: for one
  row, every lane stage count equals its matrix cell. Two definitions of "which stage is this report
  in" is the drift that makes a console disagree with itself.
- **Both capped reads state their cap** (`hidden` on a lane bucket, "showing the first N of M" on the
  matrix drill-down). A list that does not say it is truncated reads as "this is everything".

- **Reply queue** (`pages/bounty/ReplyQueue.tsx`, 2026-08-25). Bulk replying is **deliberately not
  offered** — a reply is the only action in this domain that leaves the building, and one badly
  worded sentence sent to thirty researchers cannot be taken back. The answer is to make replying to
  one cost almost nothing: the queue walks `?never_replied=true` oldest-silence-first and re-opens
  the composer on the next report the moment one is logged. It reuses `ReplyComposer`, so the
  template picker, the AI-drafted opening and the missing-amount refusal behave identically here.
  The progress strip renders **inside** the composer; as a second floating panel it sat half-hidden
  behind the letter it described.
- **`?never_replied=true|false` on the report list** — a real predicate for the same reason as
  `ungraded`: the queue must be able to ask for exactly this set rather than inverting client-side.

- **`bounty_researchers.merged_into_id`** (migration 547). Two addresses can be recorded as one
  person — confirmed in production for `suhaspandya55@` / `suhaspandya124@`, which made every
  per-researcher figure wrong for them. A **merge pointer, not a rewrite**: reports keep pointing at
  the address that sent them, because a reply has to go back to that address and "which address did
  this arrive on" is a real question in a dispute. Only `researcher_stats` and the report search
  resolve through it, and the stats are filed under **every** id in the alias set so a caller
  holding an alias is not told the person has one report.
- **One hop only, enforced in the service.** An alias may not be a merge target, and a row that
  already has aliases may not become one. Both would force alias resolution to recurse over
  operator-entered data, which is how a page ends up hanging; refusing them keeps resolution to a
  single join. `POST /bounty/researchers/{id}/unmerge` exists because an identity claim typed by a
  person is sometimes wrong and a one-way door on a guess is a trap.

- **security@ ingest → confirmation queue** (`domains/bounty/mail_intake.py`, migration 548,
  2026-08-26). `bounty_mail_intake` holds what arrived until a person accepts or ignores it. The
  poller **never creates a report**: security@ also carries vendor pitches, internal mail and
  automated scan output, and the precedent for unattended writes is the HRIS predicate that created
  26 accounts for system users. Ignoring **keeps the row**, or the next poll re-offers the same mail.
- **Follow-ups attach rather than duplicate** (`mail_intake.attach_followup`). A mail whose
  `thread_id` already produced a report is logged onto it as `inbound_logged`. Researchers chase
  their own threads, and 118 unanswered reports means a lot of chasing — each chase would otherwise
  become a duplicate finding with the conversation split across two timelines. Module level, not
  inline in `poll`, so it is reachable without a Gmail client.
- **⚠️ Gmail thread ids are PER ACCOUNT, so the automatic path cannot reach the imported reports.**
  The 149 FY27 reports carry thread ids from the platform owner's mailbox; the live poller reads
  `bounty-intake@`. A chase on any of those 149 therefore never matches by thread and lands in the
  queue looking like a brand-new finding — which the first mail the live poller ever queued
  (2026-08-28) already was: `Re: [Bug Bounty] IDOR - Cross-Tenant Metrics Exposure…`, ten days
  unanswered. `POST /bounty/mail-intake/{id}/attach` is the operator's way out (idempotent, reuses
  `attach_followup`, so events / status / the chase push are identical to the automatic path). The
  list endpoint carries `suggested_report`: subject matched exactly after stripping `Re:` / `Fwd:` /
  `回复:`. Three deliberate narrowings — the subject must actually look like a reply (a first report
  resembling an old title is a NEW finding, and a merged-away report means somebody never gets
  paid); a normalised subject under 12 characters gets no suggestion (`Re: update?` matches half the
  ledger); and it only ever SUGGESTS, because attaching a chase to the wrong finding is worse than
  leaving it queued.
- **The AI's reading of a queued mail was NULL for every mail ever polled, silently** (fixed
  2026-08-28). `_annotate_pending` keyed its request by `str(row.id)` while `suggest_from_titles`
  narrows the echoed code with `_clean_str(..., limit=32)` — a UUID renders as 36 characters, so
  every returned key was a 32-char prefix and every lookup missed. The call succeeded, the model
  answered correctly, and nothing logged. Now keyed by `row.id.hex[:16]`; the 32-char limit stays
  (it guards against a model returning prose where a code belongs — the caller's job is to ask with
  a key that survives it). The FY27 backfill path was never affected: it passes `BB-2026-077`.
  Test-authoring trap found alongside it: `JSON.none_as_null` is False by default, so an explicit
  Python `None` on `parsed` stores the JSON literal `null` and `parsed IS NULL` does not match it —
  a fixture built that way is invisible to `_annotate_pending` and the test passes for the wrong
  reason. Build the row without the field, the way `poll` does.
- **Bodies are never stored** — only a snippet. Researchers send tax forms and bank details to that
  mailbox unprompted; the FY27 import already established that correspondence stays in the mailbox.
  A test asserts the serialised row carries no `body` / `raw` / `payload` / `attachments`, so the
  field cannot grow back.
- **The AI's severity arrives as `suggested_severity`, never `severity`.** Grading pulls a reward
  tier and a fix deadline — a decision with money attached.
- **The cron shipped disabled; it is LIVE in production since 2026-08-28.** It needs
  `gmail.readonly` in Workspace domain-wide delegation, which is a Google admin action — a job
  failing every fifteen minutes trains people to ignore the failure notifier, and the scope error
  names the remediation rather than surfacing a bare "insufficient permission". Two things had to
  happen off-code: the scope was granted 2026-08-26, and `bounty-intake@pingcap.com` was created as
  a real Workspace USER and subscribed to the `security@` group on 2026-08-28. `security@` itself is
  a GROUP — domain-wide delegation can only impersonate a real user, so a poller pointed at the
  group address answers `unauthorized_client` for every scope, `gmail.send` included. Job config in
  production: `{"mailbox": "bounty-intake@pingcap.com", "query": "in:inbox newer_than:30d
  -from:mail-noreply@google.com"}`. The exclusion is not cosmetic: Gmail's own onboarding mail sits
  in every brand-new mailbox and its sender is external, so the first poll would otherwise have
  queued two rows and told the desk that two researcher mails were waiting.
- **Who gets the desk's alerts is configurable** (`bounty.alert_recipients` platform setting,
  `GET/PUT /bounty/config/alert-recipients`, 2026-08-29). Default is every active
  `security_officer`; `_DESK_ROLES` no longer includes `global_admin`, which in production made the
  audience eight people for work four of them do. An empty list means THE DEFAULT, not "nobody" — a
  config surface whose empty state silences the channel is how a queue goes unwatched; muting is
  done by disabling the poll job, which is visible on the jobs page. A named recipient who has left
  is dropped, and if every named recipient is inactive the role default takes over (with a warning
  log) rather than the desk going quiet. The read endpoint returns RESOLVED people plus
  `is_default`, because "who actually gets told" is the question the surface exists to answer.
- **The queue card names the mail, not the count** (`notify.notify_security_of_new_mail`,
  2026-08-28). One push per poll — fifteen-minute polling makes a per-mail push a mute button — but
  batching is a reason to group the content, not to drop it. The first version sent
  「security@ 有 N 封新的白帽子来信待确认」 and a link, which is indistinguishable from a vendor
  pitch arriving and cannot be triaged from a phone. The card now itemises sender, subject, the AI's
  first read (severity · host · confidence, omitted entirely when no provider is configured rather
  than rendered as a placeholder), the mail's opening line, and a warning when the thread has
  written before — that last case is the one `poll` cannot auto-attach (the earlier mail is still
  undecided in the queue), and unflagged it is how one conversation becomes two reports. Capped at
  5 items with a "N more" line. The card's button lands on `/bounty?intake=1`, which opens the
  confirmation queue itself — `/bounty` alone left it behind a badged button in the page header, one
  hop short of what the button names (same shape as the `?tab=requests` deep link that went nowhere
  in the approval registry). `notify_security_of_researcher_followup` carries the same excerpt
  for the same reason. The researcher's address DOES belong on these two cards — their recipients
  are the security desk, who answer the mail; the no-researcher-identity rule in
  `templates/bounty.py` is about the OWNER-facing cards, where the reader is a colleague pulled in
  to answer one technical question.
- **Handler registration is checked across BOTH processes**
  (`tests/test_scheduled_jobs/test_api_process_handler_parity_2026_08_28.py`). `app/worker.py`
  imported `app.domains.bounty.scheduler_handlers`; `app/main.py` did not. The cron therefore fired
  fine while the console's 「立即运行」 button — which executes in the API process — answered
  `Handler 'bounty.mail_intake_poll' 未注册`, wrote a `failed` run row and bumped `failure_count`,
  pointing the reader at a rename that had not happened. `assistant.knowledge_gap_reminder` had the
  same gap. This is the mirror of the 2026-05-15 incident (PR #316) where the worker was the process
  missing the import; that direction is guarded at worker boot by
  `_assert_all_enabled_handlers_registered`, and this test guards the other.

### 12.16 System registry — 系统台账 (2026-08-25, migration 542)

**Tables**: `registry_systems`, `registry_system_events`.

**Routes**: `/api/v1/systems/*` (10 endpoints — list / options / suggest / export / detail / events /
create / patch / owner / retire) + `/api/v1/me/systems` (self-scope). MCP: `list_my_systems` (self-scope),
`list_registered_systems` / `get_registered_system` / `find_system_owner` (admin reads). Writes are HTTP-only.

**RBAC**: reads `can_view_system_registry` (global_admin / auditor / security_officer / management / hr),
writes `can_manage_system_registry` (**global_admin only** — IT maintains the register). Menu: 安全合规 → 系统台账
(`/admin/systems`).

**What it is, and what it is deliberately NOT.** It registers the *application* layer — the systems we build and
run — and answers three questions: who owns this, is anything ownerless, what is reachable from outside. It does
not duplicate `cmdb_*` (provider-synced machines, domains, DNS, certs), `asset_software_catalog` (purchased SaaS
with seats), `sso_apps` (a SAML catalogue) or `domain_registrations` (who registered an external domain). CMDB
knows a host is running; this knows who answers for the system. The two are joined by hostname, not by FK.

**Invariants**:

- **Ownership writes are events.** Every owner change goes through `service.transfer_owner` (its own endpoint,
  not the PATCH) and appends a `RegisteredSystemEvent` carrying both sides (`from_name` → `to_name`). "Who was
  responsible for Clinic in May" is the question an incident asks, and the platform audit log does not answer it.
  `update_system` cannot write ownership at all, so there is no path that changes accountability silently.
- **`_base_query` carries `populate_existing=True`.** The session runs `expire_on_commit=False`, so re-reading a
  row already in the identity map after a transfer would hand back the PREVIOUS owner while the column is
  correct. Caught by the transfer test, not by inspection.
- **Hostname matching suggests, never decides.** `suggest_for_host` / `GET /suggest` return candidates for a
  human to accept. Auto-linking a report on a substring match would silently re-assign the finding's owner and
  budget pot. The bounty intake shows the match and a 「关联」 button.
- **`exposure=unknown` is distinct from `internal`.** Nobody has verified it, which is not the same as
  "confirmed not reachable". The default on create is `unknown`, and the external-surface reads count only
  `public` + `authenticated` — but the ownerless read counts `unknown` too.
- **`in_bounty_scope` is not derived from `exposure`.** Something publicly reachable can be out of scope (a
  third-party-hosted marketing page) and something internal can be in it. Guessing either from the other is how a
  researcher gets told "out of scope" about a system that is not.
- **An owner who has left is not coverage.** `create_system` / `transfer_owner`
  refuse a non-active person (409 `SYSTEM_OWNER_NOT_ACTIVE`, mirroring the
  bounty domain's assignment guard), and `coverage_summary` counts only
  systems whose named owner is still active — a departed owner lands in its
  own `owner_departed` bucket, never in `owned`. Without the second half the
  first is useless: someone named while employed and gone six months later
  satisfies every `owner IS NOT NULL` check, their offboarding blocker
  completed long ago, and "coverage 100%" coexists with nobody accountable.
  The `?ownerless=true` filter carries the same meaning as the KPI tile, so
  clicking the number shows exactly the rows it counted.
- **Coverage denominators exclude retired rows.** `LIVE_STATUSES` = active + deprecated; a retired system without
  an owner is not a governance gap, and counting it would make the number drift down every time something is
  switched off correctly. `retire_system` soft-deletes AND sets the status, and both exits are excluded — verified
  by mutation, because the soft-delete filter alone would have hidden a missing status check.
- **Offboarding blocks on primary ownership** (`RES_SYSTEM_OWNER`, advisory severity → force-transferred at
  T+0+24h). The scanner emits one blocker per live system the leaver owns or backs up, with distinct labels. The
  T+0 force transfer prefers the registry's **backup owner** over the generic manager fallback (they were named
  in advance for exactly this) and skips a backup who is themselves leaving. Manual handover goes through
  `resolve_blocker_by_handover_to_successor`, which moves primary ownership and clears a stale backup slot in one
  pass — otherwise the next scan re-raises the same system for the backup half.
- **A report's registry link is validated, never dangling.** `_resolve_system` in the bounty router rejects a
  `system_id` the registry does not have: a dangling link renders as an empty owner and reads as "nobody owns
  this", which is a different and much more alarming statement than "not linked". The free-text
  `affected_system` stays the fallback and is never derived from the link — a researcher can name a host we have
  never registered, and rewriting what they said would lose evidence.
- **Budget-pot precedence is server-side**: what the operator chose > the linked system's track > the platform
  default. `ReportCreateRequest.budget_track` is `str | None` for exactly this reason — a schema default would
  make "the operator chose 产研" indistinguishable from "nothing was said" and would silently charge every
  registry-linked IT finding to the wrong pot.
- **The owner-facing payload is narrowed again.** `bounty/self_service._system_names` hands the assignee name /
  slug / kind / exposure only — not the registry's owner emails or budget pot. Same reasoning as
  `include_sensitive`: the assignee was pulled in to answer one question.
- **The employee surface is a menu group, not a deep link.** 安全合规 is a
  top-level group in the EMPLOYEE menu (`/my/systems` + `/me/access-review` +
  `/me/security-quiz`) holding standing security responsibilities; time-bound
  work stays in 待办, so 漏洞确认 is not duplicated as a leaf — `/my/systems`
  banners it instead. Before this the three were reachable only by a header
  icon, a notification deep link, and nothing at all.
- **An employee can contest ownership but not edit it.** `POST
  /api/v1/me/systems/{id}/dispute` writes `owner_disputed` onto the system's
  own trail (next to the ownership history it disputes) and pushes
  `system_registry.ownership_disputed` to active `global_admin` holders — the
  only role that can re-point the row. Self-scope: 403 unless the row names
  the caller, because being able to file "not mine" against a colleague's
  system would make the trail unreliable exactly where it must be trusted.
- **`/api/v1/me/systems` has no role gate** — the scope IS the authorisation. It also feeds `it-home`
  (`registered_systems` + `registered_system_count` / `owned_system_count`) and the IT-宇宙 graph, so an employee
  learns they are on the hook BEFORE an offboarding blocker tells them for the first time.
- **The lead's side: `/api/v1/me/team/systems` + `/api/v1/me/team/systems/findings/{code}`** (2026-08-25).
  Being someone's manager IS the authorisation — no role gate, same as 团队资产 / 团队云资产. Scope resolves
  through the canonical `subordinate_person_ids(scope=MANAGED)` (whole subtree, leavers excluded) and only
  PRIMARY owners count, so one system never appears under two people. Both endpoints share
  `_owned_by_my_reports()` so list and detail cannot disagree about what a lead may see.
- **Cross-domain seam for the above: `core/coordinators/bounty_coordinator.py`** — `open_findings_by_system`
  and `finding_detail_for_systems(db, code, system_ids)`. **`system_ids` IS the authorisation argument**:
  the caller passes the set it has already established is visible, so a guessed report code cannot widen the
  answer, and an out-of-scope code returns **404, never 403** (a 403 would confirm the code exists).
  Payloads are **field-by-field allowlists**, never a serialised row. **The line the allowlist draws is
  commercial, not technical** (corrected 2026-08-26): the technical artifacts — `raw_report` (the
  researcher's own write-up), `reproduction`, `affected_urls`, `vuln_type`, `ai_assessment` — DO cross the
  seam, because the owner and their lead are the people who have to judge and fix the finding. What stays
  behind it is the researcher's identity and reply channel, the reward, the payment state, our
  correspondence, and `summary` — which sounds technical but in the real ledger is the operator's scratch
  column carrying 「已向研究者索取发票,尚未付款」 and "reward agreed", i.e. the payment state in free text.
  The earlier, wider reading withheld everything technical too, which was harmless only while the write-up
  lived solely in the mailbox; after the mail import put it in Helix for 145/149 reports it left the
  accountable parties reading a clock and a trail. Timeline entries are
  restricted to 10 lifecycle event types (no `reward_set` / `payment_changed`, no free-text types) and
  **bodies are never returned at all**. Severity labels come from the configurable `bounty_severity_tiers`
  rows (the same triple the assignee's own page receives) — label×3 + colour only, never the tier's reward
  columns. **A level nobody chose is not reported as a level**: every report arrives carrying the lowest
  enabled tier so no queue shows 未定级, and `constants.graded_severity(report)` — the single definition,
  four call sites — returns `None` while `severity_is_default` stands. The security desk still sees the
  placeholder (it is who replaces it); an owner or a lead sees only whether a judgement exists. Skipping
  that put 「告知」 on 「…leaks 7,735 attendees' full PII」 in front of its owner, on 140 reports. Adjacent same-type-same-minute events collapse to one line: the FY27 backfill wrote its own
  `created` event on top of `create_report`'s, so every backfilled finding otherwise rendered
  「报告已创建」 twice and read like a broken page.
- **No dead ends between the two.** `/my/systems` shows a manager a banner with their reports' system and
  outstanding-finding counts linking to the team view — the page is correct when empty for an IC, and was
  useless for exactly the person most likely to open it.

### 12.17 Approval-flow registry — 审批流程台账 (2026-08-26, migrations 553 / 556 / 557 / 559)

**Tables**: `registry_approval_flows`, `registry_approval_flow_events`.

**Routes**: `/api/v1/approval-flows/*` (10 endpoints — published / meta / list / export / detail / events /
create / patch / owner / retire / import / sync) + `/api/v1/me/approval-flows` (self-scope: list / confirm /
dispute / transfer / **description** / requests). MCP: `find_approval_flow_owner` (all-staff),
`list_approval_flows` (governance), `list_my_approval_flows` + `confirm_my_approval_flow` /
`transfer_my_approval_flow` / **`describe_my_approval_flow`** (self). Writes ABOUT SOMEBODY ELSE are
HTTP + `global_admin`; writes about the caller's own row are self-service on both surfaces.

**The owner writes the 用途说明** (2026-08-27). Same `description` column IT edits, not an owner-only second
field: the card has room for one sentence, and two competing sentences would force a choice with no right
answer. Gated by the same `_mine_or_404` as every other self verb, and every write leaves an event naming
who changed it. All 49 flows shipped with it empty — the register could say who to ask and never what the
thing was for, which is half of 「该走哪个流程」 missing, and IT cannot credibly write 49 of them.

**RBAC**: `can_view_approval_registry` (global_admin / auditor / security_officer / management / hr) for the
operational view; `can_manage_approval_registry` (**global_admin only** — IT maintains the register).
`GET /approval-flows/published` is gated on nothing beyond being signed in.

**Why it exists.** Every approval the company runs lives in Feishu 审批, and two questions had nowhere to be
answered: how many approvals are there, and who is responsible for each one. The first was visible only inside
the Feishu approval admin console; the second was not recorded anywhere at all.

**The wire facts this domain is built on** (all verified live against the tenant on 2026-08-26, not read off
the docs — the docs are wrong or silent on three of them):

- **There is no "list approval definitions" endpoint.** The only definition endpoint takes a code you already
  have. Discovery therefore enumerates *instances* per person (`POST /approval/v4/instances/query`) and
  collects the definitions behind them. Consequence, stated rather than hidden: a flow that has never been
  submitted cannot be discovered — hence `POST /approval-flows` and `/import` as first-class paths.
- **An approval definition DOES carry 流程管理员 — but only if you ask for them.**
  `GET /approval/v4/approvals/{code}` returns `approval_name` / `status` / `form` / `node_list` / `viewers`
  and **no admin key at all**; `?with_admin_id=true&user_id_type=user_id` adds
  `approval_admin_ids`. This bullet read "the documented `approval_admin_ids` comes back **null**" for the
  domain's whole first release — the 2026-08-26 probe was real, it just never sent the param, and the
  conclusion was copied into `constants.py`, `__init__.py`, the cron description and three i18n bundles,
  where five mutually-confirming copies made it look verified. **32 of 49 flows published 「还没人认领」
  while the Feishu console named a manager.** Re-measured 2026-08-27 with the param: **42 of 49 definitions
  return at least one admin, 37 distinct people, 33 matching an active `identity_persons` row.**
  Resolution goes through `GET /contact/v3/users/{id}` **one id at a time**, never `/users/batch`: one stale
  id fails the entire batch with `99992360`, and other teams' admin lists routinely carry them (1 of 37).
- **There is no write for 流程管理员, and no `update` verb on approval definitions at all.**
  The v4 definition surface is create (`POST /approval/v4/approvals`), get, subscribe / unsubscribe —
  every definition-level call in `app/approval/feishu_client.py` is a read, because there is nothing else
  to call. Consequence, and it is structural rather than a gap to close later: **every ownership change
  Helix makes leaves the console naming the previous person**, and the console is the side that grants
  the rights (editing the flow, reading its instance data, receiving its admin notices). A transfer in the
  half-year round therefore hands the successor the accountability with none of the access. Surfaced as
  `?tab=feishu-drift` + a daily digest (migration 564) rather than silently reconciled, because it cannot
  be reconciled from here.
- **`node_list` contains no approver identities** — node names and types only, `need_approver: false`, no user
  ids. Inferring an owner from the definition is impossible; inference reads decided *tasks* instead.
- **The `group` object is reliable on `instances/query` and NOT on `tasks/search`.** Measured over one
  person's 1196 instances: 24 distinct codes, zero with conflicting group names. The same sample through
  `tasks/search`: 13 of 25 codes returned multiple group names and 12 disagreed with the instance API
  outright (`SSO Apply` → `HR&Admin` there, `IT Equipment` here). Group is read from `instances/query` only.
- **Both search endpoints need `approval:approval.list:readonly`** (granted 2026-08-26) and reject a query
  with no `user_id` / `approval_code` / … (`1390001 all required query criteria is empty`). An instance time
  window must be closed and ≤30 days.

**Invariants**:

- **Two group columns, not one.** `source_group` is what the API reported; `category` is what Helix
  publishes. They genuinely disagree in this tenant — the console files 域名DNS解析申请 under
  `IT Services & Security` while the API answers `IT Equipment`. Publishing the API value alone shows
  everyone a taxonomy no administrator recognises; overwriting it destroys the record of what upstream said.
- **`owner_source` is the point of the ownership design.** `unassigned` / `inferred` / `feishu_admin` /
  `manual` / `imported` / `confirmed` are different facts, and a single non-null owner column would flatten
  them into one — which
  is how a register reports 100% coverage on the strength of its own guesses. Only `confirmed` (the owner
  themselves said yes) counts as confirmed coverage; `coverage_pct` and `confirmed_pct` are reported
  separately for that reason.
- **Console drift is DERIVED, never stored** (2026-08-28, migration 564). `feishu_admin_drift` is
  computed on every read from `owner_person_id` vs `feishu_admins[*].person_id`, so the nightly sweep
  clears a row the moment somebody fixes the console — a stored flag would need its own clearing path and
  would eventually publish drift that was fixed weeks ago, and a 「已处理」 button would let somebody mark
  a row done without doing it. The only persisted field is `feishu_admin_drift_notified_at`, bookkeeping
  about the push; it is nulled when a row stops drifting, or the same flow breaking again next month would
  be skipped as 「already told them」. Two silences are deliberate and both were mutation-verified: an
  ownerless flow is not drift (「没人认领」 is its own louder read, fixed by naming somebody), and an empty
  admin list is not drift — one branch covering three states that should all stay quiet (no admin declared
  upstream / an admin who resolves to no Helix person / a row nobody has ever asked the console about,
  because pre-559 sweeps omitted `with_admin_id`). A third guard on `feishu_admins_synced_at IS NULL` was
  written and then **deleted**: mutation testing removed it with no test turning red, because
  `apply_feishu_admins` is the sole writer of both columns and always stamps them together, so a NULL
  timestamp and an empty list are the same row.
- **Two pushes about console drift, and the split is by who acted** (2026-08-28). The 09:00 digest covers
  everything; an **immediate** alert also fires the moment a person presses 「转交」 (user decision). It
  hangs off `announce == HANDOVER_TRANSFER` inside `set_owner`, which is passed from exactly two call sites,
  both an employee pressing the button themselves — the offboarding coordinator and the
  request-registration paths pass `announce=None`, so a batch hand-over that moves every flow a leaver
  owned inside one transaction still surfaces as one digest line rather than nine cards about one console
  screen. The immediate alert additionally requires the gap to exist (a transfer to somebody the console
  already names needs no edit) and stamps `feishu_admin_drift_notified_at` on delivery, so the digest does
  not repeat it — stamped only on a delivered push, which is what keeps the digest a safety net rather
  than a duplicate. Mutation-verified in all three directions.
- **Zero drift sends nothing, and that is pinned in two places because the two guards masked each other.**
  `if not due` in the handler and `if not flows` in the notifier are each individually redundant — deleting
  either one left every test green, because the other caught the empty set. Coverage that only looks like
  coverage. The notifier is now called directly with an empty list, and the handler's early return is
  pinned by asserting the `reason` it writes into `last_result_summary`; each deletion is now red. Without
  a guard the loop reaches `_registry_maintainers` and posts 「0 条流程的管理员和台账对不上」 every morning.
- **A machine may fill a blank; it may not overwrite a person.** `apply_inferred_owner` refuses any row whose
  `owner_source` is in `HUMAN_OWNER_SOURCES`. Mutation-verified.
- **A declaration outranks a statistic, and neither outranks a person** (2026-08-27, migration 559).
  `apply_feishu_admin_owner` may overwrite `inferred`; `apply_inferred_owner` may not overwrite
  `feishu_admin` (`INFERENCE_OVERWRITABLE_SOURCES`). Two machine sweeps that each overwrote the other would
  re-point the same row on alternating nights.
- **Every flow with a still-employed 流程管理员 gets an owner, team or not** (user decision, 2026-08-27 —
  the earlier rule adopted only a *lone* admin and left 25 of 49 rows with an empty owner, which made the
  register's whole accountability half unusable; one named person who can answer 「不是我」 beats nobody to
  ask). An adopted admin renders `unconfirmed`, never `confirmed`: the console saying somebody administers a
  flow is not that person agreeing to answer for it, and the full `feishu_admins` list still publishes
  alongside so 「找谁」 names the whole team.
- **The pick CANNOT be `approval_admin_ids[0]`.** Feishu **rotates the array on every call** — measured
  against this tenant 2026-08-27: five reads of `Business Card Application` (7 admins) returned four
  different orders and four different heads; `Non-China Purchase Request` and `Create/change GitHub
  repository` rotate too. Index 0 would re-point the flow most nights, write an ownership event each time,
  and send December's confirmation card to whoever won that morning's roll. `discovery._pick_admin_owner`
  therefore orders candidates by `user_id` (arbitrary, but the *same* arbitrary answer every night) and is
  **sticky**: an incumbent `feishu_admin` owner keeps the flow while they remain on the list and employed, so
  neither a rotation nor an unrelated console edit that adds a colleague can move accountability. It moves
  only when the incumbent drops off the list or leaves.
- **The 「待本人确认」 KPI counts and filters through ONE predicate.** It used to read
  `owner_source == 'inferred'` on both sides — correct until a second machine source existed, at which point
  the tile would show one number and click through to a shorter list. `coverage_summary.owner_machine` and
  the `machine_owner=true` filter (HTTP + MCP `list_approval_flows`) both derive from
  `MACHINE_OWNER_SOURCES`; `owner_inferred` / `owner_feishu_admin` stay available as the narrower counts.
- **A departed admin is flagged, not dropped** (3 in production: 褚杰 / 罗进敏 / 玄飞). Hiding them turns an
  actionable 「管理员已离职」 back into a silent 「还没人认领」.
- **`feishu_admins_synced_at` is a separate timestamp from `last_synced_at`.** Older sweeps DID sync these
  definitions — without asking for admins. Without its own column, "asked, none named" and "never asked" are
  indistinguishable, which is exactly the confusion that caused the bug. A failed definition read never
  clears the stored list: one Feishu rate-limit would otherwise put 「还没人认领」 back on the all-staff page.
- **Admin email matching spans `pingcap.com` / `pingcap.com.cn`.** 8 of 37 real admins are registered under
  the sibling domain; exact-match-only dropped them, including two flows' only admin.
  See `reference_feishu_email_domain_com_cn_mismatch`.
- **Inference requires a dominant, well-evidenced approver** (≥50% share AND ≥3 decisions in 365 days,
  pending tasks excluded — a pending task says the flow was routed to them, not that they decided anything).
  A "line manager approves" flow spreads its decisions over dozens of people; naming the busiest of them
  would put someone with no say over the flow on the all-staff page. Below threshold the row stays 待认领,
  and the evidence behind every inferred name is stored in `inference_meta` so a human can judge it.
- **An owner who has left is not coverage.** Assignment refuses a non-active person (409
  `APPROVAL_FLOW_OWNER_NOT_ACTIVE`) and inference refuses to name one; `coverage_summary` puts them in their
  own `owner_departed` bucket. The `?ownerless=true` filter carries the same meaning as the KPI tile.
- **Definition sync never subscribes.** `get_approval_definition(code, subscribe=False)` — these are other
  teams' flows, and subscribing would point their instance events at Helix's webhook: thousands of payloads a
  day no handler consumes. The platform's own 18 flows still subscribe (default unchanged).
- **The sweep is three-phase.** Read the roster → materialise to plain tuples → commit → do the Feishu
  fan-out with no transaction open → write. A sweep holding its pooled connection across a multi-minute
  vendor fan-out comes back to a socket TiDB has reclaimed and loses the whole run
  (`cmdb.server_metrics`, 2026-08-10).
- **Instances are counted by distinct instance code**, not by row: one instance surfaces once per
  participant, so counting rows makes a flow with many approvers look busier than one with many applicants.
- **Truncation is reported.** Per-person page ceilings and per-person failures land in the run summary
  (`people_truncated` / `people_failed`) instead of a partial sweep presenting itself as complete.
- **The published payload is a field-by-field allowlist**, and it carries `owner_state` — an employee reading
  the page can tell a confirmed owner from a machine's candidate rather than being handed a name with
  unstated confidence. Sync bookkeeping and inference evidence stay on the admin surface.
- **An employee can contest ownership but not edit it.** `POST /api/v1/me/approval-flows/{id}/dispute` writes
  `owner_disputed` onto the flow's own trail and pushes to active `global_admin` holders. It deliberately
  does **not** clear the owner: letting one person's say-so blank the register would make a contested flow
  vanish from the coverage read instead of surfacing for IT to re-point.
- **Three surfaces, one grouping.** `/admin/approval-flows` (governance), `/approval-flows` + the handbook's
  `live-approval-flows` table (all-staff), `/my/approval-flows` (+ `/m/` twins). Categories are grouped
  **server-side** so the three cannot invent different taxonomies. The published payload carries
  `owner_state`, which is what lets a reader tell a confirmed owner from a machine's candidate — a name
  handed over with unstated confidence is how a guess becomes folklore.
- **The row-level owner control is on the row.** The system registry learned this the expensive way: its
  ownerless KPI sat visible for weeks because the only control that fixed it was two levels down in a drawer
  behind a button labelled 「转交」. Here 「补登负责人」/「确认·改派」 is a link on the row itself.
- **Writing to Feishu is not available, and the register says so rather than pretending.** There is no update
  endpoint for an approval definition: `process_manager_ids` can only be set at create time, and
  create-with-an-existing-code returned `99992402` on two different documented body shapes when probed
  (2026-08-26). More importantly, **definitions created through the API can never be deactivated or deleted**
  — Feishu documents this explicitly ("通过该 API 创建的审批定义,无法从审批管理后台或以 API 方式停用、删除")
  and also states the API is not recommended for self-built apps. The tenant already holds two undeletable
  probe definitions as proof. So the registry never creates or edits definitions: IT creates them in the
  Feishu console, the owner maintains them there, and the nightly definition sync brings name / status /
  deactivation back. A rename or a stop shows up on the all-staff page without anyone touching Helix.
- **The half-yearly round is an object, not a query** (`registry_flow_confirmation_rounds` /
  `..._items`, migration 556). 「2026-H2 那轮做完了吗、完成率多少、谁没回」 cannot be answered from
  `owner_confirmed_at`: a flow registered after the round opened was never asked, and one whose owner changed
  mid-round was asked of somebody else. An item's `owner_person_id` is a snapshot of **who was asked**.
  Five item states, and the distinctions carry the meaning: `pending` / `confirmed` / `transferred`
  (answered — the round measures whether a human engaged, not whether the name stayed) / `lapsed` (closed
  with no answer — deliberately not folded into `pending`, because at close time that difference is the
  entire finding) / `dropped` (the flow was archived or the person asked has left; **excluded from the
  denominator**, since chasing an answer about a retired flow is not a governance gap). `final_stats`
  freezes the counts at close so a transfer in March cannot rewrite December's completion rate.
- **The opening cron never acts retroactively.** Daily, but only inside the seven days after June 1 /
  December 1 — the storm guard migration 352 established for the half-yearly access self-attestation, for
  the same reason: the first thing a mid-cycle deploy would otherwise do is push a card to every owner in
  the company. A missed window is re-opened consciously through `POST /rounds/open-now`.
- **One card per person, never one per flow**, and the card carries a no-login 「全部确认」 button
  (`ACTION_TOKEN_PURPOSE = approval_flow_confirm`, 45-day TTL). GET renders, POST mutates — chat previewers
  and mail clients fetch links, and a GET that confirmed ownership would have the platform answering on the
  owner's behalf. Both halves are idempotent. Flows with **no** owner are not in the round at all: there is
  nobody to ask, they are already counted by the coverage read, and including them would depress a rate that
  measures answering.
- **Declining requires a successor.** 「不是我」 without naming who leaves the register no better off than
  before it asked, and the register cannot guess. The successor lands as `manual`, **not** `confirmed` — a
  handover is IT's decision about who should answer, not the new owner's own statement that they do.
- **Self-service answers route through the open round** (`confirmation.record_self_answer`). Two paths would
  let the campaign report somebody as unresponsive while their answer sat in the register.
- **Escalation copies the manager, and does not ask them to answer.** Fourteen days, deduplicated per
  person; the lead cannot answer for the flow, but an unanswered governance item is exactly what one
  conversation unblocks — and the real reason for silence is often 「这本来就不该是他」.
- **Offboarding blocks on flow ownership** (`RES_APPROVAL_FLOW_OWNER`, advisory → force-transferred at
  T+0+24h to the manager; there is no backup-owner slot to prefer). Handover goes through
  `core/coordinators/approval_registry_coordinator.py`.
- **The assistant answers 「该走哪个流程、找谁」 from this register**
  (`app/assistant/flow_grounding.py` + the copilot's `find_approval_flow` tool). Two mechanisms, because a
  tool alone was demonstrably not enough: the model has to think of asking, and when the knowledge base
  comes back empty the natural next move is to offer a ticket. So the register is **pre-fetched into the
  system prompt** for any 「我想…怎么办」-shaped question, the same preflight pattern the buyback policy uses.
  The motivating transcript: an employee asked 「我想加入 git 仓库怎么办」 and got 「知识库中没有关于如何加入
  Git 仓库的说明文档」, while the register held *Request to join the GitHub organization* with a named owner.
- **The grounding orders, it does not filter.** The question is Chinese and the flow name is often English —
  they share no tokens, so any keyword filter would drop the correct answer. It hands over a bounded
  catalogue (60) and lets the model match on meaning, which is the part it is actually good at.
- **`owner_state` travels with every name the assistant repeats.** An inferred owner is introduced as
  「平台推断，尚未本人确认」, a departed one sends the employee to IT, and an unclaimed flow says so instead of
  guessing. Handing a colleague's name to the whole company without saying how sure the register is, is how
  a machine's candidate becomes folklore.
- **A flow that does not exist yet has a front door** (`registry_approval_flow_requests`, migration 557).
  员工提申请 → IT 批 → IT 在飞书控制台建 → 回填 `approval_code` → 台账自动登记、负责人 = 申请人 → 通知。
  Four states matter: `approved` counts as **open**, because an approved request nobody built looks
  finished from IT's side and is an employee still waiting from theirs; only `registered` is done, and it
  is the state that links the request to the real registry row it produced. `purpose` is NOT NULL and
  becomes the flow's published description — the one sentence the next employee reads when choosing a flow,
  written by the person who needed it. A rejection requires a reason (「不批」 without one produces the same
  request again a week later), and a nominated owner who has left is refused at submission rather than at
  registration.
- **The front door and the tracking surface are two separate promises.** 「申请新建审批流程」 is an entry
  in 我的申请 · 通用 (``GENERAL_CATEGORY_ENTRIES``), badged **IT 审批** rather than the siblings' 走飞书 —
  its approver IS IT. Progress is NOT a second page: the source is registered as a provider in the
  cross-domain ``/me/requests`` aggregate (``me_universe.my_requests``), which is what 我的申请 · 记录
  actually renders, so it lands beside every other kind of request the employee has filed — and the MCP
  ``my_requests`` tool reads the same aggregate, so the assistant answers 「我提的流程申请怎么样了」 for
  free. Three rules that seam enforces: ``registered`` must be in ``_STATUS_MAP`` (an unmapped status falls
  through as a literal and reads open on one surface, closed on another); this source must **stay out of**
  ``_APPROVED_IS_TERMINAL_SOURCES`` even though every other ``CAT_GENERAL`` source is in it, because
  ``approved`` means IT agreed and has not built the flow yet; and a provider without a matching
  ``_DETAIL_BUILDERS`` entry hands its own requester a red 「加载失败」 on 查看.
- **The published directory is in 公示信息.** ``/approval-flows`` is ungated and always was, but for its
  first week it had **no navigation entry at all** — company-wide published information reachable only if
  somebody sent you the link. It sits beside 法定假期 for the same reason: that is what it is.
- **Registration adopts, never collides.** If the nightly sweep already discovered the flow, filling the
  code in re-points that row instead of failing on the UNIQUE index — the sweep and the person are
  describing the same object, and failing would make IT choose between them.
- **This request deliberately does NOT go through a Feishu approval template.** The other 18 flows do
  because their approvers are the business; this one's approver is IT, and what IT does next is manual work
  in the console — routing it through Feishu would mean creating one more approval template in order to be
  allowed to ask for approval templates.
- **Six invariants the post-delivery review had to put back** (2026-08-26, all silent failures):
  notification helpers resolve people **by id**, never through this domain's ``lazy="raise"`` relationships —
  they run inside best-effort try/except blocks, so a relationship the caller happened not to eager-load
  fails invisibly (the 「流程建好了」 push never reached a single requester whose flow was newly created).
  Confirming an item **re-checks that the flow still belongs to the answerer** — otherwise the card's bulk
  button writes the original owner back onto a flow IT re-pointed mid-round, silently taking it from its new
  owner. Escalation measures from ``notified_at``, never from the round's open date, so a card the platform
  failed to deliver is never chased to the owner's manager. And the one-click page sets its **own** CSP
  (``style-src 'unsafe-inline'`` + explicit ``form-action 'self'``, no ``script-src``): the platform default
  ``default-src 'none'`` otherwise strips its inline styles, exactly as it did for provisioning
  quick-complete and the support rating page.
- **A handover announces itself, and the announcement lives in the setter.** ``set_owner`` and
  ``create_flow`` both take ``announce`` and **default to announcing**: the person who just became
  responsible gets a card naming who handed the flow over and why. Without it somebody becomes accountable
  in silence and first hears about it from a confirmation card six months later. Wiring it per call site
  was tried first and **missed one** — between rounds there is no confirmation item, so
  ``record_self_answer`` writes the register directly and that path stayed silent. Defaulting to announce
  inverts the failure: opting out is a deliberate ``announce=None`` with a stated reason (four of them —
  「还是我」 is not a handover; the paste-import IS how this register was seeded; both request-registration
  branches send ``flow_request_registered``, which says the same thing and more). **Nightly inference never
  reaches either function** — ``apply_inferred_owner`` writes the columns directly — so the first night
  after deploy does not card every person with an inferred flow, which is the 2026-06-12
  onboarding-escalation storm. Tests pin 「推断不发信」 and 「批量导入不发信」. The offboarding handover is
  **one card for the whole handover**, not one per flow (this domain's own rule, the same one the half-year
  round follows), bounded at 12 named flows with the overflow stated rather than trimmed. The card also
  carries the half of the job the register **cannot** do: the flow itself lives in the Feishu console, so
  changing or retiring it means asking IT for admin there. Living in the setter costs the router's
  「先 commit 业务，再通知」 ordering, so the push is **non-fatal and savepoint-wrapped**: a push must never
  undo the thing it announces, and a merely-swallowed outbox flush would poison the session so the caller's
  next statement dies with an error about something it never did.
- **A per-recipient value is a ``LocalizedText``, not a ``{key}_en`` the English body never references.**
  The catalogue is three-language but the placeholder VALUES are producer-built single-language strings;
  ``_localized_context`` resolves them per recipient. Getting this wrong ships an English card with a
  Chinese sentence inside it — a production render of the outbox found exactly that on the offboarding
  cards. Two tests hold the line: the rendered English body must contain **no CJK at all**, and every
  template must carry ``_en``/``_ja`` variants whose placeholder sets match the base field's.
- **The discovery sweep isolates per flow.** One definition whose code or name fails validation used to
  raise straight out of the write loop, so a single malformed row from Feishu meant none of the other forty
  refreshed that night and the cron went red. Skipped rows are counted into ``flows_rejected`` and shown on
  the sync panel: 「发现了 40 条」 and 「发现了 41 条、丢了 1 条」 are different statements.
- **One definition per fact.** 「需要你确认」 on the employee's page derives from the same ``owner_state`` the
  admin filter, the published payload and the assistant read. It was briefly a second hand-rolled rule, and
  the two disagreed about a confirmation past its validity horizon: the admin chased it while the person
  being chased was told nothing was due.
- **Not a mirror of Feishu 审批.** No nodes, no form structure, nothing from an instance body. Those carry
  HR / finance / legal payloads Feishu already owns and protects. `app.approval` remains the transactional
  integration for the platform's own flows; this domain is the inventory of all of them. They share the
  Feishu client and nothing else.


### 12.18 GitHub organizations — GitHub 组织 (2026-08-29, migrations 566 / 567 / 569 / 570 / 579 / 583)

**Tables**: `github_orgs` (registry; carries the seat snapshot
`seat_plan/filled/total/checked_at` since migration 583),
`github_org_memberships` (ledger; carries `github_profile` +
`external_confirmed_at/by/note` since 583), `github_org_membership_events`
(append-only history), `github_org_join_requests`,
`github_org_auto_join_rules`.

**Routes**: `/api/v1/github/orgs` (list / get / create / patch / retire) +
`/api/v1/github/memberships` (list, filterable by org / state / person / **unlinked_only**) +
`/api/v1/github/memberships/{id}/events`, plus the self-service surface
`/api/v1/me/github/{orgs,memberships,requests}` (+ `requests/{id}/cancel`).
Governance surface (2026-08-31): `GET /api/v1/github/action-items`,
`POST /github/memberships/{id}/remove`, `POST /github/memberships/{id}/link`,
`POST|DELETE /github/memberships/{id}/confirm-external`,
`GET /github/link-suggestions`, `POST /github/governance-digest/preview`.
MCP: `list_github_orgs`, `lookup_github_org_member`, `list_github_org_drift`
(governance), `list_my_github_orgs`, `list_github_org_options`,
`request_github_org_access` (self-scope), plus `list_github_action_items`,
`remove_github_org_member`, `suggest_github_member_links`,
`link_github_member_to_person`, `confirm_github_member_external`
(2026-08-31).

**GitHub ID 自助修改 (2026-08-30, migration 579, +5 endpoints / +5 MCP / 0 新角色)** —
上面五张表全都靠 `identity_persons.github_id` 认人,而那一列此前**只在入职采集、之后没有任何
更新入口**(生产 417 名在职员工中 154 人没有 handle)。第六张表 `github_id_change_requests`
是那个入口:员工在 `/me/settings` 基本信息提交 → 自己的 **HR Ops**(`hris_hrops_person_id`,
缺失回退 **HRBP**,两者都无则落到 HR / global_admin 的兜底队列)确认 → **先写 SF、再写 Helix、
再推飞书**。路由 `/api/v1/me/github-id`(state / requests / cancel / approvals / decide),
MCP `get_my_github_id` / `change_my_github_id` / `cancel_my_github_id_change` /
`list_github_id_changes_to_confirm` / `decide_github_id_change`。审批面在桌面「我的待办」的
`?tab=githubId` 与移动 `/m/github-id/approvals`(飞书卡片深链两边都直达)。**发起入口两处**:`/me/settings` 基本信息,以及「我的申请 → 创建申请」目录的「通用类」卡片(2026-08-31 补 —— 上线后第一条反馈就是「找不到入口」:申请记录出现在「申请记录」页签,而发起入口不在同一页)。两处共用 `components/GithubIdChangeModal.tsx`,组件自己拉状态,有在途单时把「提交」换成「撤回」。第三个面是 IT Agent:说「我要修改 github id」由 `change_my_github_id` 接住(生产语义检索实测 5 种说法 4 种排第一、1 种第二)。

* **落地编排在 `core/coordinators/github_handle_change_coordinator.py`**,不在域里 ——
  它同时动 SF、身份黄金记录和 identity-sync 投影。与 `hris_person_refresh_coordinator`
  互为镜像:那条是 HR 在 SF 改 → SF→Helix→飞书,这条是员工在 Helix 改 → Helix→SF→飞书。
* **顺序是 SF 先、Helix 后**,因为 SF 是这个字段的上游;写反了会留一个「同步会把员工的改动
  revert 掉」的窗口。SF 拒了/连接器没配时变更**仍然落地**(员工已经被确认过了,而飞书是他真正
  看得见的那一面),失败记在 `hris_write_status='failed'` 上等人手工改 SF,
  `github_id_locked_at` 在这期间兜住不让同步冲掉。飞书与其它 event-aware 目标走
  `enqueue_person_sync` 的后台扇出,**必须在 commit 之后**广播 —— 它开自己的 session,
  commit 前调它读到的是旧 handle。
* **SF 那一跳是配置驱动的**(`platform_settings.hris.github_writeback`:`entity` / `field` /
  `key_field` / `effective_dated` / `effective_start_date` / `extra_fields`)。`cust_Other` 是
  自定义 MDF 对象,它是否 effective-dated、API 用户能否为「还没有这一行」的人新建、对象上还有
  没有别的必填字段,三个答案都在 HR 的 SF 管理员那里而每个都是一次 payload 变化 —— 做成配置
  意味着答案回来是改配置,不是改代码再发一次版。**开关默认关**(与默认开的邮箱回写相反):
  对 effective-dated 的 MDF 对象,缺 `effectiveStartDate` 的 upsert 不一定被拒,可能建出一条
  起始日期错误的时间片 —— 「有写权限」不足以开始写,必须有人读过 HR 的答复再显式打开。在此
  之前每次通过记 `skipped`。多实例路由复用抽出来的 `identity/hris_client_routing.sf_client_for_person`
  (EU 那 31 个人必须走 EU 租户,写错租户不会报错、只会静默什么都不做)。
* **handle 撞车直接拒**:`resolve_person_by_login` 与 `reconcile` 都按
  `lower(trim(github_id))` 反查人,返回形状只容得下一个;重复会让台账把成员资格接到错的人
  身上,离职时摘错账号。占用只看在职的人,并在**写 SF 之前**重查一次(提交到审批可能隔几天)。
* **`github_exists` 是三态**(存在 / GitHub 明确 404 / 没问到),经
  `driver_access.resolve_orgless_driver` 求证一次并存下来(逐行现问 = 一次翻页 N 次调用 +
  撞限流)。三态不能塌成两态 —— 把「没问到」画成「查无此人」,一次 GitHub 抖动就换来一次冤枉的
  驳回。它只作提示,不参与任何闸。

**RBAC**: `can_view_github_org` (global_admin / **github_admin** / auditor / security_officer /
management) for reads; `can_manage_github_org` (**global_admin + github_admin only**) for writes.
`meta.can_manage` on the list response is what decides whether the frontend renders write buttons —
a read-only role must not be shown a button that necessarily 403s.

**Why it exists.** The Feishu flow 「申请加入 GitHub 组织」(`AAF99056-F989-4D8C-93E4-321CB602CBA6`)
ends in two **human handler** nodes: somebody clicks invite on GitHub, the applicant clicks accept.
Helix saw none of it. The cost lands on the offboarding side: the platform did not know who was in
which GitHub organization, so **offboarding had never reclaimed a single org membership**. A leaver's
access to private repos across the 5 orgs (pingcap / tikv / pingcap-inc / PingCAP-QE / tidbcloud)
depended on somebody remembering to remove them by hand.

**The ledger's natural key is `(org_id, github_login_lower)` — NOT `(org, person)`.** The upstream fact
is stated in handles, and a handle does not always map to a person (outside collaborators, machine
accounts, people whose handle we never recorded). Keying on the person leaves "on GitHub, unidentifiable"
with nowhere to live except the bin — and that population is exactly what automatic removal cannot
reach. Keyed on the handle it is an ordinary row with `person_id IS NULL`, counted as its own KPI.

**Three states, not two, on the "not yet in" side** (the third added by the
2026-08-29 post-merge review). `pending_invite` = an invitation WAS sent and is
waiting to be accepted. **`awaiting_manual`** = the access is approved but **no
invitation was ever sent** — no credential is configured and a human has to
click it. Sharing one value between them broke two consumers silently: the
invitation sweep judges a pending row by "absent from GitHub" + **elapsed
time**, so a manual-fallback row got ruled `declined` (a **non-resendable**
terminal state) within 7 days; and member reconciliation read "absent from
GitHub" as "removed upstream", quietly marking a to-do as done while the person
never actually got access. `awaiting_manual` is in `MEMBERSHIP_LIVE_STATES` (IT
may already have clicked it) but carries **no `invitation_expires_at`** — there
is no invitation, so there is no expiry. The fix is a state rather than an `if`
in each sweep, because an `if` only protects the consumers that exist today.

**"Does this person have access?" is answered per PERSON, never per handle**
(`service.find_person_membership_in_org`). Somebody who joined as handle A and
later re-registered as handle B is still in the org; asking by handle B misses
row A and re-invites them, leaving two memberships for one person. "What state
is this handle in" is the per-handle question and keeps `find_membership`.

**`pending_invite` and `active` are different states.** GitHub org membership is invitation-based: the
API call does not put anybody in the org. Collapsing them would both overstate who has access and make
offboarding revoke a membership that does not exist. `expired` (resendable) and `declined` (not) are
distinct for the same reason.

**`github_orgs.feishu_option_value`** holds the Feishu radio-widget option value
(`l9i7g355-les25siqrij-0` shape) that Helix must echo verbatim when it raises the approval. Neither
derivable nor stable, so it is a data row: "Feishu added a new org option" becomes an edit, not a
release. Empty ⇒ the org is refused at the self-service submit gate rather than producing an instance
Feishu would reject.

**Auto-join rules match department by SUBTREE**, unlike `provision_policies`' exact equality
(`app/core/github_org_match.py` is the canonical matcher, mirroring `app/core/provision_match.py`).
Comparison is by path SEGMENT, so `Engineering` matches `Engineering / TiDB` but not
`Engineering Support` — the same trap the external API's department filter had to fix once already.

**Request chain (2026-08-29, migration 569).** Self-service submit at
`/api/v1/me/github/requests` raises the company's **existing** Feishu approval
definition (`AAF99056-…`) rather than a new one — that definition's org-owner
nodes route by the selected organization, which is the only genuinely valuable
part of it and exactly what a rebuild would break. Widget ids live in
`integration_approval_flows.payload_schema`, not in code, so a Feishu form edit
is a data change.

**Both origins share ONE execution branch.** `accept_external_origin` stays ON
for this flow: the Feishu form entry does not vanish on day one and in-flight
instances exist. Helix-initiated requests carry a local
`github_org_join_requests` row; Feishu-submitted ones do not (the parent row is
synthesized and `(org, handle, person)` is parsed from the form — display text
`github.com/tidbcloud` AND the internal option value are both accepted). They
converge on `execution.execute_approved`. **Without the external half,
Feishu-submitted requests silently get no automation during the transition —
which is the exact failure this whole thing exists to remove.**

**Four outcomes, not two** (`domains/github_org/execution.py`): invited /
already-member / **manual** / failed. `manual` (no credential configured) is
NOT a failure — the approval did pass and the access is owed, only the step is
human; recording it as failed would make week one look like a string of
outages, and skipping the ledger row would hide the grant from offboarding
removal. A **failed** invite writes **no ledger row at all**: an invitation that
was never sent is not a membership, and writing one leaves a record offboarding
will later try to revoke.

**`github_org.invitation_reconcile` (cron, every 4h)** is not optional
bookkeeping. GitHub org invitations expire after 7 days and GitHub **sends no
webhook and leaves no trace** when they do — without this sweep a
`pending_invite` sits forever while the UI says "invited" and the person never
learns they missed it. Expiry and decline are told apart **by time**, because
GitHub does not distinguish them: past TTL ⇒ `expired` (auto-resent once, and
the person is notified); still inside TTL but absent from the pending list ⇒
`declined` (**never resent** — repeatedly inviting somebody who declined is
harassment, not service). Resends are capped at 50 per run for blast radius,
and hitting the cap logs a warning rather than truncating silently.

**Driver access is resolved in ONE place** —
[`domains/github_org/driver_access.py`](../../server/app/domains/github_org/driver_access.py)
`resolve_org_driver(db, org)` returns either a driver or a reason
(`not_configured` / `disabled` / `broken`) with a sentence an admin can read.
It deliberately does **not** raise: credentials-not-yet-configured is an
expected operating state (registry first, credentials later is the normal
rollout order), and expressing it as an exception forces every caller into a
try/except, one of which will eventually swallow a real fault along with it.
The org's own `integration_id` wins; with none set it falls back to the
**single** enabled `kind='github_org'` integration — but with two or more it
refuses to guess, because two credentials covering different org sets is a
legitimate configuration and picking wrong means adding somebody to the wrong
organization. `GET /github/orgs` surfaces the result per row
(`automation_ready` / `automation_reason` / `automation_detail`) so "this org
still falls back to manual" is visible on the list rather than discovered when
the first real invitation fails.

**A departed employee still inside a non-exempt org is a FAILURE signal, not a
statistic** (`count_leavers_still_in_orgs`, surfaced as the admin page's loudest
KPI and in `list_github_org_drift`). It should be permanently 0 — this whole
domain exists to make it so. Two real paths produce a non-zero value and **both
bypass offboarding**: somebody re-added a leaver on GitHub by hand, or the
membership had `person_id IS NULL` at the time they left (unidentifiable, so the
offboarding scan never saw it) and reconciliation linked it afterwards. Each one
gets its own `failure` audit row — a count tells you there are three, only the
rows tell you WHO. Exempt community orgs are excluded: counting them would train
people to ignore the number.

**Bidirectional reconciliation (2026-08-29, migration 570,
`github_org.member_reconcile` daily 04:40).** The invitation sweep only advances
invitations **we** sent; this one covers the two directions governance actually
cares about: **on GitHub but not in the ledger** (somebody was added outside
Helix — not an error, org owners can do that, but a membership Helix does not
know about is one offboarding cannot revoke) and **ledger says active, GitHub
says no** (removed outside Helix; the ledger must follow or it keeps asserting
access that no longer exists).

It also **back-fills `person_id`**: a row whose handle now matches somebody's
`github_id` (because they registered it later) gets linked with no human coming
back to it. ⚠️ **This does NOT make the existing "unidentified" pile shrink on
its own** — an earlier version of this paragraph claimed it did, and the claim
was measured false: of the **217** unidentified handles in production, **0**
normalize to any corporate email prefix (they look like `Ehco1996` / `zz-jason`
/ `hawkingrei`). Back-fill only ever rescues people who register a handle
*later*. Clearing the backlog is what §12.18's governance surface is for.
It also **refreshes seat usage** on the same pass (see below) — same credential,
same org list; a separate cron would only be a second place to disagree.
The lookup honours **`github_id` only**, never the "email local-part ==
handle" guess the auditor domain uses: there a wrong guess costs one mismatched
MFA row; here it would attach a membership to the wrong person, and that
person's offboarding would then revoke **somebody else's** access.

**The empty-fetch brake is not optional.** "GitHub returned an empty member
list" and "this org genuinely has nobody" are indistinguishable at the HTTP
layer. Without the brake, one org rename / one scope revocation / one API
behaviour change marks every member of an org removed — **and that table is
what offboarding reads**. So: 0 members AND 0 pending invitations from a
SUCCESSFUL fetch, while the ledger holds ≥ `EMPTY_FETCH_BRAKE_MIN_LIVE` (3) live
rows ⇒ refuse, and record why **on the org row**, not only in the log. Threshold
3 rather than 1 because a 1–2 person org emptying out is plausible.

⚠️ **A removal that just succeeded can still read back as `active`.** Measured
2026-08-31 removing a real leaver from `tikv`: `DELETE /orgs/tikv/memberships/
{login}` returned **204**, and an immediate `GET` of the same path still said
`state: active`. Eight seconds later all three independent endpoints
(`/memberships/{login}`, `/members/{login}`, the paged member list) agreed it
was gone. GitHub serves those reads from a replica, so **an immediate read-back
is not a verification** — it is the mirror image of the "just-created resource
404s" trap in [[reference_google_workspace_404_window]]. Nothing in this domain
re-reads after a write (the driver trusts the 204 and the ledger is what the UI
refreshes from), which is why this is an ops note rather than a bug: anyone
verifying a manual removal by hand must re-check after a pause, or ask more
than one endpoint, before concluding the removal did not take.

**Handle labels — a maintainable taxonomy, attached to the HANDLE
(2026-09-01, migration 586, +5 endpoints, +2 MCP).** 585's "two-value enum +
free-text note" does not survive contact with "we also want to label external
people going forward". The enum is **mutually exclusive** (a handle can be both
"works at ByteDance" and "TiDB community maintainer"), and free text **rots** —
three people labelling one company write `ByteDance` / `字节` /
"bytedance guy", after which "pull up every ByteDance person for the annual
review" can never be done. A controlled vocabulary is the only thing that keeps
filtering working; the price is that creating a label must be trivial, or people
go back to the note field.

🔴 **585 also attached the classification at the wrong level, and 586 fixes it
while it is still free.** Measured: 217 unidentified handles across 331 ledger
rows, with `sre-bot` / `ti-chi-bot` / `crazycs520` / `Mini256` / `lance6716`
each in 5 orgs — and **0 rows confirmed external, 0 notes written**, so the
model change costs nothing today and would be a data migration later.
"What kind of account is this" is a property of the **handle**: attaching it
to the membership means one classification takes 5 writes, and 5 rows drift (someone
fixes 2 and misses 3, invisibly). Labels therefore key on
`github_login_lower`; attaching `account:bot` / `account:outside_human`
confirms-external every live membership of that handle, so 585's two columns
become **derived** rather than a parallel truth.

Four categories, each answering a different question — more categories and
people hesitate about which one, then skip it: `account:*` (machine vs outside
person; drives the governance queue), `employer:*` (who they work for; what an
annual review filters on), `community:*` (open-source standing; what decides
whether a leaver may keep access), `purpose:*` (what a machine account is for).
11 seeded, because **an empty vocabulary goes unused**.

Rules generalise from "bot only" to "any label" (`handle_glob` and
`profile_company_contains`), still suggest-only: `robot-liu` matches `*robot*`,
and a profile saying "PingCAP" may be a FORMER employee.

**One store, not two.** 585's `bot_handle_patterns` and 586's `label_rules`
would inevitably disagree — someone adds `*-runner` to the rules while the bot
card still reads the old setting, and one question has two answers in two
screens. So `bot_policy.get_patterns()` now DERIVES from the label rules
(filtering `label=account:bot`, `match=handle_glob`) and `set_patterns()` writes
back into that same store while preserving every other rule. The bot-specific
UI card was replaced by the general label panel.

**A rule can be turned into an automatic writer, but only by a human, and
only next to a number (2026-09-01, migration 588, +3 endpoints, +2 MCP).**
Rules always *suggest*; `enabled` decides whether one also writes labels by
itself, applied at the tail of the daily member reconcile (no new cron — labels
derive from the ledger, and the count lands in the job summary as
`labels_auto_applied`). 586's nine seeded rules carry no `enabled` key and
`get_rules` reads them as **off**; the opposite default would have set nine
heuristics writing to production the moment this shipped, with nobody having
agreed to it.

🔴 **`account:*` can never be enabled** — `set_rules` raises. Those labels
confirm every live membership of the handle as external, which is *terminal*:
the row leaves the governance queue and offboarding reclaim can no longer reach
it. Auto-marking an employee a machine account means they are not reclaimed when
they leave, which is the one thing this domain exists to prevent. The known
counter-example is not hypothetical: `robot-liu` matches `*robot*`.

`rule_impact()` exists because **"enable" without a number is a blind switch**:
the admin would learn whether the rule touches 3 handles or 180 only after the
labels landed. A `value` of `co` matches ClickHouse, Databricks and Microsoft.

`propose_rules_with_ai()` feeds the model the **actual distribution of `company`
strings** on every UNIDENTIFIED handle — labelled or not — plus the vocabulary — 50 distinct spellings in
production (`@pingcap` / `PingCAP` / `Pingcap`, `Antgroup` / `@antgroup`, and
`IPADS@SJTU`, obviously a university with no lexical regularity). Three gates:
invented label keys are dropped (hallucination guard), a `value` under 3
characters is dropped (too short is necessarily too broad), and anything
pointing at `account:*` is dropped, and placeholder company strings
(`JUNK_COMPANY`: `N/A`, `none`, `Home`, `Garden Leave`, …) never reach the model.
Everything it proposes is stored `enabled=false` with a `rationale`.

⚠️ **Proposal input and impact count are deliberately different pools**
(`_identified_pool` vs `_unlabelled_handles`, split 2026-09-01 after the first
batch). Rules are for the FUTURE — the next people to join an org — so scoping
their input to "not yet labelled" lets every successful bulk labelling drain the
proposal feature: right after the first 89 labels landed, only 6 unlabelled
handles still carried a company and all 6 were placeholders, so the model could
only propose a rule matching a single handle. The impact count stays on the
narrower pool, because "would label N" has to mean what auto-apply will actually
touch.

Auto-apply **adds only** and skips any handle a person already labelled: "the
rule changed it back" is what stops people trusting a controlled vocabulary, and
distrust sends them back to free-text notes. There is deliberately **no MCP tool
to enable a rule** — `list_github_label_rules` and `apply_github_label_rules`
exist, enabling does not.

**Retire, never delete**: deleting a label that has been used turns its history
into dangling references, and "what was this person labelled as at the time" is
exactly what an access review looks back at. A retired label leaves the picker
but its existing links still render, flagged as retired.

**The seat-warning threshold is one operational setting, not two
(2026-09-01; the last escape hatch closed 2026-09-08, migration 631).**
`github_org.seat_warn_threshold` (platform setting) → code default
`SEAT_WARN_THRESHOLD = 10`. Both consumers — the weekly digest and the UI's
action-items list — resolve through `seat_pressure()`, so they cannot
disagree. Before this, the cron config moved only the digest while the UI read
the constant: "I changed the threshold" produced *digest silent, UI still
warning*, and nobody would have noticed.

🔴 **The half-fix bit on the digest's very first run.** #3391 kept the cron's
`config={"seat_warn_threshold":N}` as a "temporary debugging" override that
outranks the platform setting — and migration **583 had already seeded
`{"seat_warn_threshold": 10}` into that very row**. So on 2026-09-08 09:30
(first fire) production sent a digest warning about `pingcap-inc` at 4 seats
remaining, while the setting page said **1** and the UI action list — correctly
— showed nothing. Migration 631 strips the key from the row and the handler now
**raises** if it reappears: an override that is silently ignored produces the
same "I changed the config and nothing changed" as one that silently wins.
Lesson for the domain's tests: `test_the_ui_and_the_digest_read_the_same_threshold`
compared `collect_action_items` with `build_digest` — two consumers of the
*same input*. The second answer was produced by the **handler**, which no test
went through. A parity test has to enter from where the inputs are assembled. Production is set to **1** (the admin's
call on 2026-09-01); the constant keeps the engineering rationale for 10 (one
normal onboarding batch eats three seats, so a threshold below that warns too
late — full means new invites fail outright, not queue). A non-integer or
negative value falls back and logs: silently becoming **0** would leave the list
permanently empty, which looks exactly like "all clear" — including at the
moment the org fills up. `collect_action_items` returns the effective
`seat_warn_threshold` so a list that says "seats low" can say what it counted by.

🔴 **`/users/{login}` needs an installation token, never the App JWT
(2026-09-01).** Under `auth_mode=github_app`, `_auth_header(org=None)` returns
the App JWT — correct for `/app*`, and a **401 Bad credentials** on every other
endpoint. Both `/users/{login}` call sites (`get_user_profile` for
name/company/email, `get_user` to resolve a login to a `user_id`) passed
`org=None`, so profile-based identification never worked in production: 217
unidentified handles, **zero** cached profiles, and the two
`profile_company_contains` label rules matching zero handles — which reads as
"the AI suggestions aren't good enough", not as an auth bug. The credential was
never at fault; the same App ran the daily reclaim reconcile successfully the
same morning, and `test_connection` was green because it probes `/app`, the one
credential that does NOT work here.

`_auth_header(org, *, any_installation=True)` mints a token from **any**
installation (`GET /app/installations`, cached under `_ANY_INSTALLATION = "*"`;
org names cannot contain `*`). Deliberately not parameterised by org: any
installation can read public profiles, and forcing callers to name one makes
"read this person's public profile" depend on an unrelated argument that will
eventually be an org the App is not installed on. `test_connection` now probes a
public profile too and reports `detail.profile_readable` — a failure there is
NOT fatal (profiles only affect identification, not invite/reclaim), but a green
light has to say what it actually verified.

**The vocabulary lives on the ledger, not only in the governance queue
(2026-09-01, +2 query params, 0 migrations).** 586 shipped the vocabulary, the
rules and a suggestion queue, and the members ledger did not mention labels at
all — `grep -c labels GithubOrgAdminPage.tsx` was 0. That makes the whole thing
worth nothing for the action it was built for: "pull up every ByteDance person
for the annual review" happens on the ledger, not in a suggestion feed, and a
label you cannot filter by is a note. `GET /github/memberships` therefore takes
`label=` (repeatable, OR across values) and `unlabelled_only=`, each row carries
its handle's labels, and every row has an inline label popover gated on
`can_manage`.

Both filters are **`EXISTS` subqueries, not joins**. A join against
`github_handle_label_links` duplicates a row per matching label, which inflates
`total` and silently corrupts pagination — a handle with two of the selected
labels would appear twice and the page count would be wrong. Pinned by
`test_a_handle_with_two_labels_is_not_duplicated`.

⚠️ **axios serialises arrays as `label[]=a&label[]=b`; FastAPI's
`list[str] = Query()` reads repeated `label=`.** Without
`paramsSerializer: { indexes: null }` the parameter is dropped server-side and
the endpoint returns the FULL result set — the UI then shows "1017 people carry
this label" and nothing anywhere errors. Verified with `axios.getUri()` in both
configurations.

**A membership already tied to a person is never touched by a label** — "is an
employee" outranks any rule. If a label could flip an identified row to
"external", that person would not be reclaimed when they leave, which is the
one thing this whole domain exists to prevent.

**Machine accounts are their own class, and only a human may assert one
(2026-09-01, migration 585, +3 endpoints, +1 MCP).** 583 gave "not yet
identified" one exit — a human confirming "not our employee". That single exit
turned out to hold two different things: an **outside human** holds access and
review asks *do they still need it*; a **bot** (`sre-bot`, `ti-chi-bot`,
`tidb-ci-bot`) will never map to an employee and the question is entirely
different — *is this automation still in use, and who owns its token*. So
`external_kind` (`bot` / `outside_human`) splits them and the report reports
them apart.

🔴 **GitHub's own `type` field cannot be used for this.** The obvious design is
to trust `GET /users/{login}`'s `type` — authoritative, no guessing.
**Measured, it does not work**: all seven machine accounts in this tenant
(`sre-bot`, `TestInfra-Bot`, `ti-chi-bot`, `ti-srebot`, `tidb-ci-bot`,
`tidbcloud-bot`, `pingcap-zendesk-bot`) report **`type: User`**. GitHub's `Bot`
type only covers GitHub App identities; these are human-created accounts used
as bots — a convention of this company, not a GitHub concept.

That leaves naming conventions and profile names, both heuristics. Hence the
policy only ever **suggests and batches**; the write is always human-pressed,
and there is deliberately **no scheduled auto-marking**. `robot-liu` matches
`*robot*`, and labelling an employee a machine account means they are not
reclaimed when they leave — the same silent access leak as attaching a
membership to the wrong person. A test asserts that `robot-liu` DOES match,
precisely so any future attempt to add an auto-marking cron has to face the
counter-example first.

Patterns live in `platform_settings.github_org.bot_handle_patterns` (seeded by
the migration, editable by `github_admin`) because new bot naming conventions
appear and that should not wait for a release. An **empty policy is refused**,
and a missing/malformed row falls back to the built-in default with a warning:
an empty policy silently matches nothing, which is indistinguishable from
"there are no machine accounts".

**Overview report — 「做成了多少」 (2026-09-01, 0 migration, +1 endpoint,
+1 MCP).** `GET /api/v1/github/report` + the 「总览」 tab +
`get_github_org_report`. Same underlying data as the action items, opposite
direction: the to-do list answers *what needs doing today*, this answers
*did we finish this*.

**The headline is `by_source`, not the member count.** The total (1016)
barely moves and says nothing. What says something is where the memberships
came from: `import` / `reconcile` are rows Helix merely **found** — they are
the proof that no process governed this before — while `approval` /
`auto_join` are what the automation actually delivered. Production is at
**0% automated** today: all 1016 are discovered. That ratio moving is the
real delivery metric. `automated_pct` is `null` on an empty ledger, never
`0.0`: "unknown" and "ran but covered nothing" imply different next steps.

**The trend reads the append-only event stream**, never current state —
inferring history from state erases "joined then left", which is exactly the
case worth seeing.

**Cross-surface consistency is enforced by test**, because this domain has
already been bitten by the opposite: the report's "not yet identified" must
equal `count_unlinked`, its "leavers still in orgs" must equal
`count_leavers_still_in_orgs`, and seats must go through the same
`seat_remaining`. Two tabs showing two numbers for one thing is how a reader
stops trusting both. Deliberately NO composite "health score": a 0–100 number
cannot be reversed into an action, and it needs re-explaining every time it
moves.

**Governance surface — 「有问题」到「有人能点」 (2026-08-31, migration 583,
`github_org.governance_digest` weekly Mon 09:30, +6 endpoints / +5 MCP).**

Launch day measured two live problems: `leavers_still_in_orgs` was **1** (a
departed employee still in `tikv`) and `pingcap-inc` had **3 seats left of
263**. Both were already in the ledger, in audit rows, and on a KPI card — and
**nothing would tell the person who should act, and no screen could act on
them.** "There is dispatch code" is not "somebody received it", and "there is a
number on a page" is one step short of "somebody saw it": it needs a human to
open that page. So: an 「待处理」 tab (now the page's DEFAULT tab), one-click
handlers, and a weekly digest to `github_admin` + `global_admin`.

Four kinds, ordered by the consequence of NOT acting (never by count):

| kind | what | who acts | the button |
|---|---|---|---|
| `leaver_still_member` | departed, still in a non-exempt org | github_admin | **remove** (same path as offboarding reclaim) |
| `seat_low` | seats nearly gone | org owner / billing manager | opens `billing_url` on GitHub |
| `awaiting_manual` | approved, no credential, invite never sent | github_admin / IT | opens the org's people page |
| `unlinked` | on GitHub, not yet matched to a person | github_admin | **link to a person**, or **confirm external** |

`collect_action_items()` is the SINGLE source for both the tab and the digest —
two implementations of "what counts as a to-do" drift, and that drift shows up
as "the UI has it, the weekly mail doesn't" with nobody noticing.

**`external_confirmed_at` is what makes the queue converge.** After the import
there were **331** memberships nobody could identify. Sampling their GitHub
profiles showed a large share list another company, a foundation, or a
university — **they are not employees and never will be identified.** With no
terminal state for that answer the count is permanently non-zero, and a to-do
count that never reaches zero is a to-do count nobody reads by week three. So
「認不出是谁」 splits into **not yet identified** (needs a look) and **confirmed
external** (a person judged it; leaves the queue, stays in the ledger — external
collaborators do hold access, offboarding reclaim simply never applied to people
outside the HRIS). `count_unlinked` excludes them; `external_confirmed` is
reported separately as the **progress** counter. Linked and confirmed-external
are mutually exclusive and enforced both ways: `confirm_external` refuses a
linked row (409), `relink_membership` clears the flag and records
`cleared_external` on the event.

**Handle matching is profile-first, and that is a measured decision, not a
preference** (`domains/github_org/handle_matching.py`). The first version scored
the handle string alone and produced **0 candidates for 217 handles**. The signal
is the public GitHub profile: of 30 sampled, **21 carried a real name, 12 a
company, 10 a public email** — one of them literally `yajing.wang@pingcap.com`.
So the pipeline is **enrich → score → AI review → human confirm**: fetch
`GET /users/{login}` (cached on the membership as `github_profile`, 30-day TTL,
≤60 fetches per run), score with profile-email-exact at the top (99) and the
handle-literal tiers demoted below it, then let the LLM review the unsure ones
**constrained to the given candidate set**. A `person_id` outside that set is
dropped as a hallucination — a hallucinated id would render a real "AI
suggestion" button that attaches a membership to a random person.

Three guards worth keeping:

* **Suggestions never write.** Any confidence, still a suggestion. A wrong link
  means that when *that* person leaves, **somebody else's** access is revoked —
  the cost is not symmetric with "a human clicks once".
* **An empty profile is NOT an external signal.** Most people fill nothing in;
  treating "blank" as "external" would mis-flag half the staff, and a
  mis-flagged row means that person is not reclaimed when they leave. Positive
  evidence only (another company's name, or `type: Bot`).
* **A failed profile fetch does not stamp `profile_fetched_at`.** Stamping it
  would freeze that handle as "already checked, nothing there" for 30 days on
  the strength of one rate-limit.

**Seat monitoring only trusts capped plans.** `free` reports
`seats=92 / filled=203` — a literal remaining of **-111**, a permanently lit
false alarm; `enterprise` reports the whole enterprise pool. Both are excluded
(`SEAT_CAPPED_PLANS` = team / business / business_plus), and the serializer
returns `seat_remaining: null` rather than a fabricated number. Seats can only be
bought on GitHub, so the notification and the button both carry
`seat_billing_url` — the actionable link, not a homepage. `invite_member` now
distinguishes a seat-exhaustion 403/422 (`seat_limit=True`) from a permission
403: otherwise an admin goes to audit App permissions when the fix is to buy
seats.

**The digest sends nothing when there is nothing to act on.** A weekly mail that
always arrives saying "all clear" is filtered to a folder by week three, and the
week it matters it is filtered too. It also **commits per recipient** rather
than wrapping the fan-out in one transaction — `dispatch` does external I/O
while holding `identity_persons` FK locks, and this repo has already had logins
browned out that way. Two "sent nothing" outcomes are kept distinct in the job
result: `no_action_items` (working as intended) vs `no_recipients` (a config
accident — nobody holds the role, and the digest has been vanishing silently).
`POST /github/governance-digest/preview` answers "would it send, and to whom"
without sending.

⚠️ **`_ACTION_PATH` is the FRONTEND route** (`/admin/github?tab=actions`, per
`App.tsx`), not an API prefix. Getting it wrong ships a weekly mail whose button
404s while every backend and frontend guard stays green — nothing in the
toolchain cross-checks that literal. A test pins it.

**Bulk import is the same code with one flag** (`apply_removals=False`) — on a
first import Helix knows nothing, so the "ledger says in, GitHub says out"
direction is meaningless. Sharing the implementation is deliberate: an import
path with its own semantics would drift from the daily one.

**`pending_invite` belongs to the invitation sweep, not here** — a member
snapshot cannot tell "declined" from "expired"; only elapsed time can.

**Offboarding removal (2026-08-29).** `RES_GITHUB_ORG_MEMBERSHIP` joins
`IMMEDIATE_REVOKE_RESOURCE_TYPES` — resolved on the FIRST tick, no 24h grace,
for the same reason as `sso_grant` / `vpn_application` / `cloud_access_grant`:
the grace window exists to hand over **hardware**, not to leave RBAC alive past
T+0. **One blocker per membership**, not per person, because each org can have
its own offboarding policy and a merged row could not express "removed from 3,
kept 1".

Two populations deliberately produce **no** blocker: orgs with
`offboarding_policy='exempt'` (community orgs — a leaver may still be a
legitimate maintainer, and the exemption is an org owner's explicit decision),
and ledger rows with `person_id IS NULL` (Helix cannot tell whose they are, so
they never reach anybody's offboarding — a **known boundary** owned by the
reconciliation surface's unidentified count, not a gap in offboarding).
`pending_invite` DOES produce one: that invitation can be accepted at any
moment, and the driver's membership DELETE cancels it in the same call.

`get_offboarding_preview` mirrors the scan **including the exemption** — a
missing line there means the operator reads a false list before pressing the
button, which is the one thing that page exists to prevent.

**A driver failure never marks the ledger removed** — that would leave a false
revocation record, and that record is what everybody later reads to answer
"does this person still have access". The exception propagates to the
per-blocker SAVEPOINT (`last_error_*`, stays `pending`, retried next tick).
**A missing credential also raises rather than returning `(False, None)`**:
both leave the blocker pending and both trip LEAVER_ORPHAN, but only the raise
records WHY — otherwise IT reads "force-transfer should have resolved this"
while the real cause is "credentials are not configured yet". Every leaver hits
this until credentials land, and that is correct: those people really do still
have GitHub access.

**Auto-join (2026-08-29).** `github_org_auto_join_rules` + `domains/github_org/auto_join.py`.
Department matches by SUBTREE and by path SEGMENT — `Engineering` covers
`Engineering / TiDB` but NOT the sibling `Engineering Support` (a bare string
prefix eats the sibling; the external API's department filter had to fix that
once already).

**A matching rule does not by itself put anybody in.** Three gates follow, in
this order: the org's `auto_join_enabled` master switch (so "configure rules,
check the preview, THEN open the tap" is possible), `is_blocked_from_new_grant`,
and **whether the person has a `github_id` on file** — only ~63% of active
employees do. A missing handle is **not a skip**: it is counted separately,
audited (`github_org.auto_join.blocked_no_handle`), and the person is notified
to add it; the next onboarding hook or reconcile then picks them up with no
human remembering to come back. Silently skipping would leave nearly 40% of
people permanently un-joined with nothing anywhere saying so. The rule preview
therefore reports `match_count` AND `without_handle_count` — the second number
is the rule's real landing rate.

**The onboarding hook is wired in TWO places** — `_dispatch_lifecycle_coordinators`
(HR sync / webhook) and **`hire_day_service`'s replay**. The confirm-time call is
gated off by `_is_future_hire` for anybody entered ahead of their start date, so
hire-day is their only second chance; without it every future-dated hire is
permanently never auto-joined. The three onboarding gates (future hire / stale
backfill / HRIS purged) are **reused verbatim from `hr_sync_coordinator`** rather
than re-implemented — each one encodes a real incident, and they have changed
three times.

**Membership writes go through `service.upsert_membership` / `mark_removed` only.** They own two things
that get missed when a state assignment is written inline: every transition appends an event (the
event stream is the only evidence access review has), and `github_login_lower` is written with
`github_login` (the unique constraint lives on the lower column, so touching only the cased one
bypasses it).

---

## 13. Cloud lifecycle (CMDB)

> See [docs/ops/cloud-lifecycle-ops.md](../ops/cloud-lifecycle-ops.md) for runbooks. This section is the architectural reference.

### 13.0 Cloud resource directory materialisation (2026-08-24, migration 529)

[`resource_directory.py`](../../server/app/domains/cmdb/resource_directory.py) materialises every existing
provider-observed CMDB object into `cmdb_cloud_resources`: server, domain, DNS record, certificate, CDN,
load balancer, object storage, IAM identity, and cloud scope. A separate hourly broad-discovery job consumes
explicitly enabled AWS Resource Explorer, Azure subscription Resources, and GCP Cloud Asset inventory and registers unknown kinds directly in
`cmdb_resource_types` by `(provider, resource_type)` without an enum, route, or schema migration. Existing
specialised tables stay authoritative for lifecycle and efficiency workflows;
the directory is the read model for cross-cloud discovery, governance, analytics, reports, and AI.

Resource uniqueness uses `(integration_id, sha256(resource_type + NUL + full_provider_resource_id))`; the full
provider ID remains untruncated TEXT. `cmdb_cloud_resource_changes` records semantic discovered/changed/
disappeared/restored events with bounded snapshots. Observation timestamps are excluded from the content hash,
so a no-op sync creates no history noise. The DB-only `cmdb.resource_directory_refresh` heavy cron runs every
15 minutes to project specialised Helix tables and prune detail events after 730 days. The Provider-read-only
`cmdb.resource_inventory_refresh` runs hourly, keeping high-cardinality tenant inventory away from the faster local loop.
Broad inventory is opt-in per integration; incomplete account/subscription/project fan-out disables disappearance
sweeping for that tick. Provider metadata is recursively credential-redacted and size bounded before persistence.
IAM/key/secret-like kinds enter with `sensitivity=restricted`; other kinds default internal until the policy layer
classifies them more strictly. Migration 529 adds three tables and seeds both crons.

### 13.0.1 Directory visibility, governance and trend layer (migration 530)

Every directory route composes the same `directory_visibility_clause`: global/cloud administrators and auditors
receive full inventory; everyone else is limited to direct resource ownership, active scope grants or scope ownership,
local business/technical stewardship, assigned active findings, and the same relations for real subordinates. The
management and finance roles do not imply full directory access. A denied object is returned as 404, keeping resource
identifiers non-enumerable. Only the three full-reader roles can receive persisted raw metadata/history snapshots;
those values were already recursively secret-redacted before storage. Auditors remain read-only. Existing invoice/list
cost confidentiality is not widened by this layer.

`cmdb_resource_governance` stores optimistic-lock versioned Helix-only stewardship. It never writes tags, owners,
policies or lifecycle state back to a cloud. `cmdb_resource_governance_rules` selects one of a fixed evaluator whitelist
(no expressions, imports or dynamic code); rule scope/config JSON is bounded and secret-like content is rejected.
`cmdb_resource_findings` keeps a stable rule/resource fingerprint, acknowledgement, assignment, due date, suppression
and resolution history. Employees may acknowledge visible findings; suppress/reopen/assign and governance/rule edits
require global/cloud admin and are audited. Expired suppressions reopen, expired governance exceptions stop satisfying
review checks, and findings no longer observed by a supported or disabled rule resolve deterministically.

`cmdb.resource_governance_evaluate` runs daily at 01:43 Asia/Shanghai. It evaluates resources in bounded pages, then
upserts `cmdb_cloud_resource_daily_rollups` for overall, provider, category, provider/category, provider/type and
provider/scope dimensions. These aggregate facts are retained indefinitely while resource-level change evidence keeps
the 730-day policy. The 12 `/api/v1/cmdb/resource-directory` routes expose permission-scoped inventory/detail/history,
type discovery, summary, governance, findings and full-reader rule/rollup administration.

### 13.0.1.1 Unified directory experience and reporting (migration 534)

The desktop cloud menu keeps Cloud Overview as an independent operating view and replaces the separate server,
load-balancer, object-storage and efficiency leaves with one Cloud Resource Directory. Its five URL-addressable tabs
are Inventory, Governance, Efficiency & Cost, Dashboards & Reports and AI Insights; legacy deep routes remain valid.
The inventory UI discovers provider/category/type values from data rather than enums, so a newly materialised resource
kind appears without navigation work. Governance edits, exception periods, finding assignment/suppression and rule
enablement remain Helix-local and never invoke a Provider mutation.

`cmdb_resource_report_subscriptions` stores each person's weekly/monthly/off preference, Feishu/email channels, IANA
timezone, local delivery hour and bounded provider/category/type/integration scope. `cmdb.resource_report_dispatch`
runs hourly at minute 17 and re-loads employment, roles and relation-scoped visibility immediately before generation;
lost access disables delivery instead of sending a stale snapshot. Frozen `cmdb_resource_report_runs` keep aggregate
facts, deterministic interpretation, scope, permission fingerprint and SHA-256 fact hash for 24 months. CSV export
uses the same live visibility clause, excludes deleted resources, caps at 50,000 rows, defuses spreadsheet formulas
and writes an audit event.

Manual and scheduled reports always produce a deterministic interpretation first. Optional Bailian interpretation is
available to every directory reader, receives only bounded aggregate facts after the same live relation-scoped
visibility predicate has been applied, cannot execute actions, and reuses
the same recipient/fact result for 24 hours to prevent repeated model spend. Report delivery is queued through the
notification outbox; a queued timestamp is not misrepresented as Provider delivery confirmation. Six report APIs plus
the hourly job are added by migration 534. Seventeen MCP twins (registered-tool hint 490 → 507) expose the same
permission-scoped inventory, history, type, summary, governance, finding, deterministic-rule, rollup and personal-report
contracts to AI clients; they invoke the HTTP endpoint bodies so visibility, optimistic locking, audit and transaction
behavior stay identical. CSV remains an audited HTTP download rather than embedding up to 50,000 rows in JSON-RPC.
The Feishu H5 route `/m/cloud/resources` carries the same five URL tabs, dynamic inventory, finding acknowledgement,
efficiency handoff, frozen report/AI reading and recipient delivery controls. Notification deep links preserve the
reports/AI tab, every mobile write uses the shared re-entry/idempotency-key primitive, and legacy type-specific mobile
routes remain valid for established bookmarks.

Migration 537 adds truthful per-resource coverage and closes historical ownership gaps. Monitoring is a capability
matrix, not a null check: only a running server on a metrics-capable provider can be `missing`; DNS, IAM and account
metadata are `not_applicable`, unsupported providers are `not_supported`, and stopped resources are `not_expected`.
Cost coverage states whether evidence is exact to the resource, inherited from its scope, shared at connector level,
or unavailable. The governance evaluator opens findings only for the actionable `missing` / `not_available` states.
Provider account stamps are persisted by specialised S3/UFile/AliCloud/IAM paths, and duplicate connectors inherit a
scope owner only when provider + native external ID resolves to one non-conflicting owner. Connector-level scope
fallback likewise requires one canonical provider-native account; multi-account ambiguity fails closed.

Migration 538 is the directory read-path performance boundary. Employee scope/grant relations are resolved once at
request admission into bounded provider-native scope pairs; list/count/group queries then use direct predicates backed
by `(integration_id, scope_external_id, deleted_at)`, rather than correlated scope/grant subqueries per resource row.
Desktop and H5 mount only the active URL tab, so inventory does not compete with governance, efficiency, history and
AI requests. Reports include permission-scoped distributions, rule/coverage gaps and changes by resource type; the
weekly/monthly/off subscription controls and live permission recheck remain unchanged. Existing server efficiency and
owner digests remain active, while the directory report is the provider-neutral analysis/push path for every resource
type discovered now or later.

Subscription details consume this read model directly (2026-08-25). The Resources tab issues a directory summary and
paged list constrained by both `integration_id` and the provider-native scope id; Azure resource-group pages match the
redacted `raw_metadata.resource_group` while subscription pages use the indexed scope column. The same endpoint still
applies `directory_visibility_clause`, so embedding it does not create a second authorization implementation. Billing
allocation remains below the inventory rather than being replaced.

`collect_cloud_scope_facts` now treats the directory as the resource population for subscription intelligence. It
computes category/type distributions, 30-day changes, effective ownership, actionable monitoring gaps and cost-evidence
coverage for every discovered kind. Server efficiency remains a typed subset (`server_total`, classified/actionable and
estimated savings). Resource references stay anonymous in the model contract; projection re-loads each referenced
directory row against the same integration and scope before emitting a local Resources-tab link. A reference that no
longer belongs to the authorized scope is hidden, preventing stale snapshots or model output from widening access.

### 13.0.0 双口径保密红线 — enforcement primitives (2026-07-30)

Authoritative surface inventory: [cost-measure-surface-sweep.md](cost-measure-surface-sweep.md); decision log: DECISIONS 2026-07-29/30. The circle predicate is `cost_view.can_view_invoice_measure` (finance / cloud_admin / global_admin). Enforcement points a new surface should reuse instead of re-inventing:

- **`lifecycle_service._invoice_measure_recipient_ids(session, recipients)`** — the NOTIFICATION-side twin of `can_view_invoice_measure`: resolves which of an alert card's already-resolved recipients (incl. `cc_person_ids`) are in the circle, so the card renders per-recipient (out-of-circle → dimensionless ratio / period / rule ordinal only; no absolute amounts, no FX). Two things a new card must copy: **(a)** one card can carry MORE than one basis — the budget tile follows `scope.budget_measure`, so it gets its own per-recipient gate even on a list-basis comparison; **(b)** this primitive only applies where recipients are resolved Person rows. A channel whose audience cannot be enumerated (the monthly-budget **Feishu group webhook**, a mailing list, a shared dashboard) can never be proven a subset of the circle, so it must withhold at the WRITE side — dimensionless percent plus a link to a measure-gated page.
- **[`app/core/masking.py`](../../server/app/core/masking.py)** now hosts the invoice-amount redactors alongside the PII maskers: `redact_invoice_basis_amounts` (audit `detail` JSON, driven by the `AUDIT_ACTION_COST_BASIS` classification table — **any new audit action writing cloud-cost money MUST register there in the same PR; unregistered actions fail closed to redaction** — enforced by an AST scan in `test_invoice_basis_redaction.py`, because fail-closed on a NON-cloud action is a silent forensic regression: the shape matcher also eats `residual_rate`, `balance_legal`, `charge_user_id`. DECISIONS 2026-07-30 ③) and `redact_money_in_text` (prose bodies — `GET /notifications/outbox` applies it to `cmdb_cloud_scope` rows for out-of-circle readers). All audit read paths (list / detail / trace / resources-history / CSV+XLSX export / 3 MCP tools) pass the reader's circle membership.
- **`cost_router._cost_fanout_admission` + `_cached_read`** — the cloud-cost read faces (incl. `/finance/waterfall`) share a Redis response cache with a **singleflight** lock: cache miss → one holder computes (~25s worst case), waiters poll the lock and **re-contend when the lock vanishes** (holder death ⇒ exactly one waiter takes over). The wait budget is derived as lock-TTL+5 (so the two constants cannot drift apart) and renews **per lock epoch** — a lifetime budget would leave the takeover epoch only 5s and stampede the waiters into local recompute (fifth-pass F-1); consecutive-takeover count is capped (`_COST_SINGLEFLIGHT_MAX_TAKEOVERS`). Admission gate bounds concurrent heavy recomputes at 3; cost reads carry a dedicated 90s browser timeout (`COST_FANOUT_TIMEOUT_MS`, `web/src/api/cost.ts`).
- **`database._POOL_TIMEOUT_SECONDS = 30`** — the pool-checkout timeout is now an explicit named constant (was an implicit SQLAlchemy default the singleflight math silently depended on).
- **`cost_view.engineering_row_clause()` — 研发口径的行白名单,单一真源 (2026-08-15).** 业务折前成本**只统计 `cost_bucket='usage'`**;退款/调账、SP·RI·CUD 承诺费与抵扣、EDP 合同折扣、赠金、税、Support 一律计 0。任何新的成本查询都用这个谓词(以及它的补集 `non_engineering_row_clause()`),不要再自己写一份「哪些行算研发成本」的字面量 —— 读侧与 `CmdbCostFact.amount_usd_list` 的建模契约各写一份,正是这次泄漏的成因:过渡性的 `COALESCE(list, invoice)` 击穿了「非 usage 行 list 恒 NULL ⇒ 天然出局」的设计,生产上把 AWS 合同折扣 −$1,183 万 / 承诺费 +$662 万、GCP 承诺折扣 −$99.7 万、阿里云调账 −$21.5 万拽进了研发口径(净效果是**低估** AWS $520 万 / GCP $128 万),并让一个阿里云订阅的下钻总额显示成 −$2,601.65。守卫按**桶**判定而非「list 是否为空」,历史脏行(`Item='Adjustment'` 却带 `PretaxGrossAmount` 的 2,844 行)因此也挡得住;`cost_bucket` 已在覆盖索引 `ix_facts_date_measure` 第 3 列,不回表;未接双口径的云(Azure/腾讯/金山/UCloud)全部行是 usage 桶,继续回落实付。该白名单与云管理/财务的「业务 List Cost」规则逐条吻合(保留 PAYG/Purchase/Renewal/升降配,排除 Refund/Reconciliation/Round-off、SP 与资源包购买费、集中采购 Support,不减 invoice_discount/deducted_by_coupons)。被排除的行不消失:`cost_service.excluded_bucket_summary()` 在响应里给出 `excluded_buckets`(笔数恒可见,**金额按 `can_view_invoice_measure` 开放** —— 折前总额与非 usage 桶实付额同框即可反推公司折扣率),该权限位**必须**同时进 `_read_cache_key`,并且它搭在 breakdown 既有月分块里查、不另起扇出(连接预算)。Tests: [`test_cost_list_caliber_purity_2026_08_15.py`](../../server/tests/test_cmdb/test_cost_list_caliber_purity_2026_08_15.py).

The legacy-CMDB → Helix migration ([`server/scripts/migrate_cmdb.py`](../../server/scripts/migrate_cmdb.py)) and the live cloud-sync engine ([`server/app/domains/cmdb/sync.py`](../../server/app/domains/cmdb/sync.py)) MUST agree on the `instance_id` shape of every row they both manage, otherwise the mark-and-sweep silently destroys migrated data. The 2026-04-24 prod incident is the canonical example: legacy migration synthesised `instance_id = f"legacy-{vendor}-{src_id}-{raw_iid}"` (so cross-vendor primary-key collisions couldn't collapse two rows), the live UCloud driver wrote raw `uhost-...` IDs, the unique key `(integration_id, instance_id)` never collided, and a single `_safe_sweep` pass marked all 116 migrated UCloud servers as missing.

**Three invariants now enforced** (verified by [`test_legacy_id_sweep_protection.py`](../../server/tests/test_cmdb/test_legacy_id_sweep_protection.py)):

1. **Migrated rows carry the same `instance_id` shape live syncs produce.** `migrate_cmdb.py` writes the raw cloud-issued ID directly for servers (`instance_id = raw_iid`), load balancers (`lb_id = raw_iid`), and DNS records (`record_id` from legacy CMDB, which is the DNSPod numeric / Aliyun record-id verbatim) whenever a non-empty raw value exists. CDN domains were already keyed on `domain_name` (matches live).
2. **Rows without a cloud-issued ID are parked on the `legacy-bare-metal` integration** (`kind=cloud`, `enabled=false`, no driver — seeded by [migration 212](../../server/alembic/versions/212_cmdb_legacy_id_rescue.py)). The cloud-sync cron only iterates `enabled=true` cloud integrations, so rows under bare-metal are structurally outside any `_safe_sweep` scope forever. `migrate_cmdb.py:_bare_metal_integration_id` resolves the integration UUID; rows route there when `raw_iid` is empty (IDC / bare-metal / manual-only) AND when the vendor has no live driver of the right capability (Qiniu CDN vendor=4, GoDaddy=9, Namecheap=10 — previously catch-all on UCloud where they got swept).
3. **`_safe_sweep` only sweeps rows the driver has ever observed.** The sweep predicate is `last_seen_sync_job_id IS NOT NULL AND last_seen_sync_job_id != current_job` — pre-fix the `OR IS NULL` branch let migration rows (`last_seen_sync_job_id=NULL` from import) match the sweep filter and get tombstoned on the first live sync that didn't return them. The `existing` count used for the drop-ratio guard's baseline narrows to the same `IS NOT NULL` set, otherwise a freshly-imported batch inflates `existing` and trips the floor on every sync forever.

**Migration 212** is the one-shot data rescue: walks `cmdb_servers` and `cmdb_load_balancers`, strips the `legacy-{vendor}-{src_id}-` prefix to recover the real cloud ID, merges owner/app/remark onto a live-sync collision row (then drops the legacy row) or rewrites in-place + clears `deleted_at` when no collision exists. True bare-metal rows (no raw_iid) move to the `legacy-bare-metal` integration with key `legacy-bare-{vendor}-{src_id}`. Forward-only (`downgrade()` is a no-op — the `raw_data._idc` JSON still carries the legacy `id` for forensic recovery).

**Forward-fix in `migrate_cmdb.py`**: every ON DUPLICATE KEY UPDATE in the 5 upsert paths (domains / servers / dns / lb / cdn) now includes `deleted_at=NULL` so a re-run resurrects tomb-stoned rows; the synthetic-ID branches route to `legacy-bare-metal` instead of UCloud catch-all.

### 13.0.2 UCloud multi-project (2026-05-28)

UCloud's API is project-scoped — a single AK + a single `ProjectId` query param sees one project's resources. Pre-fix the driver took one `project_id` field on the integration; PingCAP's prod UCloud tenant has multiple projects (the user's DLP-test server lived in `org-eneelp` while the integration was configured for `org-pingcap`), so 1/N of UCloud resources were structurally invisible to Helix.

[`UCloudProvider.__init__`](../../server/app/domains/cmdb/providers/ucloud.py) now parses `credentials.fields['project_ids']` (CSV) first, falls back to legacy `project_id` (singular) for backward compat, defaults to `[None]` sentinel (omit ProjectId → AK's default project). `_call` gained a per-call `project_id` kwarg with a `_UNSET` sentinel so callers can override the integration's default for one request. Every `list_*` method (`list_servers`, `list_load_balancers`, `list_cdn_domains`, `list_object_storage`) and `get_monthly_billing` iterates `self._project_ids` and dedupes results across projects (UCloud cloud IDs are globally unique per account, so the set works as a cheap defence against future API overlap). `skipped_legs` / `partial_failures` labels widened to `f"project:{pid}/region:{region}"` so admins see WHICH (project × region) was unavailable.

### 13.0.3 CMDB recycle bin (admin restore surface, 2026-05-28)

Six resource types with admin-curated owner / app / remark fields — server, domain, dns_record, load_balancer, cdn_domain, **certificate** (added 2026-06-16) — get a unified list + restore surface via [`router.py`](../../server/app/domains/cmdb/router.py). Certificate joined the set because its sync upsert deliberately no longer auto-resurrects a swept/retired cloud cert (`deleted_at` omitted from `on_duplicate_key_update`) and it carries `owner_person_id`/`environment`/`remark` annotations worth preserving — same bucket as server/domain — so without a recycle-bin entry a swept cert was permanently unrecoverable. Cert search covers `common_name` + `serial_number` + `fingerprint_sha256` + `JSON_SEARCH(sans, …)`.

- `GET /api/v1/cmdb/recycle-bin/{resource_type}` — paginated soft-deleted rows, newest-first, optional `integration_id` + `search` filters. Server's search covers `name` + `instance_id` + `JSON_SEARCH(public_ips/private_ips, …)` (the same IP-search hookup added to the live `/servers` list).
- `POST /api/v1/cmdb/recycle-bin/{resource_type}/{id}/restore` — clears `deleted_at`, stamps modifier, writes a `cmdb.resource.restore` audit row. 409 `CMDB_RESOURCE_NOT_DELETED` if the row isn't deleted (idempotency guard for double-clicks / stale lists).

Both gated by `can_manage_cloud`. Object storage / IAM users / cost_facts are intentionally absent — those are sync-managed without admin annotation, so a recycle bin there has no concrete preserve-my-owner story.

MCP parity: [`list_cmdb_recycle_bin`](../../server/app/mcp/tools.py) + [`restore_cmdb_resource`](../../server/app/mcp/tools.py) (mutates=True). 1:1 with the HTTP routes. `_REGISTERED_TOOLS_COUNT_HINT` bumped 186 → 188.

Frontend admin page: [`CmdbRecycleBinPage`](../../web/src/pages/cloud/CmdbRecycleBinPage.tsx) at `/admin/cloud/recycle-bin`, gated by the `cloud_admin`/`global_admin` RoleRoute. Tabs switch resource type; row-level "Restore" Popconfirm with `confirmLoading` reentry guard. The IP search hint on `CloudServersPage` lists IP support so admins know `10.9.84.114` is now a valid search term.

### 13.0.4 Server lifecycle actions (开机/关机/重启/删除, PR #559, 2026-05-28)

Recreates the legacy CMDB's 服务器 action menu — the admin-facing operator buttons the user reported missing ("以前 CMDB 截图里有开机/关机/重启/删除/重装/下工单,现在云服务器管理页什么都没有"). Scope after comparing against `cmdb-backend-main` (the legacy Go server): 4 of 6 actions ported (重装 was a stub in the legacy code; 下工单 only existed for KSY bare-metal — neither has a clean Helix correspondence).

**Provider base** ([`server/app/domains/cmdb/providers/base.py`](../../server/app/domains/cmdb/providers/base.py)) — 4 abstract methods, default to `CapabilityNotSupported`:

| Method | Signature | Semantics |
| --- | --- | --- |
| `power_on` | `instance_id, region, project_id` | Start a stopped instance. Idempotent on already-running. |
| `power_off` | `instance_id, region, project_id, force=False` | Stop a running instance. Aliyun uses `ForceStop=true` to match legacy CMDB. |
| `restart_server` | `instance_id, region, project_id` | Reboot. Not idempotent on stopped instances (provider rejects). |
| `destroy_server` | `instance_id, region, project_id` | Terminate + release attached resources (UCloud `ReleaseEIP+ReleaseUDisk`, Aliyun `Force=true`). Idempotent on already-terminated. |

**Driver impl matrix** (3/8 providers ship lifecycle today):

| Provider | start | stop | restart | destroy | Note |
| --- | --- | --- | --- | --- | --- |
| UCloud | ✓ | ✓ | ✓ | ✓ | UHost actions, RetCode 173/174 caught as idempotent. ProjectId threaded via per-call override. |
| Aliyun | ✓ | ✓ | ✓ | ✓ | ECS RPC, `IncorrectInstanceStatus.*` + `InvalidInstanceId.NotFound` caught as idempotent. `power_off` defaults to `ForceStop=true` (legacy parity, avoids hang on unresponsive guest OS). |
| AWS | ✓ | ✓ | ✓ | ✓ | boto3 in `asyncio.to_thread`; AWS API natively idempotent. Destroy treats `InvalidInstanceID.NotFound` as success. |
| AliCloud Intl | ✗ | ✗ | ✗ | ✗ | Explicit `CapabilityNotSupported` override — multi-account AssumeRole lifecycle not yet wired. Naive inheritance from `AliyunProvider` would hit `ecs.aliyuncs.com` (domestic) with intl credentials AND ignore sub-account fanout — silent wrong-target risk. Frontend grays out the menu items. |
| Azure | ✗ | ✗ | ✗ | ✗ | Base raise → router 501. |
| GCP | ✗ | ✗ | ✗ | ✗ | Base raise → router 501. |
| Tencent | ✗ | ✗ | ✗ | ✗ | Base raise → router 501. |
| KSyun | ✗ | ✗ | ✗ | ✗ | Base raise → router 501. |

**Sync resurrection guard**: `_sync_servers` (sync.py) no longer sets `deleted_at=None` in its `on_duplicate_key_update`. Pre-fix the upsert silently un-soft-deleted every row whose `(integration_id, instance_id)` matched — including rows an admin had just explicitly destroyed via `POST /cmdb/servers/{id}/destroy`. The cloud-side terminate is async (UCloud / Aliyun / AWS keep returning the instance in `stopping` / `shutting-down` state for up to a few minutes after the API call accepts); the next sync tick would silently reset `deleted_at=NULL` and the row would reappear in the admin's list. The recycle bin (`/admin/cloud/recycle-bin`, PR #558) is the canonical un-delete path now — admins click Restore explicitly. (A CASE-based "preserve when status=terminated" guard was attempted but failed MySQL semantics: ON DUPLICATE KEY UPDATE SET clauses evaluate sequentially and `cmdb_servers.status` in a CASE WHEN reads the post-update value, not the original. Removing `deleted_at` from the SET list entirely is the production-grade fix.)

**HTTP routes** ([`server/app/domains/cmdb/router.py`](../../server/app/domains/cmdb/router.py)):

| Route | RBAC | Success contract |
| --- | --- | --- |
| `POST /api/v1/cmdb/servers/{id}/power-on` | `can_manage_cloud` | DB status → `starting`, modifier stamped, audit row written. |
| `POST /api/v1/cmdb/servers/{id}/power-off` | `can_manage_cloud` | DB status → `stopping`. |
| `POST /api/v1/cmdb/servers/{id}/restart` | `can_manage_cloud` | DB status → `rebooting`. |
| `POST /api/v1/cmdb/servers/{id}/destroy` | `can_manage_cloud` | DB status → `terminated` + `deleted_at` set (recoverable via recycle bin). |

Error mapping: `CapabilityNotSupported` → 501 `CLOUD_LIFECYCLE_NOT_SUPPORTED`, `ProviderAuthError` → 502 `CLOUD_LIFECYCLE_AUTH_ERROR`, `ValueError` (missing region) → 422, other provider exceptions → 502 `CLOUD_LIFECYCLE_FAILED`. Every error message runs through `redact_provider_error` so signed-URL / response-body credentials never reach `audit_logs.detail` or the admin UI. Audit row written on EVERY path (success + 4 failure classes) — governance + auditor evidence trail.

**Reconciliation contract**: the optimistic transitional status (`starting` / `stopping` / `rebooting` / `terminated`) is a UI lie until the next sync cron reads the cloud-authoritative state — typical reconciliation window is ≤5 minutes (the `cmdb.servers` scheduled job tick). The cmdb row's `status` is treated as soft state by every consumer (no hard guards against "I just stopped this row, it should be stopped"). Tests cover both the optimistic update and the sync engine's eventual reconciliation.

**ServerResponse plumbed** with `provider` + `integration_name` (joined from `integrations` via `_resolve_integration_meta`, batched IN-query, called by list / get / lifecycle endpoints alike) so the frontend can gate the per-row action menu per-provider without a second round-trip. Null on orphan rows (integration deleted).

**Frontend**:
- [`CloudServerDetailPage`](../../web/src/pages/cloud/CloudServerDetailPage.tsx) (`/admin/cloud/servers/:id`) — the missing detail page. Single-card `<Descriptions>` block; layout matches the legacy CMDB screenshot fields verbatim. Lifecycle actions surface in the page header for `can_manage_cloud` users.
- [`ServerLifecycleActions`](../../web/src/pages/cloud/ServerLifecycleActions.tsx) — reusable "更多 ↓" dropdown. Per-row gating: provider must be one of the four supported, and item disabled state mirrors current `status` (start hidden on running, stop hidden on stopped, destroy hidden on terminated). `Modal.confirm` + `saving` boolean (the `feedback_modal_confirm_loading` reentry rule).
- [`CloudServersPage`](../../web/src/pages/cloud/CloudServersPage.tsx) — row click navigates to detail, new ProfileOutlined column for explicit detail entry, action column gets the lifecycle dropdown alongside the existing Edit icon.

**MCP** (4 new tools, `_REGISTERED_TOOLS_COUNT_HINT` bumped 188 → 192): `server_power_on`, `server_power_off`, `server_restart`, `server_destroy`. Shared `_mcp_run_server_lifecycle` driver inside [`tools.py`](../../server/app/mcp/tools.py) — same provider dispatch + audit semantics as the HTTP route. `mutates=True`, so the `mcp.*` audit row pairs with the `cmdb.server.*` row for a complete "what did the LLM do" trace.

**Tests** ([`tests/test_cmdb/test_server_lifecycle.py`](../../server/tests/test_cmdb/test_server_lifecycle.py), 15 cases): parametrized happy-path for all 4 actions (driver dispatch + transitional status + modifier stamp + destroy soft-delete), employee-403 parametrized, unknown-id 404, CapabilityNotSupported → 501, ValueError (missing region) → 422, audit log written on success + failure, list/get responses carry `provider` + `integration_name`. Real DB + mocked provider — no live cloud API calls.

### 13.0.5 Cloud-server list redesign + half-yearly owner confirmation (2026-05-29, migration 216)

User-driven five-part redesign of the cloud-server inventory + the first per-server governance loop:

**List page restructure** ([CloudServersPage.tsx](../../web/src/pages/cloud/CloudServersPage.tsx), [MyCloudServersPage.tsx](../../web/src/pages/cloud/MyCloudServersPage.tsx)) — top-level `<Tabs items=[概览|列表]>`. List uses a shared filter bar component ([CloudServersFilterBar.tsx](../../web/src/pages/cloud/CloudServersFilterBar.tsx)) with 300ms debounced search + provider / region / spec-tier / age / os filters. The filter state lives in [cloudServersFilter.ts](../../web/src/pages/cloud/cloudServersFilter.ts) (separated from the component file so React-Refresh only-export-components stays clean). Default 7 columns (`名称 / 内网IP / 厂商·区域 / 规格 / 状态 / Owner / 操作`); heavier columns (instance_id, last modified, sync time, age, history) folded into a per-row `EllipsisOutlined` `...更多` dropdown.

**Filter-options endpoint** — `GET /cmdb/servers/filter-options` returns RBAC-scoped `{providers, regions, os_types, instance_type_tiers}` so the dropdowns never show a value that returns 0 rows post-filter. Static spec-tier constant `_INSTANCE_TYPE_TIERS` lives in router.py; the frontend renders `label_zh` / `label_en` and submits the resolved `vcpu_min` / `vcpu_max` pair.

**Service signature extension** — `CmdbService.list_servers` ([service.py](../../server/app/domains/cmdb/service.py)) now accepts `integration_ids: list[str] | None` (provider→tenants pre-resolved by router), `os_type`, `vcpu_min`, `vcpu_max`, `age_min_days`, `age_max_days`. Age uses `first_seen_at` as the proxy timestamp; understates real cloud-side age for VMs that predate Helix's first sync of that tenant but is correct for everything created after rollout.

**Overview tab** ([CloudServersOverviewTab.tsx](../../web/src/pages/cloud/CloudServersOverviewTab.tsx)) composes the existing `HostsTab` (status / OS / provider / region distribution from `/cmdb/analytics`) + two new leaderboards from `GET /cmdb/servers/overview-tops`: **Owner Top 10** (per-owner server count) and **长期停机 Top 10** (status=stopped ordered by oldest `last_modified_at`). RBAC-scoped — employees see only their own + subordinates' rows.

**Global search → detail jump** — pre-fix the `GlobalSearch.tsx` `cloud_server` hit href was `${prefix}/servers` (list page); now `${prefix}/servers/${id}`. New `/my/cloud/servers/:id` route reuses `CloudServerDetailPage` with a `useLocation`-based route-aware breadcrumb so the back button lands on the right list (admin vs employee).

**Half-yearly server owner confirmation (migration 216)** — the first server-level governance loop. Pair to the existing scope confirmation but operating on individual servers aggregated per owner:

- **New table** `cmdb_server_owner_confirmations` (`owner_person_id` + `period_start DATE` + `server_ids JSON` snapshot + status). UNIQUE(`owner_person_id`, `period_start`) is the same-day idempotency anchor.
- **New column** `cmdb_servers.last_owner_confirmed_at` — stamped on respond; cron skips servers with `last_owner_confirmed_at >= now - 180d`.
- **Service** [`ServerOwnerConfirmationService`](../../server/app/domains/cmdb/server_owner_confirmation_service.py) (`list_due_owners` / `create_pending_for_owner` / `respond_confirm_all` / `respond_per_server` / `list_pending_for_owner`).
- **Cron** `cmdb.server_owner_semiannual_confirmation` (weekly Mon 09:00 Asia/Shanghai, handler in [`cmdb_server_owner_handlers.py`](../../server/app/scheduled_jobs/cmdb_server_owner_handlers.py); imports registered in both `main.py` and `worker.py` per the worker-handler-import rule). Skip rules per user spec: no owner / owner leaver / `first_seen_at < 90d` (newborn) / `status in (stopped, decommissioned)`.
- **Feishu card** built via `NotificationService` + `CardAction[]` — one card per owner per cron tick listing every due server, two CTAs: `?action=confirm_all` deep-link (auto-commits on landing) + 前往管理 (opens the drawer for per-server actions).
- **HTTP routes** `GET /cmdb/server-confirmations/mine` + `POST /cmdb/server-confirmations/{id}/respond` (owner-scope only — by design, no admin override; escalations go through audit + direct server edits).
- **UI** [`MyServerConfirmationDrawer.tsx`](../../web/src/pages/cloud/MyServerConfirmationDrawer.tsx) — opens on URL `?confirmationId=`; supports the auto-confirm deep link OR a per-row checkbox subset; manually re-entered via a `Badge` count button on the list page toolbar.
- **MCP** twins `list_my_server_owner_confirmations` + `respond_server_owner_confirmation` (mutates=True, with explicit `cmdb.server_owner_confirmation.respond` audit row mirroring the HTTP route — so MCP-initiated confirmations surface on the same audit timeline). `_REGISTERED_TOOLS_COUNT_HINT` bumped 192 → 194.
- **Per-owner safeguards** added in the pre-merge review sweep:
  - `MAX_SERVERS_PER_CARD = 500` per owner — bounds the JSON snapshot, the IN-list at hydration, and the Feishu card body. Was unbounded in the original draft.
  - Re-prompt interlock — `list_due_owners` excludes owners with a still-PENDING row from a prior tick, so a weekly cron doesn't keep minting fresh cards every Monday for the same unresponded owner.
  - Card body resolves the real provider (aws / aliyun / ucloud / …) via integration lookup — was hardcoded em-dash stub.
  - `respond_per_server` records `response_kind="confirmed_all"` when the cumulative confirmation set covers the snapshot, regardless of how many clicks it took (was misleadingly recording `partial` for multi-click full confirmations).

**Migration**: 216 (originally 213 → renumbered to 214 → renumbered to 216 after PR #561's 213 + PR #563's 214/215 landed on main first; `down_revision="215"` per the alembic-revision-race rule).

**Doc rule compliance** — this PR touches the cloud-lifecycle governance surface; both [`docs/ops/cloud-lifecycle-ops.md`](../ops/cloud-lifecycle-ops.md) and ROADMAP get updated in the same PR.

### 13.1 Core entities ([`server/app/domains/cmdb/models.py`](../../server/app/domains/cmdb/models.py))

- **`cmdb_cloud_scopes`** — billable unit. One row per AWS account / Azure subscription / GCP project / Aliyun RAM member / standalone IAM bucket. Columns: `integration_id`, `scope_type`, `external_id` (cloud-native ID), `display_name`, `owner_person_id` (FK SET NULL), `budget_usd_monthly`, `auto_suspend_after_days` (default 60), `default_grant_ttl_days` (default 90), `status` ∈ {active, suspended, closed}, `environment` ∈ {dev, staging, prod} (controls expiry behavior, added in [`076`](../../server/alembic/versions/076_cloud_scope_environment.py) + [`078`](../../server/alembic/versions/078_cloud_scope_environment_forward_fix.py)), `alert_rules` (JSON array), `decommissioned_at/by/reason/provider_state` (added in [`089`](../../server/alembic/versions/089_cloud_scope_decommission.py)). UNIQUE on `(integration_id, external_id)`. (The `is_ai_dedicated` boolean was dropped in migration 153 — Phase 2 AI Spending refactor; see §12.9.)
- **`cmdb_cloud_access_grants`** — per (person × scope × role). Columns: `scope_id`, `person_id` (nullable since [`130`](../../server/alembic/versions/130_cloud_grant_orphan_history.py) for orphan-cleanup history rows), `role_key`, `role_display_name`, `status` ∈ {pending, active, expired, revoked, disabled}, `granted_at`, `expires_at`, `last_renewed_at`, `last_notified_stage` (idempotency marker for sweeps: `t14|t7|t1|inactive_owner|inactive_it|prod_overdue`), `last_login_at`, `granted_by_person_id`, `employee_request_id`, `request_group_id`, `external_ref` (Azure assignment GUID, GCP binding, IAM user+key), `external_principal_id` + `external_principal_email` (NULL on normal rows; populated on orphan-sweep history rows so the admin "已结束" tab still attributes the row when the Helix person record is gone), `detail` (provider-specific), `source` ∈ {helix, discovered, imported} (added in [`061`](../../server/alembic/versions/061_grants_source_and_discovery_markers.py)), `last_seen_grants_sync_job_id`, `revoked_at/by/reason`. UNIQUE on `(scope_id, person_id, role_key)` — NULL `person_id` does not collide with itself under MySQL/TiDB unique semantics, so multiple swept-orphan rows for the same scope+role coexist (one per cleaned principal id). Index `ix_cmdb_cloud_grants_status_reason` (added in 130) backs the admin history-tab range scan on `(status, revoke_reason)`.

  Revoke reason vocabulary lives in `app.domains.cmdb.models` as module constants:
  `user_released` (employee released), `expired` (T+0 sweep), `inactive` (T+104 inactivity sweep — disabled state, see DECISIONS.md "revoked vs disabled"), `offboarding` (auto-revoke during leaver flow), `owner_removed` (scope owner pressed Revoke), `admin_removed` (cloud / global admin pressed Revoke), `drift_removed` (cloud-side disappearance caught by mark-and-sweep), `iam_user_disowned` (IAM user cleanup cascade), `orphan_principal_swept` (added 2026-05-07 — cloud-side orphan cleanup; see §13.1.1).

  **UI rendering of cloud-side-initiated closures (added 2026-05-17, widened 2026-05-17 Round-23)**: three reasons —  `drift_removed`, `iam_user_disowned`, `orphan_principal_swept` — represent closures where the actual operator was a cloud-side admin (or a cloud-side cascade), not Helix. The DB stores `status='revoked'` (the grant is no longer effective) but rendering it literally as "已撤销 / Revoked" misled IT admins into thinking Helix had auto-revoked production permissions (user incident 2026-05-17). The Round-23 sweep covers **every** UI surface that displays a cloud grant's status badge:

  | Surface | File | What it shows |
  | --- | --- | --- |
  | Admin overview → Permissions → 最近回收记录 | [`PermissionsTab.tsx`](../../web/src/pages/cloud/cloudOverview/PermissionsTab.tsx) `CLOUD_SIDE_REASONS` | Top-30 most recent grant closures across every integration. |
  | Admin scope detail → 已结束 (history) tab | [`CloudScopeDetailPage.tsx`](../../web/src/pages/cloud/CloudScopeDetailPage.tsx) `HistoricalGrantsTab` | Per-scope terminal-grant history with status + reason filters. Status filter gained explicit `cloud_closed` option; predicate splits `revoked` (Helix-initiated) from `cloud_closed` (cloud-side initiated). |
  | Admin scope detail → 成员 → expand-row sub-table | [`CloudScopeDetailPage.tsx`](../../web/src/pages/cloud/CloudScopeDetailPage.tsx) `perGrantActionColumns` | Per-role status for an owner reviewing one member. |
  | Owner scope detail → expand-row sub-table | [`MyCloudScopeDetailPage.tsx`](../../web/src/pages/cloud/MyCloudScopeDetailPage.tsx) `perGrantActionColumns` | Same view from the scope owner's self-service surface. |
  | Employee → My Cloud Grants → 已结束 tab | [`MyCloudGrantsPage.tsx`](../../web/src/pages/cloud/MyCloudGrantsPage.tsx) status column | Employee sees their own historical grants; dominant-status pill across `g.grants[]`. Renders `cloud_closed` when EVERY revoked sibling is cloud-side initiated. |
  | IAM-user revoked tooltip | [`CloudIamUsersPage.tsx`](../../web/src/pages/cloud/CloudIamUsersPage.tsx) | Translates `revoke_reason` via the canonical `cloudLifecycle.grants.endedReason.*` keys (not raw enum). `revoked_at` here is always Helix-initiated, so the badge text stays "已撤销". |

  The DB state is untouched on every surface; the fix is a render-layer override keyed on `(status === 'revoked' && CLOUD_SIDE_REASONS.has(revoke_reason))`. New i18n keys: `cloudLifecycle.grants.status.cloud_closed` + `.cloud_closedHint` (zh + en).

  **Software seat parallel (added 2026-05-17 Round-23)**: same bug class on SaaS seats — `slack/zoom/google/jira/feishu.seat_sync` cron flips `seat.status='revoked'` and stamps `seat.reason` with `[auto-revoked by <vendor>.seat_sync: ...]` when the upstream vendor deletes the user (Workday-driven SCIM suspension, console-side admin action). Pre-fix: rendered identically to admin-clicked revoke. New helper [`web/src/utils/seatStatus.ts`](../../web/src/utils/seatStatus.ts) `displaySeatStatus(status, reason)` detects the prefix and maps to a synthetic `vendor_closed` status ("厂商已删除 / Closed in vendor", cyan, hover-tooltip). Applied at [`SoftwareCostPage.tsx`](../../web/src/pages/asset/SoftwareCostPage.tsx) status column and [`UserItUniverseTab.tsx`](../../web/src/pages/admin/UserItUniverseTab.tsx) `renderSeatStatusTag` helper (two render sites). Helix-initiated `leaver_block_guard` / `approval callback` / admin-cancel revokes keep the literal "已撤销" — those ARE Helix actions.

### 13.1.1 Cloud-side orphan-principal cleanup (added 2026-05-07)

**Problem class**: AWS Identity Center keeps the `AccountAssignment` row indefinitely after the SCIM-synced principal is deleted from the Identity Store — the console renders these as "User not found (UUID)" attached to a permission set. `aws_cloud._list_scope_grants_sso_sync` silently dropped them (the email lookup returns None, the row is skipped), so the existing drift-removal mark-and-sweep was blind: drift only fires for grants once successfully discovered. Months-old leavers still showed e.g. `DBaaS-Prod-Admin-Role` on `tidbcloud-marketplace` long after the offboarding flow ran.

**Driver contract**: `CloudDriver` gained two methods (default `NotImplementedError` so non-AWS providers visibly opt out):
- `list_scope_orphan_assignments(scope) -> list[OrphanAssignment]` — walk Identity Center assignments per (account, permission_set), call `IdentityStore.DescribeUser` per `PrincipalId`; only `ResourceNotFoundException` classifies as orphan (throttling / 5xx / auth errors are treated as alive).
- `delete_orphan_assignment(scope, orphan) -> CloudGrantResult` — idempotent `sso-admin.DeleteAccountAssignment`; `ResourceNotFoundException` returns `already_absent=True`.

**Service** ([`server/app/domains/cmdb/orphan_cleanup.py`](../../server/app/domains/cmdb/orphan_cleanup.py)):
- `scan_orphan_assignments(integration_id)` — read-only enumeration across every active scope of one integration; recovers a best-effort email per orphan by querying `cmdb_cloud_access_grants` rows whose `external_ref LIKE '%::<principal_id>'` and joining to `IdentityPerson` (the AWS driver's canonical ref shape is `account_id::permission_set_arn::principal_id`, so any past helix-issued or discovered grant for the same principal carries that suffix). When no past grant exists the email column stays NULL.
- `cleanup_orphan_assignments(integration_id, targets, ...)` — per-target: call `delete_orphan_assignment`; on success (and not already-absent) insert a grant row with `status='revoked'`, `revoke_reason='orphan_principal_swept'`, `person_id=NULL`, populated `external_principal_id` / `external_principal_email`, `source='discovered'`; write `cloud.grant.orphan_principal_swept` audit row. Per-target outcomes returned so the admin UI flags failures individually.

**HTTP surface** (`global_admin` only via `can_manage_cloud`; `cloud_admin` is a perpetual-grant marker, NOT an authority — same gate as decommission and force-drift-sweep):
- `GET /api/v1/cmdb/cloud-grants/orphan-scan?integration_id=…&scope_id=…` — read-only candidate list. **Always pass `scope_id`** when invoked from a per-scope detail page; without it the scan walks every active sub-account on the integration (an AWS Org with 50+ accounts × 20 permission sets each easily produces tens of thousands of API calls and exceeds any reasonable HTTP timeout — observed: 90s+ in the first cut). The integration-wide form (no `scope_id`) is reserved for explicit batch / admin-tool surfaces.
- `POST /api/v1/cmdb/cloud-grants/orphan-cleanup` body `{ integration_id, targets: [...] }` — execute cleanup. Per-target durability: each successful `DeleteAccountAssignment` + history insert + audit row is committed BEFORE moving to the next target so a mid-batch DB failure on target #50 doesn't roll back targets 1-49 whose cloud-side state is already deleted (cloud-side ahead of Helix-side bookkeeping is the failure mode). On a per-target Helix-side write failure after cloud-side delete already succeeded, a `CRITICAL` log line names the orphan (integration / scope / role / principal_id / recovered_email / trace_id) so SRE can hand-reconcile.

**PII redaction** (`_serialize_grants` in `cmdb/lifecycle_router.py`): `external_principal_id` and `external_principal_email` on orphan-cleanup history rows are leakage of a now-departed employee's identifiers. They are surfaced **only** to viewers passing `can_read_cloud(viewer.role_names)` (admins / auditors / cloud_admin). Scope owners + plain members listing `?scope_id=X&status_in=revoked` see a generic `(orphan principal)` placeholder for `person_label` and `null` for the external_principal_* fields — they can still see the row exists (count matters for completeness) but can't enumerate which leaver each row pointed at.

**Continuous detection**: deliberately NOT auto-run inside `sync_grants_for_integration`. The first cut tried to surface an `orphan_assignments_detected` count after the main commit by re-calling `scan_orphan_assignments(integration_id)` — but that re-iterates every active scope and re-issues every `list_permission_sets` + `list_account_assignments` + `describe_user` call the main sync just made. Net effect: every grants_sync cron run doubled its AWS API call volume; on a real prod-shaped integration this routinely exceeded 90s. Removed 2026-05-08 same-day follow-up. The admin-on-demand flow (with `scope_id`) is the supported path; a future PR can piggy-back orphan detection on the existing scope iteration in sync (no second pass) without the cost.

**MCP** parity: `scan_cloud_orphan_assignments` (read) + `cleanup_cloud_orphan_assignments` (write).

**Frontend**: scope detail page (`CloudScopeDetailPage`) gained a 历史 tab listing every `status IN (revoked, expired, disabled)` row with revoke-reason filtering. The "扫描云端孤儿权限" button (visible only when `provider === 'aws'`) opens `OrphanCleanupModal` which auto-runs scan, lets the admin uncheck rows they want to keep, and confirms before deletion. Cleaned rows surface in the same 历史 tab on the next reload.

**Offboarding integration** (paired): `LeaverBlockedGuard.flip_to_left` (the single chokepoint for `employment_status='left'` writes — covers HRIS sync, offboarding completion, and the rare admin manual flip) calls `strip_person_from_alert_cc(session, person_id, …)` after the existing orphan-seat sweep. The helper SELECTs every scope row, walks `alert_rules[*].cc_person_ids`, removes the leaver's UUID, and writes one `cloud.alert.cc_leaver_stripped` audit row per modified scope. Failure is swallowed + audited as `cloud.alert.cc_leaver_strip_failed` (status='failed') — runtime alert routing already filters out missing IdentityPerson rows in `_resolve_billing_alert_recipients`, so cc hygiene is not a correctness invariant; the audit row is for investigators looking at "why does leaver X still appear as 未知用户". Idempotent — second call on an already-cleaned leaver is a no-op (writes zero audit rows).

**Console pivot button** (paired UX, added 2026-05-07): both `CloudScopeDetailPage` (admin) and `MyCloudScopeDetailPage` (employee/owner) render a "打开云 Console" button when `scope.console_url` is non-null. Resolution lives in [`server/app/domains/cmdb/console_urls.py`](../../server/app/domains/cmdb/console_urls.py): per-integration override (`Integration.config_public.console_url`) → global default (`platform_settings.cloud.console_url.<provider>`) → hardcoded baseline in `_BUILTIN_DEFAULTS`. Defaults: `aws → pingcap.awsapps.com/start`, `azure → portal.azure.com/#home`, `gcp → console.cloud.google.com`, `alicloud → ram.console.aliyun.com`. `noopener,noreferrer` on `window.open` prevents reverse-tabnabbing — the cloud console can never navigate the Helix tab.

**「申请权限」on the scope detail pages** (paired UX, added 2026-07-31): both `MyCloudScopeDetailPage` and `CloudScopeDetailPage` render a 申请权限 button in the 成员与权限 → 成员 toolbar, next to 添加成员 — visible to EVERY viewer of the page (a plain member has no 管理 menu, which is why the button lives in the toolbar). It opens the shared [`components/cloud/RequestAccessModal.tsx`](../../web/src/components/cloud/RequestAccessModal.tsx) in `lockedScope` mode (scope fixed, picker replaced by a read-only summary); the same component in picker mode still powers 我的云订阅's 申请访问 and the `?request_scope_id=` deep links. Two backend affordances keep it honest: (a) `GET /cmdb/scopes/{id}/roles` returns `self_grant_status` per row (`'active'` / `'pending'` for the CALLER's own live grants — exactly the `(scope, person, role)` triples `_reuse_or_create_grant_row` 409s on), so those options render disabled with a 已拥有 / 申请中 suffix while terminal (revoked / expired) rows stay requestable because that grant row is resurrectable; (b) the scope payload carries `self_requestable` + `self_request_block_reason`, computed by `scope_self_request_block_reason()` which *invokes* `_refuse_prod_self_service` / `_refuse_ungrantable_scope_type` (plus non-active + no-approver) rather than restating them — prod / closed / cost-only RG / unowned scopes disable the button with the reason in a tooltip instead of failing after the form is filled in.
- **`cmdb_cloud_grant_renewal_requests`** ([`437`](../../server/alembic/versions/437_cmdb_cloud_grant_renewal_requests.py), 2026-07-29) — a request to extend (or revive) ONE grant, pending a scope-owner decision. Separate table rather than reusing the grant's `pending` status because the grant stays ACTIVE while the renewal is under review; flipping it to `pending` would revoke access the moment the holder asked to keep it. Columns: `grant_id` / `scope_id` / `person_id` (denormalized for the owner queue + /my history), `requested_by_person_id` (differs from `person_id` only when a cloud admin files on someone's behalf), `requested_ttl_days`, `reason` (mandatory), `is_revival` (the grant was already recycled → approving re-dispatches to the provider), `status` ∈ {pending_owner, approved, rejected, lapsed, cancelled} (+ the retired `pending_admin`, still resolvable for history rows), `request_group_id` (one submission covering several of the holder's roles on one scope — the owner decides the PERSON once), `lapse_at`, `owner_reminded_at` (once-only marker for the "about to lapse" nudge), `owner_decided_by/at` (the deciding approver) + `admin_decided_by/at` (**retired 2026-08-26** — they held the second signature that a TTL over 90 days used to require; nothing writes them now, and the owner's single signature is final up to `GRANT_TTL_HARD_CAP_DAYS`), `decision_note`, `applied_expires_at`. **`open_for_grant_id`** mirrors `grant_id` while open and is NULLed on every terminal status; UNIQUE over it is what enforces "at most one open request per grant" in the DB rather than in a read-then-write a double-clicked reminder card would defeat (MySQL/TiDB allow repeated NULLs, so closed history accumulates freely). Indexes: `(scope_id, status)` owner queue, `(person_id, status)` my-requests, `(status, lapse_at)` lapse cron, `(request_group_id)` group decide. **Surfaces** (corrected 2026-07-31 — the line previously claimed both scope pages carried the queue; only the admin one did, and scope owners are usually NOT global_admin, so the approver card dead-ended for its primary audience): 「续期审批」 segment on **both** scope-detail pages — the global_admin `CloudScopeDetailPage` **and** the owner-facing `MyCloudScopeDetailPage` (`/cloud/scopes/:id` role-routes owners to the latter); the cross-scope desktop inbox `我的待办 → 云权限续期审批` (`/my/todos?tab=cloudRenewals`, inline approve/reject); and the mobile action-inbox type `cloud_renewal_approval` → `/m/cloud/renewal-approval/:group`. The two cross-scope surfaces are fed by `GET /cmdb/my-pending-renewal-approvals` and `_provider_cloud_renewal_approval`, which mirror each other exactly (badge and page must never disagree about who owes a decision) — both now show every open row to either kind of approver. The 2026-07-31 rule that hid `pending_admin` from a plain owner existed because `decide_renewal_group` 403'd them on it; with the second signature gone (2026-08-26) an owner can finalize such a row, so hiding it would strand it. **Deep link**: the approver cards emit `/cloud/scopes/{id}?renewals=1&group={request_group_id}` — the page opens the queue, sorts that submission first, tints and scrolls to it; an already-decided `group` gets an explicit "已处理完毕" message instead of a silent landing. The lapse-nudge digest omits `group` when it covers several submissions. Both shapes are pinned in `contracts/mobile-deep-links.json`.
- **`cmdb_cloud_roles`** — provider-side role catalog. Columns: `integration_id`, `scope_id` (NULL for tenant-wide), `role_key`, `display_name`, `description`, `is_custom`, `source`, `is_promoted` (shown in request modal), `promotion_overridden_by_admin` (migration 623 — the admin's 「默认下拉?」 decision at **(integration, role_key)** level, so role sync seeds a newly-discovered sub-account from the decision and re-stamps rows that drifted; without it a demote only reached the rows that existed when it was clicked, and ten `DBaaS-Prod-*` CloudSSO roles were back in the employee dropdown on 8 of 29 Aliyun sub-accounts for three months while the catalog page — which reports the cross-scope CONSENSUS — showed 否. Cleared by 「重置为默认」, which means "forget my edits"), `raw_data`, `last_synced_at`. Reconciliation logic in [`068_role_curation_and_scope_requests.py`](../../server/alembic/versions/068_role_curation_and_scope_requests.py), [`070`](../../server/alembic/versions/070_reseed_azure_privileged_default_roles.py), [`071`](../../server/alembic/versions/071_role_reconcile_aliyun_curated_frontend_url.py). **Admin-role self-service guard (added 2026-05-06)**: `GET /scopes/:id/roles` strips any role whose `role_key` or `display_name` matches `admin` (case-insensitive) or `管理员` for non-`can_read_cloud` callers. The admin / cloud-admin UI passes `?include_admin_roles=true` to retain the full catalog when assigning power roles to other admins; the self-service `/my/cloud/...` flow leaves the param off. Mirror predicates `is_admin_role` / `is_billing_role` live in [`server/app/domains/cmdb/role_classifier.py`](../../server/app/domains/cmdb/role_classifier.py) (substring sieve — covers Azure `User Access Administrator`, AWS `*AdminAccess`, Aliyun `*管理员`, custom org roles named `Tidb Admin` etc. without per-provider tables). `POST /grants/request` and `POST /grants/owner-add` reject admin-named role keys from non-cloud-admin callers as a backend belt-and-braces (stops curl bypass of the dropdown). Frontend mirror `isAdminCloudRoleName` in [`web/src/lib/rbac.ts`](../../web/src/lib/rbac.ts) keeps the request modal + owner-add dropdown free of admin entries. **Allowlist exemption (added 2026-05-07)**: a small `_ADMIN_NAME_ALLOWLIST` (backend) / `ADMIN_CLOUD_ROLE_ALLOWLIST` (frontend) names roles that LOOK administrative but are bounded to one product (e.g. AWS `DBaaS-Prod-Admin-Role` operates only inside the DBaaS-Prod sub-account, not tenant-wide) and are intentionally exposed to self-service. Match is **exact** (case-insensitive, after trim) on either `role_key` or `display_name` — substring would let a sibling like `DBaaS-Prod-Admin-Role-Plus` slip through unreviewed. Both layers must stay in sync; regression test in [`server/tests/test_cmdb/test_role_classifier.py`](../../server/tests/test_cmdb/test_role_classifier.py).
- **`cmdb_cloud_scope_requests`** — self-service "create me a sub-account" + "grant me access". State machine: `pending → feishu_sent → feishu_approved → provisioning → provisioned | rejected | failed | withdrawn`. Calls `driver.create_scope()` on approval (AWS Organizations only); manual provision path exists for other clouds. Added in migration [`068`](../../server/alembic/versions/068_role_curation_and_scope_requests.py). **AWS owner-alias (added 2026-05-25, prefix rule simplified same-day)**: AWS Organizations requires a globally-unique Email per member account, so reusing `requester.work_email` blew up on the second sub-account. `_provision` derives `<slug>@pingcap.com` from the request name (see `derive_aws_owner_alias`) — **the slug is the sanitised request name verbatim, NO forced `aws-` prefix** (names like `aws-pingcap-datalake` stay 1:1; names like `pingcap-foo` stay un-prefixed). Pathological inputs (all-CJK, all-punctuation, over Google's 64-char cap) raise `ValueError` so the admin renames the scope — no synthetic placeholder fallback that would collide between two pathological-name requests. The driver calls `GoogleWorkspaceDriver.add_alias(target_kind="user")` on the requester's mailbox (409 ALREADY_EXISTS treated as success), persists to `metadata_json.owner_alias_email` so a retry doesn't accumulate stray aliases. The alias is what's passed as `owner_email` to `driver.create_scope` — AWS sees a fresh address, the requester still receives the AWS root verification mail.
- **`cmdb_cloud_billing_daily`** — daily granular cost. UNIQUE on `(integration_id, scope_id, date)`. Two columns added in **migration 161**: `linked_account_id VARCHAR(36) NOT NULL DEFAULT ''` (the cloud account's `external_id` — backfilled from `cmdb_cloud_scopes.external_id` for registered scopes; set to the CE-reported linked-account ID for unregistered rows) and `owner_breakdown JSON NULL` (AWS-only: `{email: cost_usd}` map from `iamPrincipal/Owner` tag dimension — note: `aws:PrincipalTag/Owner` is the IAM-policy evaluator namespace and silently returns empty strings; CE's correct tag namespace is `iamPrincipal/Owner`). **Unregistered-account rows**: after the per-scope sync loop, `sync_billing_daily_for_integration` calls `driver.list_billing_daily(scope=None)` to get ALL CE-visible linked accounts; an account is written as a `scope_id=NULL, linked_account_id=acct_id` row **for each DATE it has no per-scope row on** (#2221, 2026-07-31 — the exclusion axis is the date, not the scope's status). Status-based exclusion went wrong in both directions: ACTIVE-only counted a decommissioned scope's account as unregistered and double-wrote days its historical per-scope rows already covered (#2151), while all-statuses hid SUSPENDED / CLOSED scopes from BOTH passes — the per-scope loop is ACTIVE-only for API-quota reasons — so an AWS account that keeps billing while suspended (and accrues PENDING_CLOSURE tail charges for ~90 days) vanished from the table entirely, breaking this pass's own invariant that the billing-overview total equals the provider's org-level total. The coverage set is read from `cmdb_cloud_billing_daily` itself AFTER the per-scope loop has committed, so it sees both this run's rows and older ones. Idempotency: DELETE the full date-range's `scope_id=NULL` rows for the integration first, then INSERT fresh (MySQL/TiDB NULL != NULL in unique index → UPSERT can't match existing NULL-scope rows). `BillingOverview.unregistered_cost_usd` and `.owner_breakdown` aggregate these. Used for spike detection. **Sub-tenant (RG-level) scopes are structurally absent from this table** — the per-provider daily cost cron filters its scope-fan-out to `TENANT_LEVEL_SCOPE_TYPES`, so an `azure_resource_group` `cmdb_cloud_scopes` row will NEVER have a corresponding `cmdb_cloud_billing_daily` row. The HTTP `GET /api/v1/cmdb/billing/daily` endpoint detects RG-typed scopes and routes to a `cmdb_cost_facts` aggregation instead (filter on `integration_id` + `resource_group=<scope.external_id>`, fold per-service sums into the same `cost_by_service` dict shape); tenant-level scopes still read `billing_daily` so finance reconciliation against vendor invoices is unchanged. PR #251 — pinned by [`test_billing_daily_rg.py`](../../server/tests/test_cmdb/test_billing_daily_rg.py).
- **`cmdb_billing_monthly`** — month aggregate. UNIQUE on `(integration_id, month)`. Upserted idempotently by `cmdb.billing_sync`.
- **`cmdb_sync_jobs`** — per-run audit. Columns: `triggered_by`, `resource_types` (JSON array — per-capability since [`047`](../../server/alembic/versions/047_cmdb_per_capability_sync_and_billing.py)), `status`, `capability_results` (per-capability), `total_upserted/deleted/errors`, `started_at`, `finished_at`. Lock semantics: `JSON_OVERLAPS(resource_types, ?)` allows disjoint capabilities to run in parallel.
- **Resource tables** (`cmdb_servers`, `cmdb_domains`, `cmdb_dns_records`, `cmdb_load_balancers`, `cmdb_cdn_domains`, `cmdb_object_storage`, `cmdb_iam_users`): shared shape — `integration_id`, `first_seen_at`, `last_synced_at`, `deleted_at`, `last_seen_sync_job_id` (mark-and-sweep), `owner_person_id`. `cmdb_iam_users` also has `revoked_at`, `revoke_reason`.
- **IAM cleanup cascade** (`POST /cmdb/iam-users/{id}/cleanup` disown branch, added 2026-05-05): when an admin disowns (cloud-deletes) an IAM user, the same transaction cascades to `cmdb_cloud_access_grants`. Two-pass: (a) discovered grants matched by `external_ref LIKE 'iam-user:{user_name}::%'` (the canonical FK that `_extract_iam_user_grants` stamps on every row it upserts); (b) helix-issued grants for the same person on the scopes those discovered rows live on (the IAM user was the auth handle that enacted those helix grants too). All cascades stamp `revoke_reason='iam_user_disowned'` (constant `GRANT_REVOKE_REASON_IAM_DISOWNED`). Without this cascade `source='helix'` grants would NEVER auto-clear (drift sweep skips them by design) and the 50%-safety-floor on the discovered drift sweep refuses to fire on big batch cleanups, leaving ghost grants for a deleted IAM user. Cascade count + ID list surfaced in audit detail (`cascade_revoked_grant_count`, `cascade_revoked_grant_ids` truncated to 200) and in `iam_row.raw_data._helix.cascade_revoked_grant_ids` for forensics.
- ~~**`cmdb_ai_resources`** + **`cmdb_ai_resource_cost_daily`**~~ — dropped in migrations 151 + 152 (Phase 2 AI Spending refactor, 2026-05-12). AI cost is now read from `cmdb_cost_facts` directly via the per-provider service whitelist; see §12.9.

### 13.2 Provider drivers

Adapters in [`server/app/integrations/drivers/_cloud_shared.py`](../../server/app/integrations/drivers/_cloud_shared.py) wrap legacy `CloudProvider` implementations from [`server/app/domains/cmdb/providers/`](../../server/app/domains/cmdb/providers).

Capability matrix (✓ = implemented; — = no plan):

| Provider | Servers | Domains | DNS | LB | CDN | OSS | IAM | Scopes | Roles | Grants | Billing | Account close |
|----------|---------|---------|-----|-----|-----|-----|-----|--------|-------|--------|---------|---------------|
| AWS (Organizations + IC) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | `organizations.close_account` |
| Azure | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | (not yet) | ✓ | ✓ | ✓ | ✓ | subscription cancel |
| GCP | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | (not yet) | ✓ | ✓ | ✓ | ✓ | `projects.delete` |
| Aliyun (RD) | ✓ | ✓ | ✓ | ✓ | — | ✓ | (not yet) | ✓ | ✓ | ✓ | ✓ | `RemoveCloudAccount` |
| Aliyun Intl | ✓ | ✓ | ✓ | ✓ | — | ✓ | partial (067) | ✓ | ✓ | ✓ | ✓ | Delete |
| Tencent | ✓ | — | — | — | — | ✓ | — | standalone_iam | — | — | ✓ | no-op |
| UCloud | ✓ | — | — | — | — | ✓ | — | standalone_iam | — | — | ✓ | no-op |
| KSyun | ✓ | — | — | — | — | ✓ | — | — | — | — | ✓ | — |

Credential scoping:
- **Org-root** creds (AWS Orgs master, Azure tenant app, GCP Org SA, Aliyun RD master) → discover all sub-units, manage all.
- **Sub-account** creds → that one unit only.

**Aliyun abandons every abandonable pre-check item (2026-09-02)**: `DeleteAccount` is called with `AbandonableCheckId` = every `CheckId` in the pre-check's `AbandonableChecks`. Withholding them (the 2026-09-01 shape, on the reasoning that overriding a provider safety check was not the driver's call) makes the closure **unfinishable**: Aliyun accepts the call and then kills it at `CheckFailed` on exactly the items it offered to let you pass — prod account 5995487234252122, 16 items, dead an hour after submission. It also protects nothing, because `DeleteAccount` destroys every resource in the member account regardless; the consent happened at the confirm dialog. The abandoned ids + descriptions land in `CloseScopeResult.detail` and the decommission audit row so "what did Helix wave through" always has an answer. `_acs3_call` gained list-param flattening for this (`AbandonableCheckId.1` / `.2` — a `str(list)` would send a Python repr that Aliyun silently ignores, i.e. reproduce the incident invisibly). A close that finds a dead deletion task (`CheckFailed` / `DeleteFailed`, read from `GetAccountDeletionStatus` — `GetAccount` cannot tell you, the member still reports `SwitchSuccess`) forces a FRESH `CheckAccountDelete` instead of attaching to the stale result.

**Aliyun array query params go on the wire as a JSON array, not `Name.1`/`.2` (2026-09-02, measured)**: `_acs3_call` serialises a list value into ONE query parameter holding a JSON array — `AbandonableCheckId=["SP_oss_oss_intl",…]`, matching the Aliyun CLI. It shipped for one day as the classic RPC repeated form (`AbandonableCheckId.1` / `.2`) with a green unit test asserting exactly that; Aliyun **silently ignored the parameter** and the deletion died minutes later at `CheckFailed` listing the items that had supposedly been abandoned. Same account, same call, only the encoding changed → `Deleting` with an empty `FailReasonList` and a scheduled `DeletionTime`. The failure mode is the dangerous kind: no error, no warning, and an async failure whose stated reason ("these resources still exist") is a completely plausible wrong explanation. **A unit test on an encoder proves it does what you told it to, not that the provider accepts it** — pin wire-format assertions to an observed provider response.

**Helix finishes its own closures (2026-09-02)**: `deletion_failed` is NOT terminal. `cmdb.decommission_reconcile` re-submits a closure the provider let die, bounded by `MAX_AUTO_CLOSE_RETRIES=3` **unproductive** attempts (audit `status='failed'`) counted from `cloud.scope.decommission_auto_retry` rows — an attempt that lands mid-pre-check is progress, and on a daily cadence charging it would spend the allowance on the mechanism working — plus an absolute `MAX_AUTO_RETRY_ATTEMPTS=12` ceiling so a perpetually-in-flight closure cannot be retried forever (no column, survives restarts, and the count is answerable from the log). Budget spent → `closure_stalled` (terminal) + one alert. A retry that lands mid-pre-check (`deletion_precheck_running` / `cloud_account_switch_pending`) counts as progress and does not burn budget. **`alive` is never auto-re-closed** — a failed closure is unfinished work, a reversed one is a person's decision, and Helix finishes the former without overruling the latter. On confirmed completion the reconciler notifies **only `decommissioned_by`**: one click, one answer.

**`get_scope_closure_state` — the read-only counterpart to `close_scope_on_provider` (added 2026-09-01)**: returns a `ScopeClosureState(disposition, provider_state, detail, error_message)` where `disposition` is one of `pending` / `closed` / `alive` / `failed` / `unknown`. Implemented by the four multi-account drivers — Aliyun (`GetAccountDeletionStatus`, whose documented vocabulary is `Checking`/`Deleting`/`Success`/`CheckFailed`/`DeleteFailed`, falling back to `GetAccount` when no deletion task exists), AWS (`describe_account` → `SUSPENDED`/`PENDING_CLOSURE`/`ACTIVE`), GCP (`projects.get` → `DELETE_REQUESTED`/`DELETE_IN_PROGRESS`/`ACTIVE`), Azure (subscription GET → `Deleted`/`Disabled`/`Warned`/`Enabled`/`PastDue`). **The rule for adding a state to any of these mappings (2026-09-02): if a CORRECTLY-WORKING closure can pass through it, it is `pending` — whatever else it might also mean.** Azure's `Warned` was mapped `alive`/`active` on the assumption that it was only the *pre*-cancellation grace state; it is also the *post*-cancel one (measured in prod: `cancel/action` Succeeded in ARM's activity log, 0 resources, still `Warned` 48 minutes later). Getting this wrong costs more than one wrong alert: `active` is in `TERMINAL_PROVIDER_STATES`, so the scope leaves the reconcile queue permanently and its real completion is never confirmed. `alive` is reserved for states in which the account is genuinely USABLE again (Azure `Enabled`/`PastDue`, GCP `ACTIVE`, AWS `ACTIVE`) — which is exactly what portal re-activation / `projects.undelete` produce, so the alarm loses nothing. The IAM-evacuation drivers (Tencent, UCloud, AWS `standalone_iam`) inherit the base `NotImplementedError`: their closure is a set of DeleteUser calls with no provider-side after-state, and the reconciler reads that as "nothing to reconcile" rather than an error. **A read that fails must return `unknown`, never `closed`** — every driver's error branches are written that way and tested for it, because the one thing this surface must never do is let "we could not check" masquerade as confirmation. Consumed by `cmdb.decommission_reconcile`; see `server/app/domains/cmdb/decommission_reconcile.py`.

**Aliyun real closure — CloudAccount members must be converted first (2026-09-01)**: `AliyunCloudDriver._close_rd_account` runs `GetAccount` → (`UpdateAccount NewAccountType=ResourceAccount` when the member is typed `CloudAccount`) → `CheckAccountDelete` → `GetAccountDeletionCheckResult` → `DeleteAccount`. Aliyun's `DeleteAccount` only accepts `ResourceAccount` members (`MemberTypeError.DeleteAccount`); the pre-2026-09-01 driver fell back to `RemoveCloudAccount`, which merely unlinks the member from the Resource Directory while the account stays alive and billable — and Helix still flipped the scope to `closed`. `RemoveCloudAccount` is no longer reachable from the close path at all; an un-convertible member (`LegalEntityDifferent`, i.e. an invited third-party account) fails loudly instead of silently downgrading to an unlink. **The whole method is resumable and idempotent by design**, because Aliyun documents its mandatory deletion pre-check as taking 3–5 minutes: every step re-derives its precondition from live provider state (type already `ResourceAccount` ⇒ skip the switch; a check task already in flight ⇒ attach rather than restart — restarting would re-arm the 3–5 minutes on every click and the close could never finish). A run that catches the pre-check mid-flight returns `error_code='deletion_precheck_running'`, which `decommission_scope` maps to a distinct `SCOPE_CLOSE_PRECHECK_RUNNING` / 409 so the UI renders progress, not failure. `AlicloudIntlCloudDriver` inherits this unchanged — the RD control plane (`resourcemanager.aliyuncs.com`) is global and needs no intl override, unlike BSS and ActionTrail. Terminal `provider_state`: `deleted` (`DeletionType='0'`) or `deletion_silence_period` (`DeletionType='1'`). Aliyun-specific `error_code`s on top of the Round-52 set: `foreign_legal_entity`, `cloud_account_switch_failed`, `unclosable_member_type`, `deletion_precheck_running`, `deletion_precheck_failed`, `deletion_not_allowed`, `payer_relationship_active`, `delegated_administrator`.

**Member-status "already closed" detection is a conservative allowlist (2026-09-01)**: `_RD_GONE_STATUSES = {Removed, RemoveSuccess, Deleting, DeleteSuccess}`. It replaced `status in {…PromoteVerifying…} or "Remov" in status or "Delete" in status`, which matched three families of *live* member: `PromoteVerifying` / `SwitchSuccess` (a ResourceAccount ⇄ CloudAccount type conversion in flight — all 14 of prod's CloudAccount members report `SwitchSuccess`) and `RemoveFailed` / `DeleteFailed` (the removal did NOT happen). Any of those read as `already_closed=True`, which flips the Helix row to `closed` and cascade-disables every grant having called nothing on the provider. Unknown statuses deliberately fall through to a real close attempt: failing loudly and changing nothing is the cheap direction of being wrong; silently losing a live billing account is not. **Do not grow this set on a guess.**

**`close_scope_on_provider` error-code dispatch (added 2026-05-26 Round-52)**: each driver maps provider-specific 4xx/5xx rejection codes onto a stable set of `CloseScopeResult.error_code` strings so the admin sees an actionable hint instead of the raw JSON body. Stable codes today: `resources_existing` (Azure `ResourcesExistingOnSubscription`, Aliyun `Member.NotAllowedDelete.HasResource`/etc., AWS `ConstraintViolationException` resource-bound), `pending_charges` (Azure `PendingCharges`/`BillingAccountInactive`/`OutstandingCharges`/`BillingProfileNotActive`, Aliyun `Account.Arrears`/`HasUnpaidBill`/etc.), `missing_owner` (Azure `SubscriptionMustHaveOwner`/`SubscriptionHasNoOwner`), `not_real_name_authed` (Aliyun `NotPassRealNameAuth`), `liens_exist` / `billing_attached` (GCP — anchored on `ErrorInfo.reason` enum, NOT message substring), `access_denied` / `auth_failed` (403 / 401), `throttled` (429), `transient_failure` (5xx), `cancel_precondition_failed` / `precondition_failed` (Azure 400 / GCP 400/412 with un-classified reason — still carries the provider's symbolic code in detail for forensics), `delete_failed` / `cancel_failed` (generic fallback when no code parsed). See the Aliyun block above for the 2026-09-01 additions. Every provider-originated text on the path (`arm_code`, `arm_message`, GCP `detail_text`/`reason`, Aliyun `msg`) routes through `redact_provider_error` before landing in either the user-facing message OR audit detail.

**`preflight_close_scope` / `clear_close_blockers` — Helix clears its own preconditions (added 2026-09-07)**: two driver methods that turn the `resources_existing` / `liens_exist` refusals above from an error message with a CLI command in it into work Helix does. `preflight_close_scope(scope)` is a pure read returning `ClosePreflight(supported, checked, cleanup_supported, blockers[], error_code, error_message)`; `clear_close_blockers(scope, keys=[…])` deletes and returns `ClearBlockersResult(ok, started, completed, failed, in_progress)`. Implemented by **Azure** (resource groups + subscription-level resources grouped by RG + `Microsoft.Authorization` locks; cleanup deletes **locks first** because a `CanNotDelete` lock makes the group delete 409 — a cleanup that ignores locks passes on empty test subscriptions and fails on the real ones) and **GCP** (project liens: `liens.list` / `liens.delete`, synchronous so `in_progress=False`). The other four drivers inherit `supported=False` / `cleanup_unsupported`. Three invariants: (1) **a failed read is `checked=False`, never an empty `blockers`** — the dialog's 「没有需要清理的资源」 copy is gated on `supported and checked`, so collapsing them would make Helix promise an empty subscription it never looked into; (2) **`CloseBlocker` carries `children` / `child_count`** — a resource group's name alone is not enough information to consent to deleting it (the 2026-09-07 case was a $0.00-spend subscription submitted as 从未使用过 that held a live storage account); (3) **drivers MUST NOT delete anything outside `keys`**, and Azure additionally refuses the whole call (`key_scope_mismatch`) if any key does not address the subscription being closed. `blockers[].removable=False` means a human must act — the one case where handing the problem back is honest. Codes that are NOT cleanable stay out of `_CLEANABLE_CLOSE_ERROR_CODES` on purpose (`pending_charges`, `missing_owner`, GCP `billing_attached`): they need authority Helix does not have, and pretending otherwise would leave a decommission pending forever while nobody was told.

### 13.2.1 Cloud read-side authorization model (added 2026-05-03)

Three-tier access posture across the cloud surface:

- **`can_read_cloud`** (admin / auditor / cloud_admin / management) — sees the fleet. Used by `/cmdb/load-balancers`, `/cmdb/cdn-domains`, `/cmdb/object-storage`, `/cmdb/dns-zones`, `/cmdb/billing/daily?scope_id=` (when scope_id absent), `/cmdb/stats`. Load-balancers and object-storage have **no `owner_person_id` column**; CDN gained `owner_person_id` in migration 160 (2026-05-13) and now supports `?owner_person_id=` filtering, but the endpoint remains admin-only since CDN metadata is not considered employee-safe reconnaissance (no IP/DNS hostnames in the response). No per-employee story for LB/OSS — gating on `can_read_cloud` is the only safe option for those.
- **Auto-demote** (employees + managers) — `/cmdb/servers`, `/cmdb/domains`, `/cmdb/dns-records` (and their `/{id}` and `/export` cousins, plus the MCP `list_dns_records` tool) override the `owner_person_id` query param to `{caller.id ∪ get_subordinate_person_ids(caller)}` when the caller lacks `can_read_cloud`. Manager scope is bounded BFS through `identity_persons.manager_person_id`, max_depth=5, cycle-safe. Per-row `GET /{id}` reads do the same check before returning. Without this, an employee `curl`-ing the list endpoint or guessing UUIDs got every internal hostname / private-IP A record / DKIM TXT across every cloud account.
- **Member-or-owner-or-admin** (per-row) — `/cmdb/scopes/{id}`, `/cmdb/billing/daily?scope_id=…`, scope-detail enrichments. Service-side `_require_scope_owner_or_admin` checks; member-detail responses redact `alert_rules.cc_person_ids` from non-`can_read_cloud` viewers (the cc list is the company's billing-incident escalation roster — often C-suite UUIDs that combined with `pickable-people` reverse-resolve to names + emails for phishing). `listMySubscriptions` similarly redacts other-members' cloud `roles` array.

Self-approval is blocked at four points: `/grants/{id}/approve` (scope owner cannot approve own grant request — use `owner_add_member`), `owner_add_member` (owner cannot add themselves — they already have owner-equivalent access), `/scope-requests/{id}/approve` (global_admin cannot approve own scope-creation request), `update_iam_user_owner` (cannot rebind to a leaver). Feishu callbacks for scope-requests use `approver_is_system=True` to bypass self-block AND mark `audit.actor_type='integration'` instead of falsely stamping the requester as their own approver.

IAM `mine=true` matches `owner_person_id == caller.id` ONLY (no email-local-part fallback) — the previous heuristic surfaced cross-tenant IAM ARN+attached_policies+groups+MFA whenever local-parts collided. The IAM reconcile job is the only legitimate way to populate `owner_person_id`.

### 13.3 Sync architecture

Each cron job dispatches a per-capability run:

| Cron | Schedule (typical) | What |
|------|-------------------|------|
| `cmdb.resource_sync` | per (integration, capability), e.g. servers every 6h, dns every 12h | calls `driver.list_<capability>()`, upserts, mark-and-sweep via `last_seen_sync_job_id`. **Per-capability timeout (added 2026-05-16, pre-launch hardening)**: every `_sync_*` dispatch is wrapped in `asyncio.wait_for(..., timeout=_CAPABILITY_SYNC_TIMEOUT_S = 15 * 60)`. Without this, a slow-but-not-erroring cloud API (boto3 multi-region fan-out, GCP CRM v3 100-project crawl, Azure 80-sub fan-out) could string together a multi-hour hang and exhaust the worker's connection pool until k8s liveness probe kills the pod. On timeout the capability lands `status='failed'` with `timed_out: true` + `timeout_s: 900` so the failure-notifier alerts and the next cron tick retries. **Per-leg partial-failure visibility (added 2026-05-16)**: providers' multi-region / multi-subscription / multi-project fan-out helpers now append `{leg, capability, reason}` entries to `CloudProvider.partial_failures`; the engine drains this list via `consume_partial_failures()` after each capability completes, embeds in `capability_results[cap]['partial_failures']`, flips the capability `status='partial_failure'` (was silently `success`), and increments `total_errors` so the job rollup correctly lands `partial_failure` instead of green. AWS region 4xx fall-through, Azure per-subscription 4xx, and GCP per-project exceptions all participate. Without this, a sync that 403s on 3 of 20 Azure subs would report ~15% missing rows as a successful sync (and the safe-sweep 95% floor would only trigger on >20% missing). **Provider error redaction (added 2026-05-16)**: every `ProviderError` raised by the AWS / Azure / GCP / Tencent drivers — and the engine's `except Exception` boundary that records `capability_results[cap]['error']` — is run through `app.domains.cmdb.providers.exceptions.redact_provider_error`, which matches AKIA/ASIA/AKID/LTAI/STS./UCloud/GOOG/NGAU access-key prefixes, `AccessKeyId=` / `Signature=` / `X-Amz-Signature=` / `Bearer …` / `api_key=` query-param and header values, and truncates to 300 chars. Mirrors the long-standing `_redact_aliyun_error` in the classic Aliyun provider. Without it boto3 `ClientError` / Azure 400 AAD bodies / GCP `HttpError` strings (which can echo a failing signed URL or include `~/.aws/credentials` path hints) would land in `cmdb_sync_jobs.capability_results` (admin-readable in the sync-history UI), the failure-notifier Feishu card, and log aggregator retention (30+ days). **Sweep guard (added 2026-05-03, tightened to 95% on 2026-05-05, baseline refactored to sync-to-sync on 2026-05-14, tolerance promoted to a platform_settings runtime knob on 2026-05-19)**: `_safe_sweep` refuses to soft-delete when `upserted == 0 and baseline > 0` OR `upserted < baseline × (1 - tolerance/100)` — instead raises `ProviderTransientError` and the capability is marked `failed`. The tolerance percent comes from `platform_settings['cmdb_sync.sweep_drop_tolerance_percent']` (admin-editable in the Platform Settings page; effective on the next sweep call, no restart) with `.env` `CMDB_SYNC_SWEEP_DROP_TOLERANCE_PERCENT` fallback (default 20 — was the hardcoded 0.95 keep-ratio = 5% tolerance pre 2026-05-19, which wedged multi-region clouds where one region drops 5–15% empty). The **baseline** is the previous successful sync's `upserted` count for this `(integration, capability)` pair (looked up via the new `ix_cmdb_sync_jobs_integration_status_finished` composite index added in migration 165, scanning the last 20 jobs); falls back to Helix-existing count when no prior success is on record (first-ever sync of a new integration, or after streak longer than the scan window). The sync-to-sync baseline is self-correcting under natural cloud-side drift — a steady-state cloud that loses 1 of 100 rows between syncs reads as 99% of previous → no trip → sweep proceeds → baseline updates to 99 for next time. The prior algorithm (compare to `existing`) made the system permanently lock as soon as ≥5% of any capability's rows accumulated as stale (AWS prunes terminated EC2 after ~1h; DNS records deleted in a batch; domains transferred; etc.); the lock persisted forever because each failed sync left more stale rows so `existing` kept inflating. Force-sweep override (PR #299) remains the one-shot escape for legitimately large bulk drops (admin-confirmed). Without this, a transient driver hiccup that returned `[]` would silently nuke every previously-known row. Also re-raises `ProviderAuthError` from the AliCloud-Intl master account (was swallowed). AWS S3 `list_buckets` non-auth `ClientError` re-raises as `ProviderTransientError`. UCloud CDN/UFile "service not enabled" detection by numeric `RetCode` not English substring. Tencent CAM `ListUsers` paginates (was capped at first 20). IAM `_sync_iam_users` clears `revoked_at` when the cloud-side user reappears (was leaving stale "revoked" state, hiding live IAM credentials in the UI). **Active-only baseline for IAM (added 2026-05-14, second pass after PR #299)**: the guard's `existing` count + sweep WHERE now both accept an `extra_active_filter` from the caller. `_sync_iam_users` passes `[CmdbIamUser.revoked_at.is_(None)]` so a tenant with admin-revoked history (9 active + 11 revoked, the user's exact prod shape) doesn't read as a 55% drop on every cron tick — the cloud's `list_iam_users` returns only the 9 active, and Helix now compares 9-active to 9-active. The 11 revoked rows remain in the table indefinitely as history (the 已被撤销 tab still shows them) and the sweep's UPDATE excludes them so they're never silently soft-deleted. Other capabilities call `_safe_sweep` without `extra_active_filter` (no Helix-side lifecycle column on `CmdbServer` / `CmdbDomain` / etc.). **Admin override (added 2026-05-14, PR #299)**: parallel to the grants_sync override below — `POST /api/v1/cmdb/sync/{id}/force-sweep` (`global_admin` via `can_manage_cloud`, takes `resource_types: list[ResourceType]` + optional `reason`) re-runs `run_sync(force_sweep=True)` so BOTH guards are bypassed for the chosen capabilities; missing rows finally soft-delete and the cron returns to green. Holds the `cloud_sync:<integration>` distributed lock (409 `CLOUD_SYNC_ALREADY_RUNNING` on contention with scope sync / grants override). Audit row `cmdb.sync.force_sweep_override` carries `integration_id`, `provider`, `resource_types`, `reason`, `job_id`. Inner result surfaces `capability_results[cap].forced_sweep: true` + `forced_sweep_guard` (`drop_ratio` | `zero_rows`) + `forced_sweep_existing` so the UI shows exactly which guard tripped on each cap. Triggered_by enum value `"force_sweep"` on `cmdb_sync_jobs`. NO matching MCP tool (admin-override semantics deliberately off PAT surface — same precedent as the grants override, `^force_` denylist). UI affordance mirrors the drift-sweep: warning banner on `error_message.includes('refusing to sweep')` runs in BOTH the per-job drawer (`ScheduledJobsPage`) and the global runs view (`ScheduledJobRunsPage`), with a Popconfirm-gated danger button + auto-ack of the resolved failure run. Bug report 2026-05-14: AWS-SG IAM 20 → 9 (9 active + 11 admin-revoked); clicking 「标记为已确认」 only hid the alert and the failure_notifier kept re-paging — root-caused two ways: (a) PR #299 added the force-sweep escape valve and (b) this PR fixes the underlying algorithm so the IAM case no longer trips at all. |
| `cmdb.scopes_sync` | 04:00 daily | discovers sub-accounts; auto-fills owner if exactly one person holds owner-equivalent role; flips `status=closed` on removed units. **Grant cascade on close (added 2026-05-29, migration 217)**: when the sweep flips a scope to `status='closed'`, every `active` / `pending` grant on that scope is cascade-disabled in the same transaction with `status='disabled'` + `revoke_reason='scope_closed'` (constant `GRANT_REVOKE_REASON_SCOPE_CLOSED`) + `revoked_at=NOW()` + `last_notified_stage=NULL`. Pre-fix the sweep flipped the scope but left the grants `active` forever — `grants_sync`'s drift sweep is restricted to `synced_scope_ids`, so closed scopes accumulated `status='active'` zombie grants indefinitely (prod 2026-05-28: 29 stranded UCloud grants on a closed scope, last_seen 24 days stale). Two audit rows: `cloud.scope.sweep_close` always fires when ≥1 scope closed (canonical "which scopes closed" surface); `cloud.scope.sweep_close_cascade` only fires when grants were actually disabled (so the audit row's `grants_disabled` count is always non-zero / meaningful). The explicit `decommission_scope` cascade (admin "下线" button) was updated in the same PR for symmetry: now also stamps `revoke_reason='scope_closed'` + `revoked_at` + clears `last_notified_stage`. Migration 217 backfills `cmdb_cloud_access_grants` rows on currently-closed scopes; downgrade is JOIN-gated by `s.status='active'` so a future operator running `alembic downgrade` for unrelated reasons cannot re-grant access on still-closed scopes. |
| `cmdb.roles_sync` | 04:30 daily | populates `cmdb_cloud_roles`, marks curated set with `is_promoted=true`. **Drop-tolerance + every-scope-failed guards** (mirrors `_safe_sweep` semantics): at end-of-sync, raise `RuntimeError("...refusing soft-delete sweep...")` and skip mark-and-sweep when (a) every scope's `list_scope_roles` failed and Helix-active baseline > 0, OR (b) `upserted < baseline × (1 - tolerance/100)` (default 20%, same `platform_settings['cmdb_sync.sweep_drop_tolerance_percent']` knob as the resource_sync floor). Defends against a partial driver outage emptying the access-request dropdown across the whole org. **Admin override (added 2026-05-29 after PR #565 prod incident)**: `POST /api/v1/cmdb/integrations/{id}/roles-sync/force-sweep` (`global_admin` via `can_manage_cloud`) calls `sync_roles_for_integration(force_sweep=True)` to bypass BOTH guards with explicit human confirmation. Holds the same `cloud_sync:<integration>` distributed lock as the cron (409 `CLOUD_SYNC_ALREADY_RUNNING` on contention). Audit row `cmdb.roles_sync.force_sweep_override` carries `integration_id`, `provider`, `upserted`, `deleted`, `forced_floor_bypass`, `errors`. Inner result surfaces `forced_floor_bypass: {trip: 'every_scope_failed' | 'drop_ratio', baseline, upserted, tolerance_pct?, floor?, errors?}` so the UI shows which guard tripped and by how much. NO matching MCP tool (admin-override semantics deliberately off PAT surface — same precedent as resource force-sweep + grants drift-sweep, `^force_` denylist). UI affordance on `ScheduledJobRunsPage` mirrors driftSweep / resourceSweep: error pattern `'refusing soft-delete sweep'` + `handler_key='cmdb.roles_sync'` → warning banner + Popconfirm-gated danger button + auto-ack of the resolved failure run. Incident motivating this: PR #565 added the `AliyunReserved*` filter to `AliyunCloudDriver.list_scope_roles`, legitimately collapsing the 10 674-row catalog (mostly RAM `AliyunReservedSSO-*` mirror policies that `AttachPolicyToUser` 400's on) to 288 real CloudSSO permission sets — guard correctly refused, ack-only had no path to resolution. |
| `cmdb.grants_sync` | 05:00 daily | **Tencent SDK throttle (2026-05-09)**: per-user `ListAttachedUserAllPolicies` calls now go through `_tencent_throttle.tencent_call_sync` so the per-tenant 5 RPS CAM cap can't abort a multi-user snapshot mid-loop (would otherwise be a mass-revoke hazard via the mark-and-sweep below). discovers cloud-side grants → inserts with `source='discovered'`; per-scope mark-and-sweep at end of every successful sync flips stale `source='discovered'` rows whose `last_seen_grants_sync_job_id` did not match the current run to `status='revoked'` with `revoke_reason='drift_removed'` and clears `last_notified_stage` (added 2026-05-05). Restricted to scopes the driver returned successfully — a transient outage cannot snowball into mass revoke. Helix-issued (`source='helix'`) rows never touched. AWS self-service managed policies (`IAMUserChangePassword` / `IAMUserSSHKeys` / `IAMSelfManageServiceSpecificCredentials`) are filtered at synthesis time in `_derive_grants_from_iam_users` so they never become tracked grants in the first place. **Friendly role-name repair (2026-08-09)**: before upserting any discovered grant, the shared sync resolves its `role_key` against the active scope-specific (preferred) or tenant-level `cmdb_cloud_roles` catalog entry. The catalog display name is stored on both new and existing discovered grants, so AWS Identity Center permission-set ARNs and any other provider's opaque role keys render as their curated human name; if no catalog name exists, the provider name remains the fallback. Summary now exposes `drift_closed: int`. One audit row per integration-run with reason `cloud.grant.drift_removed_sweep`. **50% safety floor + admin override (added 2026-05-06)**: the sweep refuses when `existing_active >= 5 ∧ matched/existing < 50%` and emits a `drift_sweep refused` error in the run summary instead of closing rows — defends against driver-pagination truncation silently mass-revoking access. Without an escape valve a single bulk admin cleanup in the cloud console deadlocks every subsequent run because the floor's match-rate stays below 50% until the stale rows close, but they can't close because the floor refuses. New `POST /api/v1/cmdb/integrations/{id}/grants-sync/force-drift-sweep` (cloud-admin only) calls `sync_grants_for_integration(force_drift_sweep=True)` to bypass the floor with explicit human confirmation, holds the same `cloud_sync:<integration>` distributed lock as the cron, returns `drift_closed_grant_ids` for UI feedback, and writes a `cloud.grant.drift_sweep_force_override` audit row attributing the decision to the admin user. Inner sweep audit gains `force_override: bool`, `matched_active: int`, `existing_active: int` in detail. Mirrored as MCP tool `force_cloud_grants_drift_sweep`. UI affordance lives on the per-job runs drawer in `ScheduledJobsPage` AND on the global runs view in `ScheduledJobRunsPage` — the global page now includes `job_config` on every run row so the override button can render without a second round-trip. **Per-user policy enumeration pagination (added 2026-05-29 after prod incident on UCloud)**: every driver's per-user "list policies attached" call must walk every page or the missing tail looks like cloud-side drift on the next sweep. UCloud `ListPoliciesForUser` (Offset/Limit/TotalCount, default 100) and AWS IAM `list_attached_user_policies` (boto3 `get_paginator`) were single-call → now both paginate and raise on mid-pagination transient failures. Aliyun RAM `ListPoliciesForUser` is intentionally single-call: the 2015-05-01 API has no pagination markers and RAM caps managed-policy attach at 10/user. Tencent `ListAttachedUserAllPolicies` was already paginated. **Scope-close cascade (added 2026-05-29 same PR)**: see [`sync_scopes_for_integration`](../../server/app/domains/cmdb/lifecycle_service.py) note below. |
| `cmdb.billing_daily_sync` | 06:00 daily | fetches yesterday's spend per scope; evaluates per-scope alert rules + cross-7d baseline; fires Feishu spike alert on >2× baseline (configurable) |
| `cmdb.access_expiry_sweep` | 08:00 daily | T-14/T-7/T-1 reminder pass; T+0 revoke pass. **Prod scopes are excluded from EVERY stage (2026-07-16)** — no reminder, no revoke, no notification (member or owner). Prod cloud access is not managed by Helix (never auto-recycled; prod writes default-locked so a "一键续期" card is a dead-end). Counters `prod_reminder_skipped` / `prod_skipped` in the run summary; the read-only admin-overview `prod_overdue_grants` stat still counts past-due prod grants for visibility. **`cloud_admin` and `global_admin` grants are skipped entirely** (2026-08-02 policy; migration 440 repairs global-admin rows). **Scope-owner exemption (2026-05-12)**: a grant where `person_id == scope.owner_person_id` is skipped at every stage — no T-14/T-7/T-1 reminders, no T+0 revoke. Owner is responsible for their own grants on their own scope; auto-revoking deadlocks the approval flow (owner can no longer approve their own renewal or anyone else's). Counter exposed as `owner_skipped` in the run summary. Exemption is keyed on the (scope, person) pair — same person as a regular member on a different scope still flows through normal expiry. **One-click renew magic link (2026-07-10)**: each T-14/T-7/T-1 Feishu card carries a `一键续期 N 天` primary button (action token, purpose=`cloud_grant_renew`, subject=holder, resource=grant id) → public landing `/cloud/grants/renew` → `POST /api/v1/cmdb/grants/renew-by-token` (strict holder-self-renew + active-employee guard + prod write lock; N read live from `cloud.grant_renewal_days`). Works from the Feishu mobile webview without SSO — `FeishuMobileGuard` exempts token magic-link paths (`/cmdb/expiry/ack`, `/cloud/grants/renew`) from the first-entry `/m` bounce that previously dropped the token. **Rewritten 2026-07-29** (see CHANGELOG): (a) **administrator permanence is enforced by one predicate** `load_perpetual_person_ids` shared by the discovery writer AND both passes, plus a normalization pass that CLEARS a bogus TTL off a perpetual row rather than merely skipping it — previously the discovery writer stamped 90 days on cloud admins and this sweep had no cloud-admin exemption at all (its sibling `sweep_inactive_grants` did), so 162 grants were revoked while the UI showed 「永久授权」; counters `perpetual_skipped` / `perpetual_normalized`. (b) **The reminder is aggregated PER PERSON, not per grant** — the per-grant fan-out sent 861 cards to 198 people in one run and 63 to one person in one day. (c) **The T+0 pass now notifies the holder** (`revoke_notified`); before this it sent nothing at all, so access vanished silently and people found out by being denied in the cloud console. (d) The magic-link button **files a renewal request** instead of renewing — renewal needs the scope owner's approval (`cmdb_cloud_grant_renewal_requests`); both the new and legacy token purposes are accepted so cards already in people's Feishu keep working. |
| `cmdb.renewal_lapse_sweep` | 09:00 daily (01:00 UTC) | Closes cloud grant renewal requests nobody decided within `RENEWAL_REQUEST_LAPSE_DAYS` (14), and — in the same tick, BEFORE lapsing — nudges the approver about submissions whose deadline is within `RENEWAL_OWNER_NUDGE_LEAD_DAYS` (3), once only (`owner_reminded_at`). Lapsing is neither an approval nor a rejection: the grant expires on its original schedule and the holder is told. Required rather than lazily expiring on read, because an open request holds the grant's single open-renewal slot (the UNIQUE on `open_for_grant_id`), so a forgotten request makes the grant permanently un-renewable — the holder's resubmission 409s with an error that reads like a bug — and the row stays approvable months later, which is how you resurrect access everyone had forgotten. Scheduled AFTER `cmdb.access_expiry_sweep` (00:15 UTC) so an overnight decision is already terminal and never lapsed in the tick it was approved. Seeded by [`438`](../../server/alembic/versions/438_seed_renewal_lapse_sweep_cron.py). |
| ~~`cmdb.scope_inactivity_sweep`~~ | **REMOVED 2026-05-09** (migration 138). Job + handler deleted. The `last_login_at` signal it depended on couldn't be populated reliably from the SSO-class logins our users actually do (AWS Identity Center + Azure portal SSO sign-ins aren't queryable through the IAM lastUsed APIs we wired up), so Stage-0 alerts kept firing on stale / NULL data and reaching active users as "60+ days inactive" false positives. Going forward the platform relies solely on the expiry-based gate (`cmdb.access_expiry_sweep` above) — T-14 / T-7 / T-1 nudges, T+0 auto-revoke, prod fully excluded (no reminder/revoke/notify). The lifecycle-service helpers (`sweep_inactive_grants`, `refresh_person_last_login_in_scope`, `disable_grant_due_to_inactivity`) still exist for the existing test suite but are no longer wired to any cron. **Scope-owner exemption (2026-05-12)**: the helper now also skips (scope, person) pairs where `person_id == scope.owner_person_id` at every stage, mirroring the expiry-sweep change. Even if a future sweep returns, the owner exemption is in place. Counter exposed as `skipped_owner` in the summary. The `cloud.inactivity_*_days` platform_settings rows are intentionally left in place to document intended thresholds if a future sweep ever returns. |
| `cmdb.cost_facts_raw_purge` | 03:35 daily | NULLs `cmdb_cost_facts.raw` for accounting days below the retention window (DB-first `cost_facts.raw_retention_days`, default 90, floor 45). Walks one day at a time — `date` leads `ix_facts_date_id`, and per-batch commits bound both the TiDB membuffer (error 8176) and the GC safepoint. **Interruptible by construction (2026-09-02)**: the watermark `cost_facts.raw_purged_through_date` is committed **after every completed day**, and `max_seconds_per_run` (default 600s, checked BETWEEN days) stops the sweep cleanly with `stopped_early='time_budget'`. Both exist because the first production run was killed by the scheduler's 1800s hard timeout having purged zero rows. **Root cause (found 2026-09-02 by reproducing it and catching the statement in TiDB's processlist): an infinite loop.** SQLAlchemy's JSON type renders Python `None` as the JSON literal `null` — a real value — so the sweep issued `SET raw='null'`, its own `WHERE raw IS NOT NULL` still matched every row it had just purged, and it re-selected the same 2000 ids forever. Fixed by `JSON(none_as_null=True)` on the column plus an explicit `values(raw=sa_null())` at the sweep. **Assert this in SQL, never through the ORM** — reading a JSON `null` back through `CmdbCostFact.raw` deserialises to Python `None`, so an ORM-level `assert row.raw is None` is true in both worlds, which is why the original tests were green while production span. The loop itself is fast when it works (measured in-region: ~6,600 rows/s sustained over 2M rows, so a 30-day slice is ~85s). The fatal part was bookkeeping: an end-of-run-only watermark discarded the whole run, and since `days_processed` counts empty days too, every later run re-walked the same prefix and never reached new work — **a job that failed once could not recover on its own**. `caught_up` is anchored on `day >= cutoff`, not on "did we spend the day budget". Migration 584. |
| `cmdb.decommission_cleanup_resume` | `*/5` | **The half of a decommission that happens BEFORE the provider accepts.** Azure refuses `Microsoft.Subscription/cancel` while the subscription holds resources and GCP refuses `projects.delete` while a lien is attached; with a confirmed blocker list `decommission_scope` now deletes them instead of raising, and this job carries the close to the end (cloud-side deletion is asynchronous — an Azure RG delete answers 202 and then runs for minutes). Queue: `status='active' AND decommission_pending_since IS NOT NULL AND decommission_cleanup_state='cleaning'`. Per tick: re-read `preflight_close_scope` → intersect live blockers with `decommission_cleanup_keys` → delete the intersection → only when nothing is left, retry the close through `decommission_scope` itself (same bookkeeping, same audit, actor = the person who clicked). **Deletes only confirmed keys**: a live blocker outside the list → `needs_reconfirm` + one notice, never a silent deletion. Budget `MAX_CLEANUP_ATTEMPTS=72` (≈6h) → `stalled` + one notice; a non-removable blocker → `blocked` + one notice. All three terminal states leave the queue so a notice cannot repeat every 5 minutes. **Never advances on an unreadable preflight** — "could not look" must not become "nothing left". **Session per scope** (`_database.async_session_factory()` resolved per call, not import-bound): the advance path's `AppException` on a still-refusing provider is expected, and one shared session would let its rollback discard the attempt counters of every sibling in the tick. `distributed_lock(ttl=600)`, `weight="light"`. Counters: `checked`, `closed`, `still_cleaning`, `needs_reconfirm`, `blocked`, `stalled`, `unreadable`, `notified`, `errors`. Migration 624. |
| `cmdb.decommission_reconcile` | 04:20 daily | **The read-back that makes "closed" mean something a day later.** `decommission_scope` is cloud-first, so the *submission* is honest — but every cloud finishes asynchronously (AWS ~90d `PENDING_CLOSURE`, GCP ~30d `DELETE_REQUESTED`, Azure `Warned` → `Disabled` → ~90d `Deleted`, Aliyun check → silence period) and Helix wrote `status='closed'` at hour zero and never looked again. Selects scopes that are `closed` with a non-NULL, non-terminal `decommission_provider_state`, calls `driver.get_scope_closure_state()` on each, and writes the provider's word back. Three outcomes: **completed** → terminal state + `cloud.scope.decommission_confirmed` audit, leaves the queue; **failed** (Aliyun `CheckFailed`/`DeleteFailed` — no retry, account alive) and **alive** (GCP `projects.undelete` / Azure re-activation, both self-service for the whole grace window and invisible to Helix) → `provider_state` `deletion_failed`/`active`, `cloud.scope.decommission_contradicted` audit (status=failed), and ONE aggregated admin alert per run. **Never reopens a scope** — reversing an irreversible admin action on the strength of one provider read is a bigger decision than the one being corrected. **Never treats a read failure as a closure** — `unknown` changes nothing and stays in the queue; "we could not check" and "the provider confirms it is gone" are the two facts this job exists to keep apart. Terminal in BOTH directions (finished, and already-alerted contradiction) so an alert cannot repeat nightly until people learn to ignore it. NULL `provider_state` (legacy / manual-console closes — prod carries ~1000 historical GCP rows) is out of scope: no submission to track. 200/run cap with `skipped_over_limit` counted AND logged. Raises only when ≥50% and ≥3 of what it tried to check came back unreadable — a contradiction is a finding, not a handler failure, but a reconciler that cannot see must not look like "all clear". `distributed_lock(ttl=1800)`, `weight="heavy"`. Summary counters: `checked`, `confirmed_closed`, `still_pending`, `state_advanced`, `failed_closures`, `reopened_alive`, `unreadable`, `unsupported`, `alerted_recipients`, `skipped_over_limit`, `errors`. Migration 589. |
| `cmdb.scope_idle_sweep` | 09:00 Mondays | builds digest of scopes with 0 active grants + $0 spend past 30d; **notifies BOTH the scope owner (per-owner card listing only their candidates) AND IT admins (full digest)**. Recipient set is deduped by `person_id` so an admin who is also one of the candidate owners gets one card, not two. **Per-scope dedup (migration 199 / 2026-05-25)**: `cmdb_cloud_scopes.last_idle_notified_at` is stamped after every successful dispatch; the next sweep skips scopes still inside the `IDLE_SWEEP_DEDUP_DAYS=14d` window. **Owner snooze (same PR)**: `cmdb_cloud_scopes.idle_snoozed_until` set via `POST /api/v1/cmdb/scopes/{id}/snooze-idle` (default +30d, owner or cloud/IT admin; MCP twin `snooze_cloud_scope_idle_reminder`) excludes the scope until the timestamp passes. **Race-safety (Round-3 logic audit)**: both filters live in the SQL WHERE clause (not Python post-filter), so a concurrent snooze (which takes `with_for_update()` row lock) serialises against the candidate SELECT at the storage layer — closes the snooze-at-08:59:59-cron-fires-at-09:00 collision. Handler additionally wraps the whole sweep in a `distributed_lock("cmdb.scope_idle_sweep", ttl=900)` so a multi-worker deployment / cron-restart can't double-fire (matching the leaver_grant_sweep / manual_revoke_retry / semiannual_confirmation pattern); on `LockBusy` returns `skipped_lock_busy=True` zero-shape summary so the failure-notifier stays quiet. **Stamping correctness (Round-1 BLK fix)**: only scopes whose card actually reached at least one recipient get stamped — admin-dispatch success unions all candidates, owner-only-dispatch success adds only their own scopes. Batch SELECT replaces per-scope `session.get()` to avoid identity-map staleness. UI affordance: 「忽略 30 天」 button + `已忽略至 …` info banner on both `CloudScopeDetailPage` (admin) and `MyCloudScopeDetailPage` (owner self-service). Notification copy rewritten same PR: structured "list + handling options + data caveat" body, owners see only their own scopes (avoids leaking other teams' names), explicit "billing data lag is provider-side, not Helix sync issue" disclaimer in body. **Per-scope governance trace (2026-06-09)**: for each scope it actually notifies, the sweep also writes one `idle_notified` row into `cmdb_cloud_scope_owner_confirmations` so the detail-page 治理 tab shows the owner WAS pinged (previously only a `related_resource_id=NULL` digest + a `last_idle_notified_at` overwrite — no per-scope record). Leaf `responded` shape, not escalated; dedup inherited from `last_idle_notified_at`. Summary counters: `candidates`, `notified_recipients`, `owners_notified`, `admins_notified`, `stamped_scopes`, `timeline_rows`, `errors`, `skipped_lock_busy`. **No auto-decommission.** |
| `cmdb.manual_revoke_retry_sweep` | hourly :15 | **Cloud-side revoke retry (added 2026-05-16, pre-launch 12-pass audit P1).** SELECTs `cmdb_cloud_access_grants` rows where `status='revoked' AND detail.requires_manual_cloud_revoke=true AND revoked_at < now - min_age_seconds (default 3600)`; calls `CloudLifecycleService.retry_manual_cloud_revoke(grant_id)` per row in its own session. On success / `already_absent`: clears the flag (`detail.requires_manual_cloud_revoke=false`, `last_error=null`, `retry_succeeded_at=now`) + writes audit `cloud.grant.revoke_retry status=success`. On failure: keeps the flag, updates `detail.last_error` with the latest reason, writes audit `status=failure`; next hourly tick retries. Closes the leak surfaced by the audit: `_revoke_grant_locked(suppress_driver_error=True)` stamps the Helix row REVOKED but if the cloud-side call had failed (5xx / throttle / IAM lock / propagation delay) the flag would sit advisory with no retriever — leaver's cloud-side permission persisted indefinitely. Each grant runs in its own DB session+commit; per-grant exceptions land in `summary.errors[]` AND the handler raises if `errors` is non-empty so the failure-notifier alerts. Seeded by migration 172. **Discovery query** uses `func.json_extract(detail, "$.requires_manual_cloud_revoke").is_(True)` — MySQL 8 / TiDB syntax matching the writer in `_revoke_grant_locked` ~line 2540. Summary counters: `scanned`, `cleared`, `still_failing`, `escalated_pending`, `errors`. **Retry-storm cap (2026-06-10, execution-plane PR2)**: each failed retry increments `detail.manual_revoke_retry_count`; at `_MANUAL_REVOKE_ESCALATE_AFTER` (=12, ≈half a day of hourly ticks) consecutive failures the grant escalates ONCE — notification to cloud_admin + global_admin (`cloud.grant.manual_revoke_escalated`, deep-link to scope), audit row, `detail.manual_revoke_escalated_at` stamped — and the discovery query skips it from then on (`json_extract($.manual_revoke_escalated_at) IS NULL`). Resume path: `POST /cmdb/grants/{id}/retry-cloud-revoke` (`can_manage_cloud`, prod-write-lock applies) resets counter + stamp and runs one immediate attempt under the `grant_revoke:{id}` lock; the 已结束 tab badges 云侧未回收(·已升级) rows + renders the retry button for privileged viewers (serializer fields `requires_manual_cloud_revoke` / `manual_revoke_last_error` / `manual_revoke_escalated`). The >7d aging compliance incident keeps firing independently (escalated rows still match its query). Caps the May-2026 storm class (463 silent failed attempts / 3 weeks). |
| `cmdb.auto_owner_grant_backfill` | every 10 min | **Backstop for PR #279's auto-Owner grant (added 2026-06-02).** SELECTs active **self-service-provisioned** scopes (`EXISTS cmdb_cloud_scope_requests.created_scope_id = scope.id` — provenance gate added 2026-07-03; sync-imported inventory is out of the repair mandate) with `owner_person_id IS NOT NULL` where the owner neither holds any `source='helix'` ACTIVE grant NOR an ACTIVE **owner-equivalent** grant of ANY source (static providers match `_DEFAULT_OWNER_ROLE_BY_PROVIDER` role_key; AWS matches the `cloud.aws.scope_request_default_owner_role_name` permission-set name via the grant row's `role_display_name` OR the `cmdb_cloud_roles` catalog translation of its ARN `role_key` — discovered SSO grant rows carry `role_display_name=NULL` in prod, so the catalog path is the one that matches them; added 2026-07-03) on that scope; for each candidate (bounded by `batch_cap`, default 20, oldest-first) re-invokes `_post_provision_finalize(scope_id, is_backfill=True)` — same flow `approve_scope_request` / `manual_provision_scope_request` run **inline** with `is_backfill=False` (no longer `background.add_task`) so the real-approve UX is unchanged. Candidates also exclude scopes whose owner is a leaver (`employment_status IN ('left', 'leaver_pending')`) since `provision_initial_owner_grant`'s leaver gate would silently skip them every tick and burn `batch_cap` slots forever — orphaned leaver-owned scopes need `assign_scope_owner` from an admin. The 2026-06-02 investigation of scope `dev-offline-afterworld` (b521cafb-…) proved the original FastAPI `BackgroundTasks` wiring silently lost **every** firing in prod uvicorn since PR #279 launched (2026-05-13, ~20-day silent-loss window): `audit_logs WHERE action='cloud.scope_request.auto_owner_grant'` returned zero rows, and every AWS scope sat with 0 owner grant (even when admin had bound the AWS-side IAM out-of-band). Inline-await in the approve handlers (same PR as this cron) removes the silent-loss class; this cron catches anything the inline path itself surfaces as `LockBusy` / Identity Center transient / driver exception. `provision_initial_owner_grant` is idempotent (ConflictError → no-op) so re-runs on already-fixed scopes are safe. **`is_backfill=True` contract (migration 241)**: cron path runs `silent=True` (no `[已开通] X 已生效` member notification — the owner already has cloud-side access from before Helix had this code path, so the card would misleadingly suggest "we just opened access for you") AND `force_perpetual=True` (`expires_at = NULL`; `sweep_expiring_grants` filters on `expires_at IS NOT NULL` so the renewal-nag flow skips these rows naturally; same channel cloud_admin perpetual grants already use). Rows the cron writes also carry `grant.detail.backfill_origin = True` so admin tooling / future audit queries can distinguish bookkeeping rows from real-approve rows. Counter-example: the first cron tick on 2026-06-02 08:50 UTC dispatched 14 `[已开通]` cards + wrote 14 grants with a 90-day TTL before the cron was manually `enabled=0`'d at 09:21 UTC; migration 241 re-enables it with the silent-perpetual gate now in place. Summary counters: `scanned`, `fixed`, `unresolvable`, `errors[]` (capped at 10 for audit size), `batch_cap`. Seeded by migration 239; re-enabled with silent path by migration 241. **Unresolvable-owner-role backoff (migration 244, 2026-06-02)**: AWS resolves the owner permission-set at runtime against Identity Center (`_resolve_default_owner_role` matches by configured name). When that match DETERMINISTICALLY fails (perm-set not in the catalog — distinct from a transient driver crash / `LockBusy`), no `source='helix'` grant ever lands, so `~sub_helix_owner_grant` kept the scope a candidate FOREVER, re-running the paid `list_scope_roles` call + writing a `status=skipped` audit row on every 10-min tick (SQL can't pre-filter — resolution is runtime). The helper now raises `_OwnerRoleUnresolvable`, `_post_provision_finalize` stamps `cmdb_cloud_scopes.auto_owner_grant_unresolvable_at` (via `_mark_owner_role_unresolvable`, which de-noises the skipped-audit to at-most-once per retry window), and the candidate predicate skips scopes stamped within `unresolvable_retry_hours` (default 6h). After the window the scope re-qualifies so an admin who fixed the perm-set name (in Identity Center OR via the `cloud.aws.scope_request_default_owner_role_name` platform setting) self-heals automatically; a successful resolve+dispatch (`provision_initial_owner_grant`) or a manual `assign_scope_owner` clears the marker outright. (Re-)marked scopes are surfaced ONCE per window through the same `raise → failure-notifier` channel (keyed by scope_id UUID so `compute_failure_signature` collapses them to one stable signature → dedup-ack suppresses re-spam). **Candidate-predicate tightening (2026-07-03)**: prod sat at `scanned=20/fixed=0` every tick — 98 sync-imported scopes (owners already held cloud-side IAM, mirrored as `source='discovered'` ACTIVE rows) matched the old predicate; `provision_initial_owner_grant` ConflictError'd on the exact (scope, person, role) triple (the uniqueness check ignores `source`), the finalize path swallowed it as an idempotent no-op with NO audit row and NO backoff marker, and `ORDER BY created_at ASC` deterministically pinned the oldest 20 into every batch — starving all newer genuine candidates forever. Fixed by the provenance gate + any-source owner-equivalent exclusion above; the ConflictError branch in `_post_provision_finalize` now also writes a `status=skipped` audit row so any residual recurrence is greppable. Deliberately NOT done: auto-escalating the ~53 imported scopes whose owners hold only partial roles (includes `pingcap-security` and AWS management accounts) — those go through `assign_scope_owner` / owner-add manually. |
| `cmdb.leaver_grant_sweep` | 01:30 daily | **Safety net for offboarding cloud-grant cleanup (added 2026-05-14).** SELECTs every `cmdb_cloud_access_grants` row where the holder's `identity_persons.employment_status='left'` AND the grant status is `active` / `pending` AND the holder is not soft-deleted; calls `CloudLifecycleService.revoke_grant(reason=offboarding, suppress_driver_error=True, actor_role_names=[GLOBAL_ADMIN_ROLE])` on each. **No `recent_days` cutoff** (cf. `hr.post_leaver_verify`): the residuals this sweep targets are exactly the ones the recent-only verify can't reach — historical leavers who departed before the T+0 cloud-grant auto-revoke in `hr_lifecycle.advance_service` existed (pre-PR #214 / pre-Round-21 RBAC fix), and drift grants reflected into Helix AFTER the person already transitioned to `left`. The primary path remains `hr.process_advance` (per-blocker, every 15 min); this sweep is the daily catch-up. `suppress_driver_error=True` keeps the Helix-side invariant intact (a leaver MUST NOT hold an active grant in our DB) — cloud-side failures flip `requires_manual_cloud_revoke=True` on the row so the drift / orphan-principal sweeps retry the provider call. Each grant runs in its own session+commit so one driver hang doesn't hold the whole sweep open. Scheduled at 01:30 Asia/Shanghai, half an hour BEFORE `hr.post_leaver_verify` at 02:00, so the residual alert pass sees a clean cloud-grant state. Summary counters: `scanned`, `revoked`, `cloud_side_failed`, `errors`. Seeded by migration 167. |
| `cmdb.billing_sync` | 02:00 on 2nd of month | monthly aggregate + budget threshold alert. **AWS `billing_source=bigquery` short-circuit (2026-05-15)**: when `provider=='aws'` AND `config_public.billing_source=='bigquery'`, the handler SUMs `cmdb_cloud_billing_daily` rows for the target month (via [`_aggregate_monthly_billing_from_daily`](../../server/app/scheduled_jobs/handlers.py)) instead of calling `provider.get_monthly_billing` → Cost Explorer. Same upstream feed (CUR-in-BQ via `cmdb.billing_daily_sync`), zero extra BQ scan, zero CE-permission dependency. PingCAP prod aws-primary intentionally lacks `ce:GetCostAndUsage` so the old path 100%-failed with `AccessDeniedException` for months while `scheduled_job_runs.status='ok'` silently swallowed it (handler used to `return {"status":"failed",...}` instead of raising — fixed in the same PR; every failure path now `raise RuntimeError(...)` so the failure-notifier actually fires). Other providers + AWS-on-Cost-Explorer use [`_fetch_monthly_billing_via_provider`](../../server/app/scheduled_jobs/handlers.py) unchanged. Pinned by [test_billing_sync_handler.py](../../server/tests/test_scheduled_jobs/test_billing_sync_handler.py) (6 tests including AWS-BQ aggregation correctness, AWS-CE fall-through, raise-on-provider-failure, raise-on-missing-method, disabled-integration skip, zero-daily-rows honest-zero write). |
| `apikey.budget_check` | 09:M Asia/Shanghai daily (M hashed from key) | sums per-AK product-attributable cost via [`compute_assignment_spend`](../../server/app/domains/apikey/spend.py) (PR #251) — Foundry by AK's RG, Bedrock by AK's IAM-user principal tag, Vertex by AK's GCP project scope, Bailian skipped honestly because BSS exposes only account-level. Compares to `apikey_assignments.monthly_budget_usd`. Fires 80% / 100% threshold alerts via `NotificationService` (in-app + Feishu card, template `apikey.budget_alert`) to BOTH the applicant and their direct manager. When the bucket cannot resolve a per-AK number (`data_available=False`), the cron records `bucket-data-not-available` in the run summary (with the structured caveat for compliance audit) and dispatches nothing — no dedup row, no audit row, no notification. This is the production-grade posture: never fire an alert on data that isn't honestly attributable. **Idempotency**: `apikey_budget_alerts_dedup` UNIQUE(assignment_id, billing_month, threshold_pct) — each threshold lands at most once per month per assignment. The 100% pass dispatches once; the 80% pass writes its dedup row but skips re-dispatch when the higher threshold already fired (so a refund-driven dip back below 100% doesn't re-fire 80%). **Sanitisation**: every user-controlled value (`project_name`, `display_name`, `work_email`) passes through [`safe_envelope_text`](../../server/app/notifications/text_safety.py) before splicing into the body / subject / context — strips CR/LF (defends against fake "— Helix IT 平台" signature spoofing) and `[r]/[/r]/[b]/[/b]` markup markers (defends against attacker-styled red+bold "platform-emphasis" payloads). **Dedup contract (updated 2026-05-14)**: `_try_claim_dedup_slot` atomically INSERT-or-fail the dedup row BEFORE dispatching — the old SELECT-then-INSERT (check-then-act) had a TOCTOU race where two concurrent runs could both dispatch before either's INSERT committed. Claim-first makes dedup atomic: IntegrityError → `False` (skip dispatch), clean INSERT → `True` (proceed). |
| `cmdb.domain_expiry_alert` | daily | scans `cmdb_domains` for expiry ≤30 days; Feishu webhook |
| `sso.execute_pending_google_grants` | every 5 min | finds `sso_grants` rows where `status='approved'` ∧ `sso_provider='Google'` ∧ `created_app_id IS NULL`. Parses the SAML metadata (XML wins; falls back to discrete acs_url + entity_id + x509_cert), inserts a local `sso_apps` row tagged `helix_created`, back-fills `created_app_id`, and sends `sso.app_create_pending` to every `global_admin` so IT mirrors the SAML app in Google admin manually (Helix never calls Google APIs for the SAML catalog — see migration 127 + memory `feedback_verify_third_party_api_direction.md`). On notification failure the catalog write still commits (otherwise the cron would retry forever). Catalog dedup on `display_name` — if an app with the same name was already added (manual or prior grant), links the grant to it instead of duplicating. |

### 13.4 Lifecycle service ([`server/app/domains/cmdb/lifecycle_service.py`](../../server/app/domains/cmdb/lifecycle_service.py))

Centralizes:
- `grant_access(scope, person, role)` → inserts grant + calls `driver.grant_access()`. Sets `expires_at = now + scope.default_grant_ttl_days`. **cloud_admin** holders get `expires_at = NULL`.
- `revoke_grant(grant, reason)` → calls `driver.revoke_access()`, sets status `revoked` + `revoked_at/by/reason`.
- `renew_grant(grant)` → bumps `expires_at = now + cloud.grant_renewal_days` (NOT old_expires + delta — resets the clock).
- `preflight_decommission_scope(scope_id)` → `(scope, integration, ClosePreflight)`. Read-only, never raises on a provider problem (an unreachable provider is `checked=False`). Serves `GET /cmdb/scopes/{id}/decommission-preflight`, gated by `can_manage_cloud_scope_lifecycle` — the same gate as the button it serves, because the blocker list is a partial inventory of a cloud account. **Not a gate on the decommission**: a preflight that fails must never be able to block a takedown.
- `decommission_scope(scope, reason, by_user)` → calls `driver.close_scope_on_provider()`. A driver `error_code='deletion_precheck_running'` (Aliyun's 3–5 minute deletion pre-check) raises `SCOPE_CLOSE_PRECHECK_RUNNING` / 409 rather than the generic 502 — it is progress, not a provider rejection, and the driver resumes on the next click. On success: `status='closed'`, `decommissioned_*` filled, all active grants → `revoked` (`reason='offboarding'`). **With `cleanup_blockers=True` + `confirmed_blocker_keys` (2026-09-07)**, a `resources_existing` / `liens_exist` refusal no longer raises: `_begin_blocker_cleanup` verifies the live blocker set against the confirmed one, deletes the intersection, stamps the in-flight columns (migration 624), audits `cloud.scope.decommission_cleanup_started`, and **returns normally** with `CloseScopeResult(ok=False, error_code='cleanup_in_progress')` — an exception here would roll back the very pending state `cmdb.decommission_cleanup_resume` reads. It still RAISES for the two cases where the decision is the admin's and they are still at the keyboard: 409 `SCOPE_CLOSE_BLOCKERS_CHANGED` (a live blocker nobody confirmed — deleting it anyway is the behaviour automatic cleanup must never have) and 502 `SCOPE_CLOSE_CLEANUP_FAILED` / `SCOPE_CLOSE_BLOCKER_NOT_REMOVABLE`. Without the opt-in the pre-2026-09-07 502 behaviour is unchanged — automatic deletion must never be something a caller gets by omission. **Irreversible.** The audit log emits one of three action names so log readers can distinguish operationally distinct cases without losing the trail: `cloud.scope.decommission` (admin clicked + driver actually cancelled at the provider), `cloud.scope.decommission_observed_closed` (admin clicked but the provider reported the scope was *already* closed — e.g. Azure `SubscriptionNotInRegisteredState` after an out-of-band portal cancel; AWS `SUSPENDED` from a prior `CloseAccount`), `cloud.scope.decommission_idempotent_resync` (Helix scope was already `status='closed'`, pure resync). All three carry `already_closed` + `provider_state` in detail. Added 2026-05-26 (Round-52 logic-review audit attribution).
- `sweep_active_grants_on_left_persons()` (module-level coroutine) → daily safety-net sweep. SELECTs every grant on a `left` person whose status is still `active` / `pending`, revokes each in its own session+commit with `suppress_driver_error=True`. Powers the `cmdb.leaver_grant_sweep` cron (see §14 above). Replaces the dead `revoke_all_grants_for_leaver` helper that lived here pre-2026-05-14 — the predecessor was never imported by any caller and its docstring's "called from advance_service" claim was a lie.
- `sweep_pending_manual_cloud_revokes(*, min_age_seconds=3600)` (module-level coroutine) → hourly retry-sweep for grants whose Helix-side `_revoke_grant_locked(suppress_driver_error=True)` left the cloud side dirty. SELECTs rows with `status='revoked' AND detail.requires_manual_cloud_revoke=true AND revoked_at < now - min_age_seconds`, calls `CloudLifecycleService.retry_manual_cloud_revoke(grant_id)` per row in an isolated session. Powers `cmdb.manual_revoke_retry_sweep` (see §14 above). Added 2026-05-16 pre-launch audit (CR4 P1).
- `CloudLifecycleService.retry_manual_cloud_revoke(grant_id, *, trace_id)` (instance method) → re-invokes `driver.revoke_access` for a single grant whose flag is set. Returns `True` when the cloud now reports the principal absent (success / already_absent — clears `requires_manual_cloud_revoke` + writes audit `cloud.grant.revoke_retry status=success`); returns `False` when the driver still fails (keeps flag, updates `last_error`, writes audit failure). Refuses to run on grants not in the expected post-revoke state so a misfiled grant_id can't reopen a healthy grant. Loads the driver inside the method (same `_load_driver_for_scope` pattern as the primary revoke path), closes it in `finally`.

**Scope / role sync guards (added 2026-05-16, pre-launch 12-pass audit P0; tolerance hoisted to the same runtime knob as `_safe_sweep` on 2026-05-19)**: `sync_scopes_for_integration` and `sync_roles_for_integration` mirror the `_safe_sweep` shape from [`sync.py`](../../server/app/domains/cmdb/sync.py) inline before the close / soft-delete UPDATE — Helix-active baseline (`status='active'` for scopes, `deleted_at IS NULL` for roles), tolerance resolved via `get_sweep_drop_tolerance_percent(session)` in [`sweep_settings.py`](../../server/app/domains/cmdb/sweep_settings.py) (same `platform_settings['cmdb_sync.sweep_drop_tolerance_percent']` key as the resource sweep — single source of truth for the floor across all three sweep paths). Refuses the sweep on `upserted == 0 AND baseline > 0` (driver outage returned `[]`) OR `upserted < baseline × (1 - tolerance/100)` (partial fetch). The three paths use one exact integer cross-multiplication helper rather than truncating an integer floor, so small catalogs obey the configured percentage too (default 20%: 2→1 and 6→4 both refuse; an exact 5→4 proceeds). Providers must also fail before returning any incomplete page: Tencent CAM role catalog, per-user grant discovery, policy-id lookup, direct/group revoke inspection and account-revoke policy cleanup share an authoritative collector that requires stable non-negative `TotalNum`, exact final unique-row cardinality, non-empty stable PolicyName, full non-final pages, no overlap, bounded pages, and no provider error; prefixes never reach role/grant sweep and incomplete revoke reads never become `already_absent`. Tencent scope identity is `GetUserAppId.OwnerUin` (the tenant owner), never the current credential sub-user `Uin`; missing owner identity fails closed so service-account rotation cannot fork a ghost tenant. All Tencent admin-visible/logged errors pass through the shared provider redactor, including named SecretId/SecretKey fields. On refusal the function `raise RuntimeError(...)` instead of returning silently — the matching `run_scopes_sync` / `run_roles_sync` handler now also `raise` on `result.status='failed'` OR non-empty `errors[]` so `scheduled_job_runs.status` lands `failed` and `failure_notifier` actually pages. Before this layer, a single Feishu-side / cloud-API blip would close every `active` scope in the tenant (billing UI / self-service grant flow blank) and soft-delete every role catalog row (request modal dropdown empty across the org), while the cron history quietly showed green.

**Roles force-sweep override (added 2026-05-29 after PR #565 prod incident)**: `sync_roles_for_integration(force_sweep=True)` bypasses BOTH the every-scope-failed and drop-ratio guards above. Driven exclusively by the admin endpoint `POST /api/v1/cmdb/integrations/{id}/roles-sync/force-sweep` (`global_admin` via `can_manage_cloud`); cron path never sets `force_sweep=True`. Result dict gains `forced_floor_bypass: {trip: 'every_scope_failed' | 'drop_ratio', baseline, upserted, tolerance_pct?, floor?, errors?}` recording exactly which guard tripped and by how much, so the endpoint can mirror this into the `cmdb.roles_sync.force_sweep_override` audit row and the UI `done` modal. Shares the `cloud_sync:<integration>` distributed lock with every other sync path. Mirrors the long-standing `cmdb.sync.force_sweep_override` (resource sweep) and `cloud.grant.drift_sweep_force_override` (grants sweep) endpoints — three sibling escape valves with consistent shape (synchronous when bounded like roles + drift; async-with-poll when minutes-scale like resource fan-out). Incident motivating this: PR #565 added `AliyunReserved*` filtering to `AliyunCloudDriver.list_scope_roles`, dropping the role catalog from 10 674 (mostly `AliyunReservedSSO-*` mirror policies that `AttachPolicyToUser` 400's on) to 288 real CloudSSO permission sets — guard correctly refused, but ack only silenced the alert and the next cron tick re-failed identically.

### 13.5 Scope request service ([`server/app/domains/cmdb/scope_request_service.py`](../../server/app/domains/cmdb/scope_request_service.py))

Handles `cmdb_cloud_scope_requests` lifecycle. Routes via Feishu (`integrations.approval_flows.cloud_scope_request`). On approval, attempts `driver.create_scope()` — **AWS Orgs** (`create_account`) and **Aliyun / AliCloud-海外** (Resource Directory `CreateResourceAccount`) support this. The Aliyun path's `rd_parent_folder_id` is OPTIONAL: when blank the driver resolves the RD **Root** folder id via `GetResourceDirectory` and files the account there (fixed 2026-06-03 — previously a hard-`raise` on a blank folder id permanently blocked every Aliyun sub-account request even though Aliyun never required the parameter; prod-verified neither Aliyun integration had it configured, both failed requests routed at `alicloud-intl-primary` the RD master). Resolved explicitly rather than relying on Aliyun's undocumented omit-ParentFolderId default; member-account creds (no `RootFolderId`) fail loud. `AccountNamePrefix` (seeds the login email) is the **alphanumeric-only, Alibaba-brand-token-stripped** reduction of the request name capped at 37 chars — the live 2020-03-31 endpoint rejects a prefix containing `aliyun` (and siblings `alibaba`/`alipay`/`hichina`) with `IllegalParameter.Account.AccountName`, proven in prod 2026-06-03 (`devofflineafterworldaliyun` refused, `devofflineafterworld` succeeded → AccountId 5660180459278784). DisplayName keeps the brand word + hyphens. **结算账号** (`PayerAccountId`, added 2026-08-07): omitting it makes the new member **self-paying** (Aliyun's default) — which is what Helix did until 2026-08-07, leaving those accounts outside the master's consolidated bill the cost sync reads. The driver now sends the RD **管理账号** (`MasterAccountId` from the same cached `GetResourceDirectory` call) unless `rd_payer_account_id` overrides it (member AccountId → that member pays; literal `self` → deliberate 自主付款); an unresolvable master fails the request instead of silently self-paying. Azure / GCP / Tencent / UCloud require manual provision (`POST /scope-requests/{id}/manual-provision` with a JSON body `{"external_id": …, "scope_type": …}` — the 「回填云端 ID」 dialog; it is a body, not a query param). **Auto-owner-grant**: after a successful create, `_post_provision_finalize` grants the requester an owner-equivalent cloud role via `_DEFAULT_OWNER_ROLE_BY_PROVIDER` — `gcp`→`roles/owner`, `aws`→runtime-resolved Identity Center permission set, **`aliyun`/`alicloud_intl`→CloudSSO `admin` access configuration** (added 2026-06-03; previously absent, so Aliyun requesters landed as Helix-side owner with zero cloud IAM). **Azure** (`/providers/Microsoft.Authorization/roleDefinitions/8e3af657-…` = built-in Owner, ARM `roleAssignments` PUT), **Tencent** (`AdministratorAccess` CAM policy name → `AttachUserPolicy`) and **UCloud** (`AdminPolicy`) were added 2026-08-17 — they have no `create_scope`, so every sub-account arrives via `manual-provision`, and the map's old header ("providers slot in here when their drivers grow `create_scope` support") left them out: `auto_owner_grant` audit-logged `status=skipped reason="no default owner role resolved"` and the requester owned a subscription with zero cloud IAM (prod `azure-ai-prod` scope `7845b0f7`, plus `e7babe7b` / `ae0486fc` / tencent `7f4642bb`). A provider missing from the map = no auto-grant (the requester self-adds later); `tests/test_cmdb/test_auto_owner_role_map_coverage_2026_08_17.py` fails CI when a driver declaring `owner_role_keys` has no entry, with `ksyun` the one documented exemption (no `owner_role_keys`, no `grant_access`).

**AWS-specific provisioning contract** (current 2026-05-25):

1. **Owner-alias auto-derive + GWS add_alias** — `_ensure_aws_owner_alias` calls `derive_aws_owner_alias(req.name)` to slug into `<slug>@pingcap.com` (**slug verbatim, no forced `aws-` prefix**; over Google's 64-char cap or all-non-ASCII names raise `ValueError` so the admin renames rather than silently colliding on a placeholder). Looks up the primary enabled `software_provisioning / google_workspace` integration, calls `GoogleWorkspaceDriver.add_alias(target_kind="user")` (409 ALREADY_EXISTS treated as success), persists to `metadata_json.owner_alias_email`. The alias becomes `owner_email` for AWS Organizations CreateAccount — fresh globally-unique address, mail still reaches the requester. No GWS integration / GWS 4xx → `failure_reason` populated, request lands in `failed` for admin retry. No silent fallback to `requester.work_email` because that would hit AWS EMAIL_ALREADY_EXISTS far from the real root cause.
2. **AWS CreateAccount idempotency** — AWS Organizations CreateAccount does NOT accept `ClientRequestToken` (the parameter is rejected client-side by botocore; corrected from prior assumption on 2026-05-25). Redrive safety comes from `_find_existing_create_account_request` — before issuing `create_account`, the driver sweeps `list_create_account_status(States=[IN_PROGRESS,SUCCEEDED])` for an entry whose AccountName matches; if found, the driver resumes polling that RequestId rather than reissuing. The 30-day retention on CreateAccountStatus bounds the lookup window naturally.
3. **Auto-Owner grant on AWS** — `_post_provision_finalize` invokes `_resolve_default_owner_role("aws", ...)` which reads platform_setting `cloud.aws.scope_request_default_owner_role_name` (default `DBaaS-DevLeader-Role`), lists Identity Center permission sets via `AwsCloudDriver.list_scope_roles`, matches by display name, and returns the resolved ARN. The ARN is then passed to `provision_initial_owner_grant`, which binds via `sso-admin.create_account_assignment` in `_grant_sso_sync`. Name not found → audit-logged skip, the requester can self-bind via owner-add.

### 13.6 Billing alerts ([`billing_alerts.py`](../../server/app/domains/cmdb/billing_alerts.py))

`maybe_send_budget_alert` runs after monthly billing fetched. Threshold lookup: `platform_settings.billing.budget_usd.{slug}` (per-account) or `billing.budget_usd.default` (org-wide). Daily spike: `(yesterday_cost / avg_7d_cost - 1) > cloud.daily_spike_multiplier` (default 2.0).

### 13.7 Routes

- `GET/POST /api/v1/cmdb/cloud-scopes` + `/{scope_id}` (CRUD + assign owner)
- `GET/POST /api/v1/cmdb/cloud-scopes/{scope_id}/grants` (list, owner direct-add)
- `POST /api/v1/cmdb/cloud-scopes/{scope_id}/decommission`
- `GET/POST /api/v1/cmdb/scope-requests` + admin approve/reject + manual-provision
- `GET /api/v1/cmdb/cloud-roles` (catalog)
- `GET /api/v1/cmdb/billing/monthly|daily`
- `GET /api/v1/cmdb/{servers,domains,dns,lb,cdn,object-storage,iam-users}`
- `POST /api/v1/cmdb/sync` — manual trigger (admin)
- `POST /api/v1/cmdb/integrations/{id}/roles-sync/force-sweep` — admin escape valve when the roles_sync drop-tolerance guard refuses; see §13.4 force-sweep override note.

**DNS zone picker UNION (added 2026-05-29)** — `GET /api/v1/cmdb/dns-zones` ([`CmdbService.list_dns_zones`](../../server/app/domains/cmdb/service.py)) returns the UNION of `SELECT DISTINCT zone_name FROM cmdb_dns_records` (legacy: zones with records) and `SELECT domain_name FROM cmdb_domains` (admin-curated, every non-deleted domain regardless of `allow_dns_request`). The previous records-only query hid newly-registered domains that had ZERO records yet — admin couldn't add the first record because the picker was empty (prod 2026-05-29: `tiup.io`). The employee path `/api/v1/cmdb/domains/selectable-for-dns` is unchanged and still gated on `allow_dns_request=True`, so admin remains a strict superset of employee. Both sources scoped to `_active_cloud_integration_ids` when no `integration_id` filter is pinned.

**DNS record list ordering (added 2026-05-29)** — `GET /api/v1/cmdb/dns-records` orders by `(first_seen_at DESC, zone_name, name)` so brand-new records float to the top. `first_seen_at` is `server_default=func.now()` on insert and the cron's ON DUPLICATE KEY UPDATE deliberately doesn't touch it — stable "when did this row first appear" anchor.

**DNS self-service hostname regex (updated 2026-05-29)** — [`_SUBDOMAIN_RE`](../../server/app/domains/cmdb/dns_request_service.py) accepts underscore-led labels at every position (RFC 8552 + universal cloud DNS practice — Aliyun, AWS Route 53, Cloudflare, GCP Cloud DNS all accept them; the strict letter-first form was rejecting `_dnsauth` and similar before the per-label denylist could even run). Reserved labels (`_dmarc` / `_acme-challenge` / `_dkim` / `_domainkey` / `_spf` / `_mta-sts`) remain admin-only via the denylist in `_validate_subdomain` — now actually exercised because the regex no longer pre-empts it. Hyphen still rejected at first/last position of any label. Wildcard `*` still rejected for self-service (admin direct-write `POST /dns-records` bypasses the validator).

**DNS self-service record types (updated 2026-06-09)** — `DNS_REQ_SELF_SERVICE_TYPES` (models.py) = `{A, AAAA, CNAME, TXT, NS}`. NS joined the whitelist 2026-06-09 (subdomain delegation to external nameservers; value validated as an FQDN like CNAME by both `_validate_dns_value` in schemas.py and `_validate_value_for_type` in dns_request_service.py). MX / SRV / CAA stay admin-direct-write only (mis-edits cascade across email delivery / service discovery / CA issuance authority). The literal `DnsRequestRecordType` in schemas.py and the frontend `DnsRecordTypeOption` in [`DnsRequestFormModal.tsx`](../../web/src/pages/cloud/DnsRequestFormModal.tsx) must stay in sync with this set. See DECISIONS.md for the NS rationale.

**Self-service owner transfer (added 2026-06-02)** — `POST /api/v1/cmdb/dns-records/batch-owner-transfer` + `POST /api/v1/cmdb/servers/batch-owner-transfer`. Distinct from the legacy admin-only `/dns-records/batch-owner` (and `/servers/batch-annotate`) endpoints: the caller MUST own every record in the batch (admin gets `can_manage_cloud` bypass), recipient MUST be `employment_status='active'`, no approval (handing off rows you already own does not escalate privilege). New owner gets a Feishu + email notification via `NotificationService.dispatch` → `notification_outbox` (failure does NOT block the transfer commit). DNS path clears `last_owner_review_at` + `last_recertify_nag_at` on transfer so the new owner enters the next `cmdb.dns_owner_recertify` cron pass (mirrors `update_dns_record_in_db` Round-30 dedup-clear). New `CHANGE_REASON_SELF_SERVICE_TRANSFER = "self_service_transfer"` in [`owner_history.py`](../../server/app/domains/cmdb/owner_history.py) separates employee-initiated handoffs from admin batch fixups in the audit timeline. Frontend: shared [`OwnerTransferModal`](../../web/src/pages/cloud/OwnerTransferModal.tsx) used by `MyCloudDnsPage` + `MyCloudServersPage` — Antd Table `rowSelection` with `getCheckboxProps` disabling non-owned rows, UserSelect for the picker, optional handoff note included in the notification.

**DNS recertify button gating (added 2026-06-02)** — `DnsRecordResponse` exposes `last_owner_review_at` (column existed since migration 177, just wasn't on the FE schema). `MyCloudDnsPage::computeRecertifyState` derives `daysUntilDue = 90 - ageDays`; the "我还在用" button + a 警告 Tag («保鲜确认 剩 N 天» / «保鲜已逾期») render ONLY when `daysUntilDue ≤ 14` or `last_owner_review_at IS NULL`. Earlier behaviour rendered the button on every owned row regardless of staleness — the user-feedback called it "permanent visual noise". The 90-day cron threshold and 30-day nag dedup window continue to come from `platform_settings.cmdb.dns.recertify_period_days` / `cmdb.dns.recertify_nag_dedup_days` (unchanged) — the UI threshold is intentionally a hard-coded 14-day mirror to keep `/my/cloud/dns` self-contained.
- `GET /api/v1/cmdb/overview/admin` — multi-tab fleet workspace payload (subscriptions distribution, grants by status / expiring / recovered, IAM users, governance alerts). Admin-only (`can_manage_cloud`); backs `/admin/cloud/overview`. Implementation in [`admin_overview_service.py`](../../server/app/domains/cmdb/admin_overview_service.py).

`cloud_lifecycle_router` adds `/api/v1/cloud-lifecycle/*` for grants, renewal, etc.

### 13.8 Admin Cloud Overview workspace (added 2026-05-08)

`/admin/cloud/overview` is a multi-tab dashboard styled after the operational-insights workspace. Replaces the prior single-scroll page that gave admins no visibility into "how many subscriptions / how many active grants / how many recovered permissions" — the user-facing top question after PR #200 shipped orphan-principal cleanup.

Tabs: 概览 / 主机 / 网络 / 子帐号 / 帐号与权限 / 身份 / 报表. Two backing endpoints — `/cmdb/analytics` (legacy resource counts) + `/cmdb/overview/admin` (subscriptions, grants, recovered history). Tenant filter scopes every tab. Gated by `RoleRoute roles={['global_admin']}` on the route + `can_manage_cloud` on the new endpoint.

Headline metrics: active subscriptions / closed scopes / unowned scopes / owner-left-org count / active grants / distinct holders / recovered last-30d (incl. orphan-swept breakdown) / pending requests / prod-overdue grants. Recovered tab carries a 30-row history table that handles orphan rows (`person_id IS NULL`) with a special tag and falls back to `external_principal_email` for attribution.

---

## 14. Frontend architecture

### 14.0 系统手册 (System Handbook) — in-product, diagram-first reference {#140-system-handbook}

All-staff, no-role-gate reference at `/handbook` ([`web/src/pages/handbook/`](../../web/src/pages/handbook/)) explaining how Helix is **designed, wired and operated** — big (cloud connections & permissions) to small (how a single toggle is flipped). Complements `/help` (which answers "how do I *do* X"): the handbook answers "how is X *built / run*", system-wide, diagram-first. Shipped as an 8-PR series (2026-07-02, PR1 = foundation + overview + platform-base doc); later PRs fill one domain each.

- **Content pipeline (new abstraction, prefer over the /help pattern for large zh docs)**: one Chinese Markdown file per doc under [`web/src/content/handbook/*.md`](../../web/src/content/handbook/), lazy-loaded via `import.meta.glob(..., { query: '?raw' })` (one chunk per doc — NOT one giant .tsx like `HelpCenterPage`). Registry + ToC in [`docRegistry.ts`](../../web/src/pages/handbook/docRegistry.ts). Rendered by [`MarkdownDoc.tsx`](../../web/src/pages/handbook/MarkdownDoc.tsx) (react-markdown + remark-gfm; strips HTML-comment frontmatter). Two fenced conventions: ` ```mermaid ` → [`MermaidDiagram`](../../web/src/components/MermaidDiagram.tsx) (extended with a `theme` prop → dark-aware, additive/non-breaking); ` ```live-<kind> ` → [`LiveTable`](../../web/src/pages/handbook/LiveTable.tsx). **`.md` is outside the i18n ESLint glob** → Chinese-only content is legal and pays no zh/en/ja tax; the handbook *chrome* (nav/search/pager/table headers/states) still goes through `t()` in all three locales.
- **Live tables (anti-rot)**: read-only, all-staff (`get_current_user`, non-sensitive: structural facts only — no credentials, no run history, no PII) backend domain [`app/domains/handbook`](../../server/app/domains/handbook) → `GET /api/v1/handbook/{scheduled-jobs,connectors,rbac-roles}`. `scheduled-jobs` = the code registry (`list_handlers()`) ∪ `scheduled_jobs` DB rows (handlers with no DB row show as 未调度), so the table is the full catalogue and never a stale hand-maintained snapshot. `connectors` = driver registry metadata + `integrations` enable-state. `rbac-roles` = the 9 canonical roles + manager-derived scope. Read-only doc-support endpoints (not a self-service flow → no MCP tool).
- **Nav** (2026-07-02, updated): reached via the header **docs dropdown** — the `?` icon in `MainLayout` header-right is an AntD `Dropdown` with 用户手册 (`/help`) / 系统手册 (`/handbook`) / 对外 API 文档 (`/api-docs`); the former standalone `/api-docs` header icon folded in. Handbook is NOT a left-sidebar menu item (removed from `menuItems` per user feedback — a top-level entry felt too heavy for a docs surface). Its own left ToC is a sticky column; switching chapters scrolls the app content region (not the window) to the ToC top via `portalRef.scrollIntoView`, so the sidebar stays fixed and the hero doesn't re-appear each click (visited chapters are cached in state → no skeleton flash).
- **Shipped chapters** (grow PR by PR): `start`(overview), `foundation`(platform base); `identity`(HRIS 同步+字段映射 / 出站同步中心); `lifecycle`(入职 / 离职+Leaver Blocked / 假期); `accounts`(账号生命周期管家 / 连接器全景); `software`(软件与坐席 / API Key 与 AI 花费); `cloud`(云资源与访问模型 / 云访问生命周期 / 网络资产 / 云成本口径 / 数据仓库 DAX); `governance`(开通编排 / 审批统一流 / 安全审计与合规 GRC / 财务对账中心); `employee`(员工体验与自助 / 求助与 AI 助手 / 移动端 H5); `examples`(执行范例集:SOC2 加邮件组等). **全部 10 组、24 篇内容已落齐(2026-07-02)**;2026-08-18 追加 `start`/`AI 大脑`(八域权重与打分口径 / `_ROLE_DIMENSIONS` 裁剪表 / 每日 snapshot→analysis→高危通知链路 / 建议状态机与只有 `global_admin` 能动的边界),共 25 篇。**文件名的 `NN-` 前缀是落地顺序,不是 ToC 顺序** —— 顺序由 `docRegistry.ALL_DOCS` 的数组次序决定。
- **mermaid authoring gotchas** (bit us, codified): with `htmlLabels:false` no `<br/>` in labels; `;` inside a sequence `Note` acts as a statement separator; parentheses/slashes in state/sequence labels break the parser; a participant id colliding with a reserved keyword (e.g. `OFF` ↔ `off`) fails — use safe ids like `PROC`.

### 14.1 Stack & conventions

- React 19 + TypeScript 5.9 + Vite 7 + Ant Design 6 desktop + antd-mobile 5 mobile.
- Routing: `react-router` v7, lazy chunks via [`web/src/lib/lazyWithReload.ts`](../../web/src/lib/lazyWithReload.ts) — auto-reloads on stale chunk 404 (prevents user pain after deploy), `sessionStorage` flag prevents reload loops.
- State: Zustand (auth store at [`web/src/stores/auth.ts`](../../web/src/stores/auth.ts)).
- HTTP: Axios with interceptors. 401 → single in-flight refresh, queues concurrent 401s to retry with new token.
- i18n: [`web/src/i18n/index.ts`](../../web/src/i18n/index.ts) wires per-language bundles for zh-CN, en-US, and ja-JP. ESLint regex blocks new hardcoded `[一-鿿]` strings in JSX/props.
- **BE↔FE enum-label contract (2026-07-30)** — [`contracts/i18n-enum-labels.json`](../../contracts/i18n-enum-labels.json), enforced by a **pair** of tests, same shape as `contracts/mobile-deep-links.json`: [`server/tests/test_i18n/test_enum_label_contract.py`](../../server/tests/test_i18n/test_enum_label_contract.py) imports each module and asserts the live constants under `const_prefix` are EXACTLY the recorded `values`; [`web/src/i18n/enumLabels.contract.test.ts`](../../web/src/i18n/enumLabels.contract.test.ts) asserts every recorded value has a key under every listed namespace in zh-CN / en-US / ja-JP. **The pairing is the mechanism**: adding an enum member fails the Python test until it is recorded, and recording it fails the frontend test until the label exists. It closes the gap the two lint-time i18n guards structurally cannot see — per-VALUE coverage, since the value is only known at runtime. Found 9 live gaps, five at render sites passing no fallback (so the literal i18n key reached the user). Currently covers 14 enums / 93 values: grant revoke reason, my-requests source, certification decision, onboarding resolution/status/SLA, offboarding status/SLA, scope-request status, DNS action, `scope_type`, cost bucket. **Adding a value to any contracted enum requires a label in the same PR** — a value that should never be user-visible goes in `unlabelled` with a reason. An entry may also set `namespaces: []` when its values reach the UI through **hardcoded branches** rather than a dynamic lookup (e.g. `KB_SOURCE_*`, rendered by a ternary); it is then contracted purely for **growth detection** — a new member would be silently rendered as an existing branch, which is worse than being untranslated — and the wellformed test then requires every value to carry an `unlabelled` reason.
- **i18n dynamic-namespace guard (2026-07-30)** — [`web/scripts/check-i18n-dynamic-namespaces.mjs`](../../web/scripts/check-i18n-dynamic-namespaces.mjs), run by `pnpm lint`. A dynamic lookup (``t(`ns.${value}`)``) resolves at runtime, so nothing notices when `ns` is a namespace that never existed — and because those sites pass `{ defaultValue: value }`, the failure is **silent**: the user sees the raw backend enum (`manual_provisioned`, `feishu_approved`, `breach`) in every language while the desktop page beside it shows a proper label. The audit found **9 such sites** (5 mobile detail pages + 2 admin pages), e.g. `cloudTimeline.action.*` where the real namespace is `cloud.timeline.scopeRequest.*`, and `offboarding.status.*` where it is `offboardingAdmin.status.*`. Rule: every **literal** dynamic prefix in `src/` must have ≥1 key in all three bundles (369 prefixes today). Deliberately weak — it cannot prove a specific enum VALUE is covered, since the value is only known at runtime — but it closes the whole-namespace-typo class, which is what the audit actually found. A prefix whose root is a variable has nothing literal to check and is skipped by construction. **When adding a dynamic lookup, point it at the namespace that already carries the labels** — grep the enum's Chinese label before inventing a new namespace; 8 of the 9 needed zero new keys.
- **i18n translation-parity ratchet (2026-07-30)** — [`web/scripts/check-i18n-translation-parity.mjs`](../../web/scripts/check-i18n-translation-parity.mjs), run by `pnpm lint`. **Every guard before it checked that a key EXISTS in all three bundles; none checked that it was TRANSLATED**, so pasting the zh-CN string into the ja-JP slot passed everything — and 2021 ja-JP keys had done exactly that (Japanese users shown Chinese: `账号`, `未读`, `运营洞察`…). Rule, narrow by design: `<locale> value === zh-CN value` **and** the value contains a Han character (Han is required — bare numbers, `{{count}}`, latin product names and emoji are legitimately identical). Values read via the TypeScript AST, not regex. Covers **ja-JP and en-US**. Two files sit beside it: [`i18n-untranslated-baseline.json`](../../web/scripts/i18n-untranslated-baseline.json) (the pre-existing ja-JP offenders, so CI is green today) and [`i18n-identical-allowlist.json`](../../web/scripts/i18n-identical-allowlist.json) (per-locale, permanent, one reason per entry — `名称`/`操作`/`故障` really are the same word in Japanese; language names shown in their own script really are the same in English). **It is a ratchet, not an exemption list**: the guard fails both on a new offender AND on a baseline entry that no longer offends, so the baseline can only shrink — you cannot translate a key and leave the baseline stale. **en-US has no baseline (0 offenders) → a hard gate.** When adding a ja-JP translation, delete its baseline line in the same commit.
- Theme: indigo primary (`#4F46E5`), Ant Design ConfigProvider routes locale via `i18n.language`.
- **Vite chunk strategy** ([`web/vite.config.ts`](../../web/vite.config.ts)): `manualChunks` deliberately scopes large dynamic-imported deps into their own chunks so they don't pollute the always-eager `vendor` bundle. Current chunks: `react-core` (react/react-dom/scheduler **+ Vite's `__vitePreload` helper**), `router`, `google-oauth`, `i18n` (dayjs + i18next + react-i18next), `state` (zustand), `charts` (echarts + zrender), `ui-shared` (leaves BOTH component libraries use), `antd-icons` (@ant-design/icons + -svg), `antd-mobile` (antd-mobile + its exclusive graph, **including the rc-* v5 line**), `antd-ui` (antd + remaining @ant-design/* + @rc-component/* + cssinjs deps), `geo-data`, `mermaid` (mermaid + @mermaid-js/* + cytoscape* + d3* + dagre-d3-es + katex + khroma + marked + roughjs + ts-dedent + @braintree/sanitize-url + @iconify/utils + @upsetjs/venn.js). Everything else falls into `vendor`. **Critical invariant**: chunk lists must contain only packages exclusive to that chunk's purpose. Shared utils (`lodash-es`, `dompurify`, `stylis`, `uuid`) stay in `vendor` because antd / cssinjs / form-libs need them too — putting them in `mermaid` would force `vendor` to import from `mermaid`, eagerly evaluating Mermaid's logger which calls dayjs at module-init top-level → vendor↔i18n↔mermaid cycle TDZ → app-wide white screen on every page load. PR #168 hit exactly this; the `vite.config.ts` comments document the invariant in-place.
- **Two component libraries, one SPA — the mobile-bundle contract** (2026-07-28). Desktop routes render Ant Design; the Feishu H5 shell under `/m/*` renders antd-mobile only. Three rules keep the graphs disjoint, and all three had been silently violated (mobile first paint was 1530 KB gzip; it is now 531 KB):
  1. **Nothing statically reachable from [`web/src/main.tsx`](../../web/src/main.tsx) may import `antd`.** The desktop chrome (ConfigProvider + AntApp + theme + locales) lives in [`web/src/components/DesktopChrome.tsx`](../../web/src/components/DesktopChrome.tsx) behind `lazy()` and is mounted by `App`'s `ChromeGate` only when the pathname is not `/m*`; `MainLayout` is lazy for the same reason. Use [`BootSpinner`](../../web/src/components/BootSpinner.tsx) instead of antd's `<Spin>` on the boot path. Enforced by [`web/src/bootBundleContract.test.ts`](../../web/src/bootBundleContract.test.ts), which walks the static import graph and names the offending file.
  2. **antd-mobile's `rc-*` deps are NOT antd's.** antd-mobile 5 still uses `rc-util` / `rc-motion` / `rc-field-form` / `rc-segmented` / `classnames`; antd 6 moved to `@rc-component/*` + `clsx`. Despite the similar names the two sets are disjoint, so the `rc-` prefix rule belongs to the `antd-mobile` chunk, not `antd-ui`. Check real dependents (`node_modules/.pnpm/*/node_modules/*/package.json`) before moving anything.
  3. **Genuinely shared leaves go in `ui-shared`, never in a leaf chunk.** `@babel/runtime`, `react-is`, `clsx`, `@rc-component/util`, `@ant-design/colors`, `@ant-design/fast-color`, `@rc-component/mini-decimal`, `is-mobile`. A third chunk is the only acyclic placement: parking them in `antd-ui` gives `antd-mobile → antd-ui`; parking them in `antd-icons` gives `antd-icons → antd-ui → antd-icons` (antd imports the icon set), which rollup reports as `Circular chunk` and which leaves the mobile pages importing `antd-ui` anyway.
- **The web image builds from `./web` only — nothing outside it exists at image-build time** (2026-07-28). `image-release.yml` passes `context: ./web`, so repo-root files (notably `contracts/mobile-deep-links.json`, the shared BE↔FE deep-link truth) are absent inside the container. `tsconfig.app.json` therefore **excludes `src/**/*.test.ts(x)`**: tests may import across the repo root, production code may not, and compiling tests during `pnpm build` is what made `tsc -b` resolve in CI and fail with TS2307 in Docker — breaking the `helix-web` image on 7 consecutive main merges (#2068 → #2077) while every PR stayed green. Test type-checking lives in `tsconfig.test.json`, run by `pnpm typecheck` (also now the CI "Type check" step; the old `pnpm exec tsc --noEmit` was a **no-op** — without `-p` it resolves the solution-style `tsconfig.json` whose `files` is `[]`, and without `-b` it checks nothing). `web/Dockerfile` pins `corepack prepare pnpm@9.15.9` to match CI's `pnpm/action-setup@v4 version: 9` and `lockfileVersion: 9.0` — a floating `pnpm@latest` resolves at build time and pnpm 11 cannot even start on `node:20-alpine`. **Operability gap (open)**: a failed `image-release` run on `main` raises no alert, which is why this rotted for seven merges — `merge ≠ deployed` is only safe if the build failure is loud.
  Also note **Vite's `__vitePreload` virtual module must be claimed explicitly** — unassigned, rollup parked it inside the `mermaid` chunk, so the entry chunk carried a static `import { _ } from './mermaid-*.js'` and every page load on both shells downloaded and evaluated 2.3 MB of mermaid to get a ~20-line helper (defeating `MermaidDiagram`'s lazy contract and re-arming the PR #168 ordering hazard). It is now pinned to `react-core`, which has zero outbound chunk edges and therefore cannot introduce a cycle.

### 14.2 Routes

149 routes registered in [`web/src/App.tsx`](../../web/src/App.tsx). Major groupings:

- `/login`, `/dashboard`, `/help`, `/me`, `/me/:userId`, `/me/settings`, `/me/inventory[/:cycleId]`
- `/admin/*` — global admin surface (rbac, users, org, lifecycle, integrations, settings, audit, scheduled-jobs, insights, ai-spending, leaver-blocked, relationship-graph, compliance-incidents, notifications/outbox)
- `/admin/assets/*` — asset admin
- `/admin/cloud/*` — cloud admin (servers, domains, dns, lb, cdn, object-storage, iam-users, billing, bailian, scopes, scopes/:id, role-catalog, overview)
- `/admin/network/overview`
- `/admin/lifecycle/*` — provisioning/onboarding/offboarding
- `/auditor/*` — 9 sub-features + dashboard
- `/my/*` — employee self-service (todos, lifecycle, requests, software-costs, cloud overview, cloud servers, cloud grants, cloud scope-requests, cloud scopes/:id, network)
- `/catalog/software` — public software catalog
- `/company/{directory,mail-groups}`
- `/team/assets` — manager view
- `/403`, `/500`, `/*` (404)

Lots of legacy redirects (e.g. `/admin/provisioning` → `/admin/lifecycle`, `/admin/cloud/per-user-accounts` → `/admin/cloud/scopes`, `/admin/reports/*` → `/admin/insights?tab=*`) — kept so old bookmarks + Feishu deep-links keep working.

### 14.3 Role-based UI

- `<AuthRoute>` — auth gate.
- `<RoleRoute roles={[...]} />` — RBAC gate; `requireManager` mode for team views.
- `<PermissionGuard>` inside pages — element-level (button/tab) hide.
- `viewRole` in Zustand — multi-role users flip perspectives via header dropdown (`cloud_admin` is intentionally NOT a perspective — it's a marker role). **`leader`/团队负责人 is NOT a perspective either (removed 2026-06-15)** — being a manager is the `is_manager` fact (has direct reports), which surfaces the `我的团队` menu group (incl. `团队资产`) and the per-page `下属合计` toggles directly; a plain manager shows a static `普通员工` chip with no dropdown. Backend still tolerates `X-View-Role: leader` as an `[employee]` alias for in-flight clients. See DECISIONS 2026-06-15.

**Default view role on login (2026-05-07)**: `deriveDefaultViewRole` ([`web/src/stores/auth.ts`](../../web/src/stores/auth.ts)) is intentionally a one-line predicate — `global_admin` lands on the admin perspective; **every other role lands on `employee`**. HR partners, finance, asset admins, auditors, and management holders no longer get dropped into a back-office view they didn't ask for; they switch into their specialist perspective from the header role chip when they need to. This makes the first thing a non-admin sees "their own stuff" (requests, assets, cloud resources, profile) instead of an unfamiliar admin console. Older revisions of this function fanned out to ~7 conditional branches; do not re-introduce them — the simplification was an explicit UX call (see ROADMAP "Friendlier first-login UX" entry).

**First-visit OnboardingTour (2026-05-07, per-role on 2026-06-01)**: [`web/src/components/OnboardingTour.tsx`](../../web/src/components/OnboardingTour.tsx) + [`web/src/components/onboardingTour.helpers.ts`](../../web/src/components/onboardingTour.helpers.ts). antd `Tour` walkthrough that auto-fires on the first authenticated render for each `viewRole` the user lands in — every non-`global_admin` view role has its own tour, persisted with a per-role `helix.onboardingTour.seen.<role>.v1` flag (the legacy v1 key is honoured as "employee seen" so users who already dismissed the original walkthrough don't get it again). On first login the user lands in `employee` view (per `deriveDefaultViewRole`) and sees the **employee tour**: sider, "我的工作" / "我的云资源" submenus, help icon (with an "打开使用说明" CTA navigating to `/help`), language toggle, role switcher (only when the user holds > 1 role), notification bell, user dropdown. When the user later flips the role chip into `asset_admin` / `cloud_admin` / `auditor` / `hr` / `finance` / `management`, each fires its own first-visit tour: welcome → that role's sider menu → 1–2 key admin surfaces (e.g. asset workbench + employee requests, cloud overview + network resources, audit log + security insights, HRIS sync + lifecycle, AI Spending + cloud cost insights, insights + audit log) → shared help / lang / role-switch-back / notifications / user-menu steps. **The `leader` tour was retired 2026-06-15 with the view-role** — managers (`is_manager`) now ride the employee tour with one extra `我的团队` step (`onboardingTour.team.*`). `global_admin` is intentionally excluded — IT admins onboard through internal runbooks, not a bubble walk. Targets resolve via stable `id="helix-tour-*"` attributes on the header + `[data-menu-id$="…"]` selectors for dynamic sidebar items, so the tour is decoupled from header shape. The user dropdown carries a "重新查看新手引导" entry that calls `restartOnboardingTour()` (clears the CURRENT view's flag + dispatches the `helix:onboarding:restart` window event); the component reads the active `viewRole` from the auth store and replays the matching tour. 68 new specialist-tour i18n keys × 3 langs (zh-CN + en-US + ja-JP, parity verified). Helper file is split out from the component to satisfy `react-refresh/only-export-components`.

Server-side RBAC is authoritative; frontend gates are defensive.

### 14.4 API clients ([`web/src/api/`](../../web/src/api))

26 client files: `auth`, `asset`, `assetInventory`, `approval`, `cloud`, `cloudLifecycle`, `auditor`, `provisioning`, `offboarding`, `leaverBlocked`, `lifecycle`, `meUniverse`, `user`, `hr`, `integrations`, `integration`, `emailGroup`, `emailAliases`, `permission`, `apikey`, `tokens`, `notifications`, `scheduledJobs`, `relationships`, `aiSpending`, `bailian`. Plus a shared `client.ts` with the Axios instance + interceptors.

**Request-timeout contract (2026-07-31)** — `apiClient`'s `timeout: 30_000` is the **floor for a DB-bound request, not a universal budget**. Any call whose backend work is slower by design MUST override it per-request, and the override must stay under nginx's `proxy_read_timeout 300s` ([`web/nginx.conf`](../../web/nginx.conf)) so the abort is OURS — recognisable by `isRequestTimeout` and reportable with the honest `apiError.timeout` copy ("may still be completing, reload to check") — rather than a 504 whose HTML body the envelope parser cannot read. Three documented classes, each with its rationale next to the constant:

| Class | Constant | Value |
|---|---|---|
| Provider fan-out writes | `PROVIDER_FANOUT_TIMEOUT_MS` / `PROVIDER_CALL_TIMEOUT_MS` ([`cloudLifecycle.ts`](../../web/src/api/cloudLifecycle.ts)) | 240s / 90s |
| Month-chunk DB fan-out reads | `COST_FANOUT_READ_TIMEOUT_MS` ([`cost.ts`](../../web/src/api/cost.ts)) | 120s |
| **Synchronous LLM round-trips** | `LLM_SYNC_TIMEOUT_MS` ([`timeouts.ts`](../../web/src/api/timeouts.ts)) | **180s** |

The LLM one lives in its own shared module because its 13 call sites span 10 files. `BailianLLMClient`'s default is 90s and the heaviest generators (HR insights diagnosis, leadership impact summary, AI-spend monthly summary) raise it to 120s, so every AI surface was aborting at 30s over work that then completed and persisted — prod nginx logged those as **499, never 504**, which is the tell that the client gave up first. Aborting at 30s is what makes the UI claim 生成失败 over a summary the user can see by reloading. **Cached/derived neighbours deliberately stay on the global**: `finance-flux ai/adopt` (copies an already-generated narrative) and the `ai-summary` GETs (read the last stored row) do no model call, and raising their budget would hide a genuinely stuck write for three minutes. Guarded by [`llmTimeouts.test.ts`](../../web/src/api/llmTimeouts.test.ts) (each call site + the ordering invariants + the `aiAdopt` counter-example) and [`HrAiPanel.test.tsx`](../../web/src/pages/admin/insights/HrAiPanel.test.tsx) (DOM-level, the reported button).

Server side, the corollary: with the client no longer capping them, a synchronous LLM path must bound ITSELF. The copilot agent loops ([`copilot.py`](../../server/app/domains/support/copilot.py)) had an iteration cap (`_MAX_TOOL_ITERATIONS` = 6) but no wall clock — 6 × 60s = 360s, past both nginx and the client budget — so they now carry `_COPILOT_TURN_BUDGET_SECONDS = 150s`, checked between hops, degrading through the existing 转人工 fallback. Any new agent loop needs the same.

### 14.5 Shared components ([`web/src/components/`](../../web/src/components))

`AuthRoute`, `RoleRoute`, `PermissionGuard`, `ErrorBoundary`, `GlobalSearch` (header search across policies/users/assets/cloud + three public-directory classes added 2026-06-19 — 邮件组 via `GET /email-groups?search=`, 软件目录 via `GET /assets/software-catalog[/public]?keyword=` (admins hit the full-catalog endpoint and land on the admin page; everyone else uses the name-only public endpoint and lands on `/catalog/software`), 已审批 AI 工具 via `GET /ai-tool-applications/approved?keyword=`; all visible to every signed-in user with no role gate, and since these have no detail-by-id route each hit deep-links to its directory page with a `?q=` param the page seeds its search box from — + the document classes added 2026-06-10: 我的申请 via `GET /me/requests?q=` for every signed-in user, 待审批 via `GET /approval/admin/overview?q=` for admin-read roles, 供给任务 via `GET /provisioning/tasks?keyword=` for global_admin/management — each hit deep-links to the owning/decision page; since 2026-06-11 also a real ⌘K/Ctrl+K global shortcut plus two client-side groups — 页面 (role-aware sidebar flattened by MainLayout into `navEntries`) and 帮助 (the 68-topic `web/src/pages/help/helpTopicIndex.ts` index, hits deep-link `/help#<id>`; HelpCenterPage derives its topic catalogues FROM that index via per-audience `Record<TopicId, parts>` joins so palette and manual cannot drift) — and a per-person localStorage 最近访问 list shown on empty-query focus), `NotificationCenter` (header bell + unread count), `OnboardingTour` (first-login antd `Tour` walkthrough — see §14.3), `PageHeader`, `PersonTag`, `UserSelect`, `PickablePeopleSelect`, `TransferButton`, `RoleChangeDrawer`, `RequestHelpBanner`, `SubmitScopeRequestModal`, `WorkflowTimeline`, `MonoId`, `DesktopChrome` (lazy antd ConfigProvider + AntApp shell — see the mobile-bundle contract in §14.1; never import it eagerly), `BootSpinner` (dependency-free full-screen spinner for the boot path, replaces antd `<Spin>` there).

**Person-picker contract (NON-NEGOTIABLE, 2026-05-12)**: every `<Select>` that offers a person / user picker MUST use one of the two shared components — `UserSelect` (admin pages, debounced search against `/users`) or `PickablePeopleSelect` (cloud scope owner pages, debounced search against `/cmdb/scopes/{id}/pickable-people`). NEVER prefetch a paginated `/users` or `pickable-people` slice and run a client-side `filterOption` over it: in a >200-person org the search box silently misses everyone past page 1 (reported 2026-05-12: 转让 owner 搜不到 `liwen.xu@pingcap.com`). The non-paginated fallback `/assets/reference-data/people-picker` (returns the full active roster, no email) is acceptable only for `it_admin_person_id`-style hand-off pickers on self-service pages where a search-as-you-type UX isn't needed.

**Modal `confirmLoading` contract (NON-NEGOTIABLE, 2026-05-19)**: every Antd `<Modal>` whose `onOk` invokes an async API call (NOT `() => form.submit()` which delegates to `onFinish`) MUST:
1. Hold a `useState` `xxxSaving` boolean that wraps the API call (`setSaving(true)` / `try` / `finally setSaving(false)`).
2. Pass `confirmLoading={xxxSaving}` to the Modal — without this the OK button stays clickable while the PATCH/POST is in flight.
3. Gate the handler entry with `if (xxxSaving) return;` so a fast double-click can't squeeze a second request through before React commits.
4. Gate `onCancel` with `if (!xxxSaving) ...` so the user can't dismiss the modal mid-flight and leave a half-finished mutation untracked.

Why: every confirm hook in the platform that mutates state can ALSO fire downstream notifications (Feishu, MyTodos inbox, email). A double-click = a duplicate notification, which is the visible failure mode (PR 2026-05-19 reported: `MyCloudScopeDetailPage::submitTransfer` double-fired on 转 owner). Sweep landed for `MyCloudScopeDetailPage::submitTransfer`, `BailianAdminPage::submitOwnerChange` + `submitKeyOwnerChange`, `MyTransfersPage::handleRespond`, `MyTodosPage::handleXferRespond` + `handleProvComplete`. The reference shape is `CloudScopeDetailPage::TransferOwnerModal` (since 2026-05-12) and `MyCloudScopeDetailPage::submitAddMember` (since 2026-04-18). Any new Modal whose `onOk` is async without this guard is a copy-of-the-bug.

**Button-as-action double-click guard (companion rule, 2026-05-19)**: the same anti-double-fire contract applies to header-level / page-level admin buttons that hit an idempotent-but-side-effecting endpoint (cache refresh, force re-sync, audit re-run). Pattern: `useState xxxRunning` boolean + `<Button loading={xxxRunning} disabled={xxxRunning} onClick={() => void handler()}>` + handler-side `if (xxxRunning) return;` early-return inside the `useCallback`. Reference: [`EmployeeRequestPage.tsx::refreshAssetTemplates`](../../web/src/pages/asset/EmployeeRequestPage.tsx) (2026-05-19 admin "刷新模板" button — hits `POST /approval/asset/templates/refresh`, which on success drops the 6h template cache and re-fetches from Feishu; a double-click would Feishu-rate-limit + double-audit-log). The button is gated by `canManageAssets(currentAuthUser)` and rendered in `PageHeader.extra` so it surfaces on every mount of the page (`/my/requests`, `/admin/assets/employee-requests`, `/admin/assets/software-requests`) where the viewer holds asset-admin authority. Backend re-enforces `can_manage_assets` so the visual gate is defense-in-depth, not the trust boundary.

**Approval upload MIME allowlist** ([`approval/router.py`](../../server/app/approval/router.py), widget-type-aware since 2026-05-19, image-widget gains video on 2026-05-25): allowlists keyed by `widget_type` because Feishu has THREE different upload endpoints with DIFFERENT format restrictions, and a single allowlist either over-rejects attachments or over-accepts images.

- `image` widget (with 视频 checkbox) → routes by MIME:
  - **photo** (`jpeg/jpg/pjpeg/png/gif/bmp/webp`) → `/im/v1/images` → `image_key` (10 MB cap; HEIC / HEIF / TIFF rejected at the Helix boundary with the "请在「兼容性最佳」改成 JPEG 后重拍" hint because Feishu's images endpoint would otherwise 230002 with a non-localised error the user can't act on).
  - **video** (`mp4/quicktime/x-m4v/webm/x-matroska/3gpp/x-msvideo`) → `/im/v1/files` → `file_key` (30 MB cap matching Feishu's /im/v1/files ceiling). The buyback / asset-return `恢复出厂设置证明` widget's copy literally requires a factory-reset video — without this path the user has no way to satisfy the form. The image-widget value-array serialiser then emits `{"image_key": ...}` for photos and `{"file_key": ...}` for videos in the same widget (Feishu accepts both shapes in one widget when the admin enabled both checkboxes); dispatch is by stable Feishu token prefix (`img_v2_*` vs `file_v2_*`).
- `attachmentV2` widget → `/drive/v1/medias/upload_all` → `file_token` (30 MB cap) → `ATTACHMENT_WIDGET_MIME_TYPES = IMAGE ∪ VIDEO ∪ {heic, heif, tiff, pdf, doc/docx, xls/xlsx, ppt/pptx, txt/csv, zip, octet-stream}`.

Rejection raises `AppException(code=UPLOAD_TYPE_NOT_ALLOWED, message="不支持的文件类型 (X)。<widget-specific hint>")` (NOT `HTTPException`) so the frontend's `getApiErrorMessage` reads `errors[0].message` and the toast names the specific MIME type that failed + the supported set + the widget-specific recovery hint. Per-MIME size cap fires via `UPLOAD_TOO_LARGE` (10 MB photo / 30 MB video / 30 MB attachment). Feishu's downstream endpoint is the authoritative gate so this allowlist is the *first* filter, not the *only* filter. **Pair with [`web/nginx.conf`](../../web/nginx.conf)** — the `/api/` proxy carries `client_max_body_size 50m` so nginx doesn't 413 a video before the request reaches the per-widget gate (default nginx cap is 1 MB; the buyback flow's 3.6 MB factory-reset video hit this on 2026-05-25 and surfaced as a generic "上传文件到飞书失败" toast).

**Unregistered Form.Item submit-value contract** ([`EmployeeRequestPage::buildSubmissionAnswers`](../../web/src/pages/asset/EmployeeRequestPage.tsx), 2026-05-25): Two widget classes in the asset-request modal deliberately render under `<Form.Item>` WITHOUT a `name=` prop:

1. **`image` / `attachmentV2` upload widgets** — naming would let Antd auto-inject an `onChange` that races every `setFieldValue`; see [`feedback_upload_form_item_no_name`](../../.claude/memory/feedback_upload_form_item_no_name.md) and the `MyInventoryPage` `RespondModal` 2026-05-21 incident for the prior bite.
2. **`offboarding_buyback`'s `amount` widget** — renders as a presentational read-only `<Input>` + `<Select>` (currency picker is the only user input; amount is server-quoted), so naming a Form.Item around the pair gains nothing.

Both classes' values were originally written to the form store imperatively via `form.setFieldValue(['answers', widget.id], …)` / `form.setFieldsValue({answers: {[widget.id]: {…}}})`. **However**, `rc-field-form@1.44`'s `validateFields()` returns `getFieldsValue(namePathList)` → `cloneByNamePathList(store, namePathList)` where `namePathList` comes from `getFieldEntities(true)` — **only registered Form.Items**. The same restriction applies to `Form.useWatch` (default `preserve=false` walks the same registered-path clone). So the imperative writes land in the store but **never reach** `values` on submit, `watchedAnswers` on display_condition eval, or anything else that goes through the registered-paths gate.

- For uploads, this surfaced as `<widget label> 为必填项` even with a file visibly attached — the 2026-05-25 离职回购 video-upload bug after PR #498 unblocked the upload pipeline.
- For the buyback amount widget, this silently dropped the user's currency choice on submit — `_enforce_server_buyback_amount` re-quoted in the asset's entity-default currency, and the Feishu approver saw CNY 5235 while the employee picked USD 750 in the UI.

**Contract**: `buildSubmissionAnswers` reads unregistered-widget values from their **canonical React state** at submit time, NOT from `validateFields` output:

- Upload widgets → `uploadFileLists[widget.id]`, filtering to `status === 'done'` and extracting `response.token`.
- Buyback amount widget → `buybackQuote` state (`{amount, currency}` straight from the server quote endpoint, no intermediate form-store hop).

[`RequestSupplementUploads`](../../web/src/components/RequestSupplementUploads.tsx) has used this `useState`-as-source-of-truth pattern since 2026-05-14. When adding a new self-service form with an Upload, a server-quoted price field, or any other presentational widget under an unregistered Form.Item, mirror this — NEVER read its value from `validateFields` output.

**Companion guards** in `handleSubmit` (same file, 2026-05-25 Round 5):

- **`stillUploading` block**: `Object.entries(uploadFileLists).filter([widgetId,_] => preSubmitVisibility[widgetId] !== false).some(item.status === 'uploading')` — toast `asset.request.uploadInProgress` and return early. The visibility filter recomputes via `evaluateDisplayConditions(templateWidgets, form.getFieldValue('answers'))` so an in-flight upload on a now-hidden conditional widget doesn't false-positive-block submission.
- **Buyback pre-flight**: `buybackAmountApplies && (!buybackQuote || buybackLoading)` — toast `buybackError ?? t('asset.request.buyback.amount.loading')` and return early. `buybackAmountApplies` (2026-06-24) is `true` for `offboarding_buyback` always and for `laptop_replacement` only when `isLaptopBuybackChosen(widgets, watchedAnswers)` — so the same read-only-amount render, unified quote-fetch effect (keyed on `buybackAmountApplies` + `watchedAssetId`; clears the stale quote when buyback is toggled back to 否), and `buildSubmissionAnswers` amount branch cover both templates. The shared gate lives in [`approvalWidgetEngine.ts`](../../web/src/components/approvalWidgetEngine.ts) (`isBuybackChoiceWidget` / `isLaptopBuybackChosen`) so desktop + mobile (`MRequestForm`) stay in lockstep with the backend `_laptop_replacement_buyback_chosen`. Must run **after** `await form.validateFields()` so the `asset_id` required-rule fires first; the `buybackLoading` arm catches the currency-switch race where the old quote still sits in state while a new fetch is in flight (`fetchBuybackQuote` only clears `buybackQuote=null` on error, never during refetch).

Regression coverage: [`test_buyback_submit_tamper_guard.py::test_server_quote_applied_when_client_omits_amount_key`](../../server/tests/test_asset/test_buyback_submit_tamper_guard.py) — asserts backend canonical-amount injection works when the client omits the amount key entirely (the new realistic shape after this contract change).

**Feishu widget `value` wire-format contract** ([`approval/service.py::_serialize_widget_value`](../../server/app/approval/service.py), 2026-05-26): every Feishu approval flow that goes through `_serialize_feishu_form` ships the `form` field as a single JSON-stringified array of widget dicts. `_serialize_widget_value` returns the NATIVE Python value for each widget's `value`; the outer `json.dumps(serialized, ensure_ascii=False)` is the ONLY encoding step. The wire shape per widget type (verified live against `POST /open-apis/approval/v4/instances` 2026-05-26):

- `input` / `textarea` / `radioV2`: `"value": "string"` (plain string, no nested JSON).
- `date`: `"value": "YYYY-MM-DDTHH:MM:SS+TZ"` (RFC3339 with explicit timezone offset). Bare `YYYY-MM-DD` is rejected with `1395006 控件值不合法或者为空 控件= <date widget id>`. `_serialize_widget_value` auto-coerces bare dates to `YYYY-MM-DDT12:00:00+08:00` (noon Asia/Shanghai — DST-safe midpoint so the date in the approval card matches the applicant's picked date regardless of approver timezone).
- `image` / `imageV2`: `"value": ["UUID-CODE-1", "UUID-CODE-2", ...]` — **raw** JSON array of plain file-code strings. NO `{image_key|file_key:...}` wrapper objects, NO stringification at the inner level. The file codes MUST come from `/approval/openapi/v2/file/upload` (see endpoint section below) — IM-domain `img_v3_*` tokens are syntactically valid but render as empty in the approval card.
- `attachmentV2`: same shape as image — raw JSON array of UUID codes.
- `amount` (multi-currency widget): **`value` is ALWAYS a plain numeric string; the currency is a SIBLING KEY on the form item, never a member of `value`.** The widget's `option.currencyRange` (e.g. `offboarding_buyback` "Repurchase price" ships `["CNY","USD","JPY","SGD","MYR"]`) is the pickable set and `currencyRange[0]` is what Feishu applies to an item that carries no currency of its own. Wire shape:
  - **default currency** (== `currencyRange[0]`) OR a currency the widget doesn't offer → `{"id": …, "type": "amount", "value": "10400"}` — no `currency` key. `currencyRange[0]` renders correctly and finance edit-nodes round-trip it.
  - **non-default currency the widget offers** → `{"id": …, "type": "amount", "value": "816.61", "currency": "USD"}`. Feishu stores the result as `value: <number>` + `ext: {"currency": "USD"}` — structurally identical to a human's currency pick in the Feishu mobile form, which is why the finance "confirm receipt of payment" **edit** node reads it back fine.
  - **Live shape matrix** (2026-08-13, probed on a *throwaway* approval definition created via `POST /approval/v4/approvals` with `currencyRange = ["CNY","USD","JPY"]`, so no production flow was used as a test bed; instances were cancelled afterwards):

    | form item | create | resulting `ext.currency` |
    |---|---|---|
    | `{"value": "816.61"}` | accepted | `CNY` (= `currencyRange[0]`) |
    | `{"value": {"amount": "816.61", "currency": "USD"}}` | **`1395006` rejected** | — |
    | `{"value": "816.61", "ext": "{\"currency\":\"USD\"}"}` | accepted | `CNY` (`ext` ignored) |
    | `{"value": "816.61", "ext": {"currency": "USD"}}` | accepted | `CNY` (`ext` ignored) |
    | `{"value": "816.61", "currency": "USD"}` | accepted | **`USD`** ✅ |

    (Bonus finding from the same probe: in an approval **definition**'s `form_content`, an `amount` widget's `value` field is the *currency code*, not a number — `"value": "CNY"`. Sending a number there fails with `1390001 currency 0 out of range`.)
  - History — four shapes, three of them wrong: pre-#1204 sent the **stringified** object for all currencies → finance edit-node blanked the field (REQ-20260617-F54C); #1204 reverted to the plain number → dropped the currency label; #2558 (2026-08-04) inferred a **native object** from the image-widget precedent and shipped it **unverified against live Feishu** → every non-CNY submission after it died with `1395006 validate form error 控件值不合法或者为空。控件= widget17284665706080001`, which is how a USD/USA 代提回购 was blocked for 9 days (the CNY majority path never noticed because it falls back to the plain number). The rejection of the dict shape had *already* been recorded in [`apikey/service.py::_autofill_answers`](../../server/app/domains/apikey/service.py) — the approval domain re-derived it the hard way. The sibling-key fix is `_amount_wire_currency`; `currency_range` is read from the widget's `raw_option` in `_serialize_feishu_form`. **Lesson: this widget's shape is not inferable from other widgets — probe it.**
- `dateInterval`: **native object** `{"start": "2026-02-25T00:00:00+08:00", "end": "2026-06-01T00:00:00+08:00", "interval": 97, "timezoneOffset": -480}` (`_date_interval_wire_value`, 2026-08-14). Read off a real Feishu-**native** `computer_borrow` instance via `GET /approval/v4/instances/{code}` — i.e. the shape Feishu itself stores when a human fills the widget in the Feishu app. Three details the 2026-05-19 attempts got wrong: the duration key is `interval` (not `duration`), it is a **number of days counted inclusively** (2026-03-11 → 2027-03-11 = 366), and `timezoneOffset` is part of the object (minutes **west** of UTC, JS `getTimezoneOffset()` convention, so Asia/Shanghai = `-480`). A half-filled / reversed / unparseable window serialises to `""` and the widget is dropped rather than 400'ing the applicant.
- `checkboxV2` / `select`: raw or stringified array of option values (pre-#504 stringified; not yet round-trip verified against live Feishu under the new shape contract).

**Feishu's two `1390001` error variants you'll see** if the contract breaks:
- `控件值不是数组` / "Widget value is not array" — `value` field is itself a string (not a JSON array). Fired when the legacy code did its own `json.dumps` on the inner value.
- `Widget value is not string` — `value` IS a JSON array but its elements are not plain strings. Fired when the array contains `{image_key:...}` / `{file_token:...}` wrapper objects.

Read together: image / attachmentV2 value must be `[str, str, ...]` — raw array, plain string elements.

**Pre-2026-05-26 bug history** — four failed shape attempts, each surfaced a (poorly-worded) Feishu error:

1. `"value": "[{\"image_key\":\"...\"}]"` (stringified array of OBJECTS, PR #258 → PR #498 code). → `1390001 控件值不是数组` — even though value IS a string and parses to a list, Feishu's check is "elements are file-code strings"; objects fail.
2. `"value": [{"image_key": "..."}]` (raw array of objects). → `1390001 Widget value is not string` — elements weren't plain strings.
3. `"value": "[\"img_v3_...\"]"` (stringified array of plain strings). → `1390001 控件值不是数组` — top-level value field was a string, not an array.
4. ✅ `"value": ["UUID-CODE", ...]` (raw array of plain string codes). Verified accepted, BUT widget rendered as empty in the approval card because tokens were `img_v3_*` (IM domain) — fixed by switching upload endpoint, see below.

The bug class was masked for 6+ months because every front-door submit path was being blocked client-side by [the validateFields false-positive](https://github.com/UcJmh/Helix/pull/504) before ever reaching Feishu — only after that PR landed could the bad payload actually hit `/approval/v4/instances` and the live errors surface.

**Regression**: `test_image_widget_value_round_trips_through_form_payload` runs the full serializer → outer dump → wire-parse round-trip and asserts BOTH:
- `parsed[i]["value"]` is a `list` (not a `str`) — catches attempts #1, #3.
- every element of `parsed[i]["value"]` is a `str` (not a dict) — catches attempt #2.

Any new widget-type handler MUST pass an equivalent round-trip test, not just a unit assertion on `_serialize_widget_value` in isolation (the in-isolation test was green for 6+ months while the wire form was broken).

**Approval file-upload endpoint** ([`approval/feishu_client.py::_upload_approval_file`](../../server/app/approval/feishu_client.py), 2026-05-26): all `image` / `attachmentV2` widget file uploads MUST hit Feishu's approval-specific endpoint `https://www.feishu.cn/approval/openapi/v2/file/upload` (note the host is `www.feishu.cn`, NOT `open.feishu.cn` — it's one of the very few Feishu endpoints that doesn't live under `open.feishu.cn/open-apis`). Multipart fields: `name` (filename), `type` (`image` for image MIMEs, `attachment` for everything else including video MIMEs), `content` (file body). Returns `{"code": "<UUID>", "url": "https://..."}`; the `code` is what goes into the widget value array.

The historical wrong endpoints we used were `/im/v1/images` (returns `image_key` for IM messages), `/im/v1/files` (returns `file_key` for IM file messages), and `/drive/v1/medias/upload_all` (returns `file_token` for Drive). All three return syntactically-valid token strings but the strings reference IM / Drive files — Feishu's approval widget renderer doesn't resolve cross-domain tokens, so the widget displays as empty even though the approval instance was created successfully. Verified live 2026-05-26 by submitting buyback with `img_v3_*` tokens → API 200 OK → approval card "Proof of factory reset" rendered empty. Switching to `/approval/openapi/v2/file/upload` → code starts with `D93653C3-...` (UUID-shape) → widget renders the actual file in the approval card.

Video upload (image widget with the 视频 checkbox enabled) goes to the same endpoint with `type=attachment` (Feishu's `type=image` only accepts image MIMEs; videos must upload as attachments and the image widget still accepts the resulting code because the admin-side 视频 toggle is what gates video playback, not upload-time type).

**`dateInterval` on Feishu submit — SKIP (2026-05-19) → shipped (2026-08-14)** ([`approval/service.py::_serialize_feishu_form`](../../server/app/approval/service.py)). For 15 months the `computer_borrow` 借用时间 widget was omitted from the submission: every value shape tried in 2026-05 (`{start, end, duration}` string-days, `{start, end, interval}` epoch-seconds, ISO 8601 with TZ, naked epoch ints, mixed) 400'd with `1395006: validate form error 控件值不合法或者为空 控件= widget<id>`, and Feishu **accepts a submission that simply omits the widget** even though the template marks it `required: true`. The price of that workaround only surfaced when a user compared the two screens on 2026-08-14: the approver's Feishu card showed a reason and a pick-up location and **no dates at all** — the borrow window existed only inside Helix. The fix did not guess a new shape; it read the shape off a real Feishu-native borrow instance (`GET /approval/v4/instances/{code}`) — see the `dateInterval` row in the wire-format contract above. Two safety rails, because the accepted **write** shape still could not be exercised end-to-end without submitting into a production approval flow: an unusable window is dropped instead of raising `APPROVAL_FIELD_REQUIRED`, and `_create_instance_with_form_fallback` catches a `1390001`/`1395006` rejection at instance-create time and **resubmits the identical form minus the interval widget** (`_form_without_date_interval`) — so the worst case is the pre-2026-08-14 behaviour (card missing the dates), never a borrow request nobody can file. That degradation is exactly what the 2026-08-04 amount-widget regression lacked when it blocked every non-CNY buyback for nine days. `create_asset_approval_request` still stashes the window in `EmployeeRequest.detail.borrow_dates`, and `_apply_approved_request_to_asset` still reads it first (Feishu's callback `form_items` arrives partial under load, and the submit path may have degraded); `extracted.get("borrow_start")` remains the legacy fallback. Same `detail`-stash pattern protects `resignation_date` for `asset_return` / `offboarding_buyback`. **Same fix shipped to the API-key flow in the same PR**: `AI API KEY Apply` carries its own required `dateInterval` (`widget17763462677850001`, literally named "DateInterval" in every locale — keyword matching can't reach it, so `apikey/service.py::_auto_fill_answers` fills it BY TYPE from `valid_from`/`valid_until`). `_SKIP_WIDGET_TYPES` is now empty. The degradation rail for that flow (and every other non-asset flow: data_access, cloud scope, DNS, VPN, SSO, announcements, …) lives in the shared durable submitter [`approval/submission_intents.py::submit_with_durable_intent`](../../server/app/approval/submission_intents.py), which persists the degraded form on the intent (`payload.provider.date_interval_dropped`) before retrying so a recovery tick replays what Feishu accepted. The shapes themselves are now one module — [`app/integrations/feishu_widget_values.py`](../../server/app/integrations/feishu_widget_values.py) (`date_interval_wire_value`, `form_without_date_interval`, `FEISHU_FORM_VALUE_ERROR_CODES`) — because the last time two flows each kept their own copy of a Feishu encoding, the approval domain re-derived the `amount` rejection the apikey domain had already recorded, and lost 9 days of non-CNY buybacks to it. Regressions: `test_computer_borrow_ships_date_interval_and_stashes_dates_in_detail`, `test_date_interval_wire_value_matches_feishu_native_shape`, `test_date_interval_wire_value_drops_unusable_windows`, `test_serialize_form_omits_unusable_date_interval`, `test_create_instance_retries_without_interval_on_form_rejection`, `test_create_instance_reraises_non_form_rejections`.

**Payment Information unconditional overwrite (anti-tamper, 2026-05-19)**: `_augment_answers_with_asset` overwrites the `Payment Information` / `付款信息` widget answer UNCONDITIONALLY from the server-resolved `purchase_entity` → `asset_legal_entities` text — not "only when client value is empty". The frontend renders the field `disabled` but `disabled` is a UI affordance, NOT a server trust boundary. Without the unconditional overwrite, a curl/MCP/replay caller could POST forged bank info under the widget's id and the approver's Feishu card would display attacker-controlled account details that LOOK like a legitimate auto-fill. The widget short-circuits the rest of the prefill loop with `continue` so other widgets' "only fill when empty" semantics (serial_number / asset_tag / brand / model) are preserved. Regression: `test_payment_information_unconditional_overwrite_blocks_tamper`.

### 14.6 Utilities

- [`web/src/utils/format.ts`](../../web/src/utils/format.ts) — `formatDateTime`, `formatDate`, `formatRelative`, `formatNumber`, `formatMoney`, `formatBytes` (B/KB/MB/GB/TB auto-unit; added 2026-05-27 to consolidate 1-off `formatSize`-style helpers across cloud storage pages), CJK avatar fallback.
- [`web/src/utils/pagination.ts`](../../web/src/utils/pagination.ts) — `defaultPagination(total, overrides?)` + `previewPagination(pageSize?)`. **Critical contract (added 2026-05-07)**: the base config sets **`defaultPageSize`** (uncontrolled) NOT `pageSize` (controlled). Without this, uncontrolled callers' `{ defaultPageSize: N }` overrides are silently shadowed by the base's `pageSize: 20`, breaking the page-size dropdown across every list table. Controlled callers that spread their own `pageSize` (server-paged tables with `current` + `onChange`) work because antd treats `pageSize` present as the controlled signal — the `defaultPageSize` from the base is harmlessly unused. ANY new pagination callsite MUST either go through `defaultPagination` (uncontrolled, with `defaultPageSize`) or pass the full controlled triple `{ current, pageSize, onChange }` — direct `pagination={{ pageSize: N }}` without an `onChange` is the recurring anti-pattern (PR 2026-05-07 found 11 instances and fixed them, plus the util-level root cause).
- [`web/src/utils/statusColor.ts`](../../web/src/utils/statusColor.ts) — status badge color mappings. **Single source of truth** for every `<Tag color={...}>` driven by an enum-ish string (employment, asset, cloud, approval, sync). Extended 2026-05-27 with the asset lifecycle (`idle`/`assigned`/`claimed`/`maintenance`/`under_repair`/`pending_repair`/`borrow_pending`/`borrow_return_pending`/`transferring`/`disposal_pending`), employment terminal states (`leaver_pending`/`left`), and identity-sync run outcomes (`skipped`/`excluded`/`deactivated`/`deleted`) so the 14 pages that used to maintain local `STATUS_COLORS` records (UsersPage/UserDetailPage/AllApprovalsPage/TeamAssetsPage/DashboardPage/IdentitySyncCenterPage/AssetListPage/AssetDetailPage/AssetDetailModal/PermissionAdminPage/RbacPage/...) all import from here. **Intentionally NOT consolidated**: `MyCloudGrantsPage` + `PermissionsTab` keep local `revoked='red'` / `cloud_closed='cyan'` (product-deliberate to differentiate "just revoked, attention" vs central "neutral past tense"); `CloudAnalyticsDashboard` + `HostsTab` use hex (`#10B981` etc.) for ECharts series, not Antd Tag colors; `auditor/CertExpiryPage` `ALERT_COLORS` indexes by cert-expiry window (`expired`/`30d`/`60d`/`90d`/`none`) not by status. Add new status values here — DO NOT add a new local map.
- [`web/src/utils/roleColor.ts`](../../web/src/utils/roleColor.ts) — role → Antd tag color (`global_admin`/`it_admin`/`asset_admin`/`cloud_admin`/`auditor`/`security_auditor`/`hr`/`finance`/`employee`/`manager`/`management`/`leader`). Added 2026-05-27 to dedupe `ROLE_COLORS` across `UsersPage` + `UserDetailPage` + `RbacPage`. Pairs with `users.role.<key>` i18n namespace for label.
- [`web/src/utils/currencySymbols.ts`](../../web/src/utils/currencySymbols.ts) — `currencySymbol(code)` → glyph (`CNY/USD/EUR/JPY/SGD/MYR/THB/HKD/TWD/GBP`). Added 2026-05-27 to dedupe asset cost rendering across `AssetListPage` / `AssetDetailPage` / `AssetDetailModal`. Falls back to `¥` (CNY) for unknown / null code — matches the historical default in the deduped sites so we don't visually regress legacy rows missing the column.
- [`web/src/utils/listSep.ts`](../../web/src/utils/listSep.ts) — `listSep()` returns `'、'` (zh-CN) or `', '` (everything else) so `array.join(listSep())` enumerations stay locale-aware. Added 2026-05-27 after ESLint's `[一-鿿]` rule flagged 8 hardcoded `.join('、')` sites (asset inventory + formSnapshot). Use this — not `.join('、')` or a literal `, ` — anywhere a Latin caller would expect commas.
- [`web/src/utils/pinnedUserStub.ts`](../../web/src/utils/pinnedUserStub.ts) — `buildPinnedUserStub(personId, ownerLabel)` deserializes a packed `"Display · email@x"` BFF label into a minimal `UserListItem` for `<UserSelect pinnedUsers={[stub]} />`. Added 2026-05-27 to dedupe 23-line hand-rolled stubs across `CloudServersPage` / `CloudCdnPage` / `CloudDomainsPage` edit drawers. Pairs with the [Person-picker contract](#person-picker-contract) — full search uses `UserSelect`, but a drawer pre-populated with the current owner needs a stub row so the select doesn't show a bare UUID while the search results load.
- [`web/src/utils/apiError.ts`](../../web/src/utils/apiError.ts) — `getApiErrorMessage(err, fallback)` + `getApiErrorCode(err)`. Centralises the layered unwrap of Helix's `{success: false, errors: [{code, message}]}` envelope so catch handlers don't drift back to `(e as { message?: string })?.message` (which only ever surfaces Axios's "Request failed with status code N"). Layered fallback: `response.data.errors[0].message` → `response.data.message` → `response.data.detail` → `error.message` → caller-provided fallback. **Every new catch handler that talks to the API MUST use this** — sweep migrating 56 legacy callsites across 24 files landed 2026-05-09; copying the old pattern into a new file is a regression.
- [`web/src/lib/rbac.ts`](../../web/src/lib/rbac.ts) — frontend mirrors of backend `can_*` predicates.
- [`web/src/utils/formValidation.ts`](../../web/src/utils/formValidation.ts) — `validatedValues(form)` wraps antd's `Form.validateFields()` and resolves to **`null`** on invalid input instead of rejecting. Added 2026-08-18 after the same footgun was fixed twice (`IntelligenceBriefPanel` PR #3005, `CloudScopeRequestsPage` this PR): a dialog that keeps itself open by letting `validateFields()` reject — or worse, by `throw`-ing out of a `modal.confirm` `onOk`, where antd's `ActionButton` attaches no rejection handler — emits an **unhandled rejection**. That is invisible in a browser but makes `pnpm test` exit **non-zero with every test green**, which is extremely expensive to diagnose. **Any dialog whose confirm handler validates a Form MUST go through this helper** (`const values = await validatedValues(form); if (!values) return;`) and express "required" as a Form `rules` entry — never as a `throw` whose only purpose is to keep the modal open.

---

## 15. MCP server

[`server/app/mcp/`](../../server/app/mcp). FastMCP-based Streamable HTTP server mounted at `/api/v1/mcp`. PAT-auth wrapped — unauthenticated callers rejected before reaching the JSON-RPC dispatcher.

Exposes Helix capabilities as tools for Claude / agents (list users, fetch cloud spend, submit approval, etc.). The `mcp_server.session_manager.run()` lifecycle is hooked into the FastAPI `lifespan` so MCP is fully wired before uvicorn accepts traffic.

**Two credential kinds** reach the endpoint, both validated in [`PatAuthASGIMiddleware`](../../server/app/mcp/middleware.py) and converging on the same `user_id` + fresh-role load:

1. **PAT** (`helix_pat_…`) — manually issued under 个人令牌; the original surface for Claude Code config files.
2. **MCP OAuth 2.1 access token** (JWT, `type=mcp_access`, audience-bound) — minted by Helix's co-hosted authorization server so a Claude / Cursor connector can self-onboard via the GUI "custom connector" flow (no admin-issued PAT). See **§15.2**.

### 15.2 MCP OAuth 2.1 authorization server

[`server/app/auth/oauth/`](../../server/app/auth/oauth). Helix co-hosts an OAuth 2.1 AS next to the MCP resource server so any spec-compliant MCP client connects with one SSO login. Implements the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization): RFC 9728 (protected-resource metadata) + RFC 8414 (AS metadata) + RFC 7591 (dynamic client registration) + RFC 7636 (PKCE S256) + RFC 8707 (resource-indicator / audience binding).

- **Discovery**: `GET /.well-known/oauth-protected-resource[/api/v1/mcp]` (advertises `authorization_servers`) + `GET /.well-known/oauth-authorization-server`. The MCP middleware returns `401` with `WWW-Authenticate: Bearer resource_metadata="…"` on every unauthenticated probe — the single signal a connector follows to bootstrap. **nginx** proxies `/.well-known/oauth-*` to the backend (it otherwise only proxies `/api/` + `/scim/`).
- **DCR** (`POST /api/v1/auth/oauth/register`): public clients only (`token_endpoint_auth_method=none`, no secret); redirect_uri must be https or `http://localhost`/loopback. Rows persist in `auth_oauth_clients` (migration 325) so a connector's `client_id` survives restarts.
- **Authorize** (`GET /api/v1/auth/oauth/authorize`): validates client + redirect_uri (400, never an open redirect) then 302s to the SPA consent page `/oauth/authorize`. The user (already SSO-logged-in, or sent through `/login` and back via the `helix.postLoginRedirect` stash) approves; `POST /authorize/decision` mints a single-use authorization code (Redis, 60s) bound to user + client + redirect + PKCE challenge + resource.
- **Token** (`POST /api/v1/auth/oauth/token`): `authorization_code` grant verifies PKCE S256 + redirect + client, issues an audience-bound access JWT (`aud` = canonical MCP URI, 1h) + a single-use-rotation refresh JWT (30d, jti in Redis); `refresh_token` grant rotates. `POST /revoke` (RFC 7009).
- **Scope = `mcp` (full RBAC parity with PAT)** — an [approved decision](DECISIONS.md): the OAuth token carries no roles; the middleware loads the owner's roles fresh per request, so offboarding / role changes take effect immediately. Mutating tools stay behind the Leaver gate; OAuth requests additionally validate audience (cross-resource replay defense), owner-active, and force-logout (compared against the token's `iat`). Audit rows carry `via=oauth`.
- **Token mint/verify**: [`core/security.py`](../../server/app/core/security.py) `create_mcp_access_token` / `create_mcp_refresh_token` / `decode_mcp_access_token`. Issuer / discovery URLs / audience anchor on `PUBLIC_BASE_URL` (prod: `https://helix.pingcap.net`) or, when empty, the request `Host` (fine for local/tests). **Prod note**: TLS terminates upstream (ALB) so nginx forwards the ALB's `X-Forwarded-Proto` (not `$scheme`) — without that the issuer renders `http://` and connectors reject it. Integrator-facing how-to: [docs/api/external-api.md](../api/external-api.md) § "Claude / MCP connector (OAuth)".
- **Connection governance** (migration 327, `auth_oauth_grants`). One durable row per (user, client) authorization — the source of truth for the governance surfaces and for revocation. Recorded at first token issue (`record_grant`), `last_used_at` bumped per issue. `is_grant_active(user, client)` = grant not revoked AND client not `disabled_at` — consulted **per request by the MCP middleware** and by the refresh grant, so a user-revoke or admin-disable is immediate logout (existing access token 401s on its next call, not after expiry). Surfaces: employee self-service `/me/settings` → **Connected apps** tab (`GET /auth/oauth/connections`, `POST …/connections/{client_id}/revoke`); admin **`/admin/mcp-connections`** (`GET …/admin/connections`, `POST …/admin/clients/{client_id}/disable|enable`, `require_admin`=global_admin). Audited: `mcp_oauth.connection_revoked` / `client_disabled` / `client_enabled`.

**Per-tool runtime invariants** ([`runtime.py:tool_runtime`](../../server/app/mcp/runtime.py)). Every tool body executes inside this async-context-manager and is guaranteed:

- **One DB session per call**, owned by `tool_runtime`. Service methods MUST NOT call `self.db.commit()` themselves — the runtime commits on success and rolls back on any exception. (Pre-launch audit caught `DnsRequestService.{submit,cancel,recertify_owned}` violating this and pulled their commits up to the HTTP-route boundary.)
- **Leaver Blocked runtime gate** for mutating tools: `if mutates and not leaver_allowed and not is_active_employee(user)` → `LEAVER_RUNTIME_LOCKED` 403, audit row written. Reads stay accessible so leaver_pending users can still see what they own. Matches the REST `require_active_employee_for_mutations` dependency. `leaver_allowed=True` is the carve-out for actions a departing employee must still take, and every use needs its "the HTTP twin is exempt too, for this reason" note at both call sites: attendance monthly-report confirm/return/undo (payroll basis for a worked period), and `submit_approval_request` (归还 / 回购 — that tool takes every template_key, so its real gate is the per-template check in `ApprovalService.create_asset_approval_request`).
- **Stale-user gate**: re-validates `is_effectively_departed(user)` per call — closes the HRIS-sync race between PAT auth and tool dispatch.
- **Per-call audit row** (`mcp.<tool_name>` action). Args are passed through `_summarise_args` which truncates strings to 500 chars, caps lists/dicts at 50 entries with depth/cycle guards, redacts sensitive arg names (`password`, `secret`, `token`, `api_key`, `pat`, …), and scrubs inline credential shapes (Helix PAT, JWT, AWS AKIA, GitHub `gh[pousr]_`, Slack `xox[baprs]-`, Google `ya29.`, Stripe `sk_(live\|test)_`, Anthropic `sk-ant-`, OpenAI `sk-…`, Bearer headers, PEM blocks). Failure-audit always uses a separate session via `_write_failure_audit` so an audit-DB outage cannot mask the original error code.
- **Commit-failure handler**: if `db.commit()` raises after a successful tool body, rolls back via `_safe_rollback` (which itself swallows secondary rollback errors so the typed `AppException` doesn't get replaced by a raw SQLAlchemy traceback), writes a separate-session `COMMIT_FAILED` failure audit, and re-raises a uniform `AppException(500)`.
- **Sensitive-action rate limit**: `apply_sensitive_rate_limit(user_id)` (extracted from the `check_sensitive_endpoint_rate_limit` FastAPI dep so it's callable from outside a Request context) shares the same per-user cap (default 10/h) across HTTP and MCP transports. Used by `reveal_api_key` and `reveal_my_contact`.
- **Return-value defense in depth**: `serialize()` (the JSON coercion every tool's return passes through) recurses through `model_dump()` rather than short-circuiting, and applies `_SERIALIZE_DENY_FIELDS` (`hashed_password`, `key_value`, `refresh_token`, `private_key`, `mfa_secret`, …) at every depth so a future tool that accidentally returns a raw ORM row or a Pydantic schema with a sensitive field name gets the value masked to `"***"`.
- **Boundary helpers** (in [`tools.py`](../../server/app/mcp/tools.py)): `_u(value)` → typed `VALIDATION_ERROR` on bad UUIDs; `_parse_iso_date` / `_parse_iso_datetime` → typed 422 on bad dates with naive→UTC normalisation; `_short_repr(value)` → bounded, control-stripped repr for echoing user input back into validation messages so adversarial payloads can't round-trip into the LLM context.
- **Concurrency invariants**: every mutating service method that flips a status takes `SELECT ... FOR UPDATE`. Pre-launch audit added the `lock=True` flag to `_get_task` (provisioning), `_get_assignment` (apikey), `get_item` (asset inventory), and `check_no_active_seat_request` locks the parent `software_license_plans` row to serialise concurrent seat-request INSERTs. `revoke_key` is idempotent on already-revoked rows (writes `apikey.key.revoke_noop` audit + returns current state, no double mutation).

**Post-launch hardening (2026-05-28, 5-round loop)**. Follow-up 5-round MCP/CLI review loop (R1 code+logic+security, R2 logic+security+regression, R3 logic+security+regression, R4 + R5 final verification) closed 1 P0 + ~30 P1s:

- **P0**: `submit_cloud_access_request` used `can_read_cloud` (admits `auditor` / `finance` / `management`) for its admin-named-role self-grant refusal — these roles could self-grant `User Access Administrator` / `*管理员` via MCP. Tightened to `can_manage_cloud` to match the HTTP twin.
- **Audit attribution**: middleware now sets the generic `current_actor_user_id` + `current_request_trace_id` contextvars (in addition to the MCP-scoped ones) so the `leaver_blocked` `before_flush` trap audit attributes MCP-initiated unguarded writes to the real actor instead of `None` + synthetic uuid.
- **Commit ordering**: `tool_runtime`'s mutating success path now commits business writes FIRST, then writes the success-audit row in a *fresh* session. The previous "audit-in-same-session → commit" order had a poisoned-session double-commit failure mode (an audit-row constraint error would also revert the business write and emit two fallback audits for one event).
- **Naive datetime defense**: `serialize()` and the new `_iso_utc(dt)` helper coerce naive MySQL `DATETIME(tz=True)` loads to UTC at the serialization boundary across ~70 emission sites. Closes the systemic class of "downstream chart treats unsuffixed ISO as local time" attribution bugs. `_iso_utc` short-circuits on `date` (no tzinfo coerce attempt).
- **Class-C free-text caps**: ~20 tools were passing raw LLM-supplied strings (`reason`, `note`, `decision_note`, `response_note`, `description`, etc.) to service / DB-write paths because LLM clients skip the HTTP Pydantic schemas. `_bound_free_text(field, max_len)` now applied at the MCP boundary for every such site.
- **IDOR defense-in-depth**: `release_my_api_key` / `cancel_my_api_key_transfer` / `cancel_my_api_key_release` add tool-boundary `assigned_to == rt.user.id` fail-fast (mirrors `extend_api_key`); `bypass_4_eyes_dns_request` / `retry_blocked_dns_request` gate at the tool boundary on `can_manage_cloud` (was service-layer only).
- **CLI hardening**: `_validate_base_url` blocks HTTPS to non-routable IP literals (previous SSRF block was only on `http://`); `redact_pat` returns neutral `<redacted>` instead of shape-echoing `<unknown-format>`; `_coerce_scalar` preserves zero-padded ids; MCP client adds explicit `verify=True` for defense-in-depth parity with `http_client`.
- **Serialization deny-list**: `app_secret` added to both `_SENSITIVE_KEYS` and `_SERIALIZE_DENY_FIELDS` — every Feishu integration driver's config dict carries it as a literal field name.
- **Other**: rate-limit `int()` cast hardens against decode-bytes pools; `list_my_api_keys` derives the same effective `expired` status as HTTP without mutating assignment rows (durable transition + audit is owned by the scheduled expiry job), retains deprecated `sweep_ok=True` for old clients, and identifies the new semantics with `expiry_status_source='derived'`; `list_compliance_incidents` default mirrors HTTP `status='open'`; `cost_subordinates_breakdown` refuses `leaver_pending` (matched HTTP); `revoke_api_key` raises `VALIDATION_ERROR` not `ValueError` (was mapping to 500); `request_software_seat` price renders `Decimal(0)` (free-tier seats) and uses `plan.currency` not hard-coded `$`; `create_sso_app` mirrors the CR-310 inactive-owner gate that `set_sso_app_owner` enforces; `get_cloud_scope_request_timeline` validates `request_id` with `_u()` at the boundary; 3 audit-arg key drifts fixed (`request_email_group`, `list_cloud_servers`, `list_cloud_domains`).

Codified rules to keep this discipline:

- **R1 audit-arg key drift rule**: keys in `tool_runtime`'s args dict MUST match the tool's parameter names exactly (`additional_owner_ids`, not `additional_owners`; `owner_person_id`, not `owner`) — otherwise admins filtering audit logs by parameter name silently miss the call.
- **Class-C cap rule**: any free-text parameter on an `@mcp_server.tool` that flows to a service call without a Pydantic body wrap MUST go through `_bound_free_text(field, max_len)` first.
- **MCP-boundary defense-in-depth**: ownership / RBAC checks belong at *both* the tool boundary and the service layer — a service-layer-only gate is a latent privilege-escalation if the service ever drops it.
- **Audit-write in fresh session**: tool bodies should not write audit rows in the same session as the business mutation; let `tool_runtime` handle the success audit post-commit, or open an explicit fresh session like `_write_failure_audit`.
- **Boundary datetime emission**: outbound timestamps go through `_iso_utc(dt)` or `serialize()` — never bare `.isoformat()` on a fresh-loaded ORM datetime column. (Date columns can use bare `.isoformat()` — no tz semantics.)
- **Description-as-discovery-surface rule** (2026-07-30): a tool description is the ONLY discovery surface an LLM has, so a tool name cited in a description MUST be registered, and a mutating tool's required ids MUST be obtainable from some registered read tool. `submit_cloud_access_request` + `list_my_cloud_grants` cited `list_my_cloud_subscriptions` — never registered — and it was the only documented `scope_id` source, so the whole cloud-access self-service flow was **unreachable** over MCP while every individual tool worked. Same scan found `list_my_dns_records` (phantom) and `list_persons` / `search_persons` (phantom names for a real capability). **The person-resolver gap was closed the same day by `search_directory_people` (hint 441→442)** — see the decision note below. Enforced by `test_descriptions_never_point_at_unregistered_tools` in [`test_mcp_tools_surface.py`](../../server/tests/test_mcp_tools_surface.py): scans "use/call/via ``X``" + "``X(``" references across every description and requires verb-prefixed tokens to resolve to a registered name. **Corollary — an MCP-sync gap hides as a wrong tool name**: when the twin of a picker/list HTTP route is missing, whoever writes the mutating tool's description has no real name to cite and invents one. `list_cloud_scopes` (1:1 `GET /cmdb/scopes`, hint 440→441) is that missing twin: default = membership view w/ member counts, `requestable=true` = the picker (active + tenant-level + owned-or-GCP-billing + **non-prod**, all filtered at the API layer like the HTTP twin, not just in the UI). Reuses the HTTP `_serialize_scopes` so owner labels / member counts / prod-write-lock state can't drift between surfaces.

- **Person-resolver decision — the twin is the *self-service* picker, not the HR directory** (2026-07-30). `search_directory_people` (1:1 `GET /auth/directory/people/lite`, hint 441→442) resolves a name / partial work email → IdentityPerson UUID, the argument behind every `successor_person_id` / `recipient_person_id` / `new_owner_person_id`. The gate question looked like "HR-only or not" only while the assumed HTTP twin was `GET /auth/users?search=` (`require_hr_read`). It isn't: `/directory/people/lite` already exposes **exactly this field set to every authenticated employee**, and the tools blocked by the missing resolver are mostly self-service (`transfer_my_api_key`, `transfer_my_dns_records`, `transfer_my_servers`, `handover_ai_tool_application` — all owner-callable), which `require_hr_read` would have kept dead. So: same gate as the HTTP picker, and the HTTP picker's safeguards **in full** — `q` mandatory (blank → empty, no roster dump), no pagination, `limit ≤ 30`, active + `leaver_pending` + `hris_record_state='present'` only, plus an explicit `apply_directory_read_rate_limit` (600/h) call. That last one is the non-obvious part: a PAT is headless and long-lived, so without it the MCP path would be a *weaker* enumeration surface than the browser one. Field set stays narrower than the HR workbench (`/auth/users`): no `employee_no` / `roles` / `manager` / `last_login_at`; the HR-gated single-person view remains `get_person_identity_profile`.

**Launch-readiness audit (2026-05-05, third 9-pass)**. Final pre-go-live MCP audit (3 × code-review + 3 × logic + 3 × security) caught the cross-cutting defenses the per-tool passes missed:

- **Per-PAT rate limit** (Redis fixed-window, default 600 req / 60s, [`MCP_RATE_LIMIT_PER_PAT_*`](../../server/app/core/config.py)). Every tool call writes ≥ 1 audit row, so without a cap one runaway / compromised PAT could fill `audit_logs` and saturate the DB. Fail-open on Redis outage with a small async delay (matches `apply_sensitive_rate_limit`).
- **Per-IP failed-auth rate limit** (Redis, default 30 / 60s). Fired BEFORE the PAT-hash lookup — stops a brute-force PAT-prefix scanner from churning DB sessions and from filling the audit table with `mcp.auth_failure` rows.
- **Auth-failure audit trail**. Every rejected MCP request (missing/malformed bearer, non-PAT bearer, invalid PAT, inactive owner) now writes a `mcp.auth_failure` row with `actor_type="anonymous"`, IP, UA. The PAT row's `last_used_*` fields only update on success, so this row is the only signal a brute-force scan generates.
- **Body-size cap** (`MCP_REQUEST_BODY_MAX_BYTES`, default 1 MiB). Header check in middleware before body buffering — without it one PAT could OOM the uvicorn worker by streaming a multi-MB JSON-RPC payload.
- **Contextvar reset** in `try/finally` with `var.set(...).reset(token)`. Defense-in-depth against any future ASGI deployment that reuses tasks across requests — silent identity bleed between users would be the worst possible failure mode.
- **`serialize()` deny-list aligned to a superset of `_SENSITIVE_KEYS`**, plus the real ORM column names (`token_hash`, `secrets_encrypted`, `secrets_nonce`, `key_hash`) that carry credential ciphertext. A future tool that accidentally returns a raw `Integration` / `PersonalAccessToken` row now masks rather than leaks.
- **Outbound string sanitization** in `serialize()`. Every string leaf returned to the LLM gets C0 control chars + Unicode bidi-override codepoints stripped. Closes the **cross-tool prompt injection** vector where User-A's free text (asset notes, transfer reasons, approval answers) feeds into User-B's MCP-driven assistant verbatim.
- **Header-style audit-key normalisation** (`_is_sensitive_key`). Hyphenated / `x-`-prefixed / dash-component variants (`X-Api-Key`, `Set-Cookie`, `Proxy-Authorization`) now match the redaction set. Previously `key.lower() in _SENSITIVE_KEYS` only caught exact matches.
- **Read-only audit-write try/except**. A transient `audit_db.commit()` failure on a read tool used to escape as 500 *after* the read had already returned — fixed to log + best-effort `AUDIT_WRITE_DEGRADED` fallback, never re-raise.
- **Token-redact patterns extended** to Aliyun (`LTAI…`), Tencent (`AKID…`), and GCP service-account JSON (`"type": "service_account"`).
- **`get_cloud_server` / `get_cloud_domain` per-row scoping**. The list endpoints already auto-demoted non-admins to `owner=self` (via `can_read_cloud`), but the singular getters previously returned any row by UUID. Now mirror the HTTP routes: owner OR manager-of-owner OR admin, else `NotFoundError` (no oracle).
- **Software-catalog self-lockout guard** (`update_my_software_catalog_entry`) rewritten to check the POST-patch state of `owner_person_id` AND `it_contact_person_id`. Previous logic over-rejected the legit "give myself it_contact in the same call as transferring owner away" path.
- **`revoke_my_cloud_grant` parity with HTTP**. Hard-coded `reason="user_released"` (caller-supplied `reason` ignored at the service boundary, kept in the signature for API compatibility) and `suppress_driver_error=False` (flipped 2026-05-09 round 16) so a genuine cloud-IAM failure surfaces as a 502-equivalent error instead of silently flipping the local row to `revoked` while the cloud-side grant is still active. Idempotent already-absent responses still succeed at the service layer.
- **`admin_change_seat_plan` per-call trace_id**. Previously `f"admin_change_plan:{alloc.id}"` collapsed multiple tier changes on one allocation into a single trace chain. Now uses the per-MCP-request UUID via `_trace_str()`.
- **`cancel_my_seat_next_month` / `undo_my_seat_cancellation`** now `SELECT ... FOR UPDATE` so two MCP calls (or MCP + UI) racing on the same allocation can't both pass the status check and write duplicate audit rows.
- **`submit_approval_request` rejects `template_key="software_seat"`**. The dedicated `request_software_seat` tool is the only entry point because it enforces `check_no_active_seat_request` (per-(plan,user) dedup) and `check_plan_quota` — a generic submitter would let an LLM stack N pending Feishu approvals on one logical "I want this seat" intent.
- **Boundary input caps** added: `submit_approval_request` (`reason ≤ 4000 chars`, `notes ≤ 4000`, `answers ≤ 50 keys`); `request_email_group` (`additional_owner_ids ≤ 50`); `get_audit_trace(trace_id)` now goes through `_u()` for clean 422.
- **`list_my_software_seats`** no longer crashes on pending allocations (`allocated_at` is NULL until IT confirms).

The cross-cutting defenses (rate limit, body cap, auth audit) live in [`server/app/mcp/middleware.py`](../../server/app/mcp/middleware.py); the per-tool fixes live in [`tools.py`](../../server/app/mcp/tools.py); the redactor / serializer changes live in [`runtime.py`](../../server/app/mcp/runtime.py).

**Pre-launch second-pass audit hardening (2026-05-04)**. The first MCP audit caught the well-known IDOR / TOCTOU / leaver-pending family. A second 9-pass audit (3×CR + 3×logic + 3×security) caught the *latent crashes* the first pass missed — tools that had no smoke tests and would have raised on their first real production call: `renew_my_cloud_grant` forwarded a kwarg the service didn't accept (TypeError every call); `submit_cloud_scope_request` exposed `reason` + `environment` parameters the service didn't accept and was missing the required `cloud_account_type` (TypeError every call); `list_my_cloud_scope_requests` read `r.environment` and `r.external_id` off `CmdbCloudScopeRequest`, but those columns belong to the `CmdbCloudScope` table (AttributeError every call); `list_my_provisioning_tasks` and `get_my_onboarding_progress` read `t.phase` (no such column on `ProvisioningTask`; closest semantic is `action`) — AttributeError every call. Lessons baked into the audit script: every `await <service>.<method>(...)` call inside `tools.py` is now signature-checked against the actual service method via `inspect.signature`, and every inline ORM-attribute access is checked against `__table__.columns.keys() | dir(cls)`. Also closed in this round: `list_my_email_aliases` was marked `mutates=True` because `list_aliases_for_person` does an opportunistic `db.flush()` to refresh the email-alias cache — but that tripped the leaver-pending mutate gate and 403'd a pure read; flipped to `mutates=False` (cache write rolls back at session-close, next call re-fetches from Google). Pagination was retroactively added to `list_my_software_seats`, `list_my_api_keys`, `list_my_inventory_items`, `list_my_permissions`, and `list_my_cloud_scope_requests` (all five had hardcoded or missing pagination, silently truncating power-user data). UUID validation at boundary added to `get_my_cmdb_analytics(integration_id)`. `create_asset_transfer` description rewritten to drop `software_seat`/`mailing_list` from the supported list (service rejects them via `TRANSFER_NOT_SUPPORTED`).

**Tool count**: 442 tools (`_REGISTERED_TOOLS_COUNT_HINT` in [`tools.py`](../../server/app/mcp/tools.py), matching the current `@mcp_server.tool(...)` decorator count; verified 2026-07-30). Use `rg -n "@mcp_server\\.tool" server/app/mcp/tools.py | wc -l` when auditing the count. Earlier PRs that added tools are listed below as examples of the MCP-sync rule: every new self-service flow ships with a 1:1 MCP tool paired to the corresponding HTTP endpoint.

| Tool (added 2026-05-03) | Backed by | RBAC |
|------|-----------|------|
| `list_business_units` | `app.identity.bu_helpers.list_bus` | any logged-in user |
| `my_software_cost_summary` | `_compute_cost_stats(user_id=self)` | self only |
| `subordinates_software_cost_summary` | `_compute_cost_stats(user_ids=BFS)` | manager-or-self |
| `ai_spending_overview` | `AiSpendingService.get_overview(period, bu)` | `can_view_ai_spending` |
| `ai_spending_movers` | `AiSpendingService.get_movers(period, dimension, limit, bu)` | `can_view_ai_spending` |
| `ai_spending_forecast` | `AiSpendingService.get_forecast(period, bu)` | `can_view_ai_spending` |
| `ai_spending_by_bu` | `AiSpendingService.get_by_bu(period)` | `can_view_ai_spending` |
| `my_notification_summary` | `InboxService.summary_for_recipient` | self only |
| `submit_cloud_access_request` | `CloudLifecycleService.submit_access_request` | any logged-in user (caller is grant subject) |
| `list_my_cloud_grants` | direct query on `CmdbCloudAccessGrant` | self only |
| `renew_my_cloud_grant` | `CloudLifecycleService.renew_grant` (tool-layer ownership gate) | self only |
| `revoke_my_cloud_grant` | `CloudLifecycleService.revoke_grant` (tool-layer ownership gate) | self only |
| `submit_cloud_scope_request` | `CloudScopeRequestService.submit` | any logged-in user |
| `list_my_cloud_scope_requests` | direct query on `CmdbCloudScopeRequest` | self only |
| `withdraw_my_cloud_scope_request` | `CloudScopeRequestService.withdraw` | self only |

The pre-existing `list_my_notifications` and `mark_my_notifications_read` were refactored the same day to share the new `InboxService` (eliminates SQL drift between the HTTP path and the MCP path).

**Hint-bump rule**. The `_REGISTERED_TOOLS_COUNT_HINT` constant at the bottom of [`tools.py`](../../server/app/mcp/tools.py) IS the source of truth tests assert against; PLATFORM.md / ROADMAP.md / commit messages cite it. Any PR that adds or removes an `@mcp_server.tool(...)`-decorated function MUST bump this constant in the same PR. The 98 → 105 jump landed in PR #158 without bumping the hint and was caught a day later by a routine `len(@mcp_server.tool)` grep — small lie about platform state, real cost to the next reader. Codified here so future audits don't have to re-discover it.

### 15.1 Official CLI (`helix`)

[`server/app/cli/`](../../server/app/cli) — registered as a `[project.scripts]` entry in [`server/pyproject.toml`](../../server/pyproject.toml). The CLI is a **client-side wrapper around the existing MCP surface**, not a parallel API. It opens a single `POST /api/v1/mcp/` per command, parses the SSE response, and exits.

Why this shape:

- **No second auth/permission channel.** The CLI accepts only Helix Personal Access Tokens (`helix_pat_…`) — the same tokens already used by Claude Desktop, Cursor, and CI scripts. The MCP middleware continues to reject JWT bearers and the CLI rejects non-PAT shapes locally before any network call. There is no service-account model, no PAT scope expansion, no Google SSO device-flow, and no password login.
- **No business logic.** Every mutating action goes through the same `tool_runtime`-mediated path a UI click takes — Leaver Blocked, RBAC, sensitive-action rate limit, audit-row write, approval routing all stay intact. Adding a CLI command is therefore one dispatch entry pointing at an existing MCP tool name.
- **Two REST exceptions.** `helix health` and `helix ready` hit `/api/v1/health` + `/api/v1/ready` directly because the probes are unauthenticated by design (they're what an external load-balancer would hit). No PAT is attached to those requests so it can't leak into proxy logs.

Module layout:

| File | Role |
|------|------|
| [`config.py`](../../server/app/cli/config.py) | Resolve `base_url` / `token` from CLI flag → env → file → default. Persists at `~/.config/helix/config.json` (mode `0o600`, parent `0o700`); `HELIX_CONFIG` overrides for tests/CI. `redact_pat` only ever exposes `helix_pat_ab****`. |
| [`output.py`](../../server/app/cli/output.py) | Single chokepoint for stdout/stderr writes. Every byte passes through `redact()` first — PATs, `Bearer …`, JWT shapes, and `password` / `api_key` / `secret` / `private_key` / `refresh_token` style key-value pairs are masked. Honours `HELIX_DEBUG=1` for tracebacks but never disables redaction. |
| [`mcp_client.py`](../../server/app/cli/mcp_client.py) | Hand-rolled JSON-RPC client over `httpx` (no async, no SDK dependency). Sends `Authorization: Bearer …`, `Accept: application/json, text/event-stream`. SSE parser handles single-`data:`, multi-`data:`, comments, leading-space stripping per spec. Status-code → exit-code: 401→2, 403→3, 4xx tool/RPC error→4, 5xx/network/timeout→5. |
| [`http_client.py`](../../server/app/cli/http_client.py) | Tiny REST helper used only by `health` / `ready`. `trust_env=False` everywhere so `HTTP(S)_PROXY` env can't redirect PAT-bearing traffic to a debug recorder. |
| [`main.py`](../../server/app/cli/main.py) | argparse dispatcher (stdlib only — Click/Typer rejected as overkill for ≤ 30 commands). |

User-facing surface:

```
helix [--base-url URL] [--json] [--timeout SECONDS] <command>

config   set-url URL | show
auth     token set --token-stdin | token clear
mcp      initialize | tools | call <name> [--arg k=v ...] [--json-arg JSON]

# convenience shortcuts (each = `mcp call <tool>`)
whoami | assets list | software {catalog,seats} | cloud servers
notifications {summary,list} | approvals list

# unauthenticated REST probes
health | ready
```

`auth token set` only reads from stdin (`--token-stdin`) — there is intentionally no `--token` flag because tokens passed on argv leak into shell history and `ps(1)` listings.

The CLI never auto-retries a failed call (read or write) — important for mutating tools, where a retry would risk double-submitting an approval / ticket. Failures surface a single redacted error line + non-zero exit.

Test surface lives at [`server/tests/test_cli/`](../../server/tests/test_cli) and covers config precedence + file-mode invariants, redaction patterns, the SSE parser's spec edge cases, the MCP client's wire shape (path `/api/v1/mcp/` with trailing slash, headers, JSON-RPC body), and end-to-end argparse dispatch via mocked httpx. The existing [`test_auth/test_mcp_endpoint.py`](../../server/tests/test_auth/test_mcp_endpoint.py) regression suite (JWT-rejected, PAT-accepted, real `tools/call`) continues to gate the server side.

---

## 16. Migrations index

Single head `567` (verified 2026-08-29 with `alembic heads` + a full upgrade / downgrade / re-upgrade round trip on a scratch DB; was `519` on 2026-08-19 — this number moves often, so use the migration graph rather than filename sorting as the truth). High-level grouping:

> **Idempotency guards must be schema-local** (invariant, enforced 2026-08-17 by
> [`tests/test_alembic/test_information_schema_probes_are_schema_local.py`](../../server/tests/test_alembic/test_information_schema_probes_are_schema_local.py)).
> Every `information_schema` existence probe in a migration MUST carry
> `TABLE_SCHEMA = DATABASE()` — or `CONSTRAINT_SCHEMA = DATABASE()` for
> `REFERENTIAL_CONSTRAINTS`, whose scope column that is (`COLLATIONS` is a
> server-level view and is exempt). `information_schema` spans every database on
> the server, so an unqualified probe answers for *other* schemas: it reports
> "already exists", the guarded DDL is skipped, and `alembic upgrade head` still
> succeeds and stamps head. The damage surfaces much later as `1054 Unknown
> column` from unrelated code. Migration 005 shipped this way and left
> `asset_software_catalog.custom_fields` missing on every fresh local/CI database
> co-hosted with another helix schema (prod TiDB Cloud has a single `helix`
> database, so it was never exposed). Prefer the existing helpers in
> [`app/core/migration_helpers.py`](../../server/app/core/migration_helpers.py),
> which are already schema-local. Note when auditing this by hand: the house
> style is **uppercase** `TABLE_SCHEMA`, so a case-sensitive `grep table_schema`
> falsely reports ~225 migrations as unguarded when only 005 ever was.

| Migrations | Theme |
|-----------|------|
| 636 | **把「催几轮后升级」种进平台设置(纯数据)。** `assistant.room_escalate_after_reminders`(默认 3)在 635 落地时只以代码里的默认值存在,而平台设置页列的是 `platform_settings` 里**实际有的行** —— 于是这个键在页面上根本不出现,管理员想改成 2 或 5 没有入口。同一批设置里另外五个都是种进去的,唯独这一个漏了(合并后复审找到)。幂等 + **不覆盖**管理员已经改过的值。降级删这一行。0 表 / 0 列 / 0 端点 / 0 MCP / 0 cron。 |
| 635 | **`assistant_escalation_routes.backup_owner_person_id` + `assistant_ticket_chats.escalated_at`。** 备份负责人:主负责人今天在休假(读 `hr_leave_absences`,与对外可用性接口同一套 `approval_status` 判据)或没有可用飞书账号时拉备份那个人;两个都不在时**仍然降级成只建工单** —— 拉不到人时说拉到了是这条链路最坏的失败。`escalated_at`:一个房间只升级一次(否则每次催办都再拉一次人,房间会变成整个 IT 团队)。类型用 `id_type_for("identity_persons")` 而不是裸 CHAR(36) —— 裸类型继承表默认排序规则,TiDB 上那是 3780 的形状。群内催办**复用** `support.ticket_sla_reminder` 的水位与冷却,不新建时间表。0 端点 / 0 MCP / 0 cron。 |
| 634 | **`assistant.ticket_chat_sweep` cron + 两个保留期设置。** 每天收尾一次工单群:结了的按 `assistant.ticket_chat_retention_days`(默认 7)解散、**解散前保证工单上有一段过程记录**(群的聊天记录随解散一起消失),还开着但超过 `assistant.ticket_chat_idle_nudge_days`(默认 14)没动静的群里问一句「这件事还要不要」——**刻意不自动关单**。cron 的 `config` **刻意留空**:阈值只从平台设置读,而 #3520 正是反过来的那一半(播种进 config 的钥匙赢过设置页,静默一周)。 |
| 633 | **`assistant_ticket_chats` —— 工单 ↔ 飞书群的台账。** `ticket_id` 唯一(一张工单最多一个房间:建群不可撤销,两个讲同一件事的房间会让参与的人各说各话)、`feishu_chat_id`、`card_message_id`(群里那张工单卡,状态变了要原地重画)、`state`/`closed_at`/`close_reason`、`last_activity_at`(收尾任务的静默判据)、`opened_by_person_id`/`opened_reason`。回填:已有转人工记录里带 `feishu_chat_id` 的那些。 |
| 632 | **`assistant_conversations.chat_kind` + 三个群内应答设置。** `chat_kind` ∈ `p2p`/`ticket_group`/`group`(审计里分得清「谁在哪种房间里问的」);`assistant.group_chat_enabled`(默认**开**,Helix 自己建的工单群)、`assistant.group_chat_any_enabled`(默认**关** —— 打开等于让全公司任何群都能 @ 它)、`assistant.group_turns_per_hour`(默认 120,与「每人每小时」各自独立)。种子幂等且**不覆盖**已有行。 |
| 631 | **取走播种进 cron config 的坐席阈值覆写(纯数据).** 从 `scheduled_jobs` 里 `handler_key='github_org.governance_digest'` 那一行的 `config` 删掉 `seat_warn_threshold` 键(其余键原样保留)。583 播种了 `{"seat_warn_threshold": 10}`,#3391 把阈值抬成平台设置(生产 1)时没取走它 —— 而 handler 把 config 当「调用方显式传的」用,**优先级最高**。结果:2026-09-08 首播那封摘要按 10 报了 `pingcap-inc` 余 4,而设置页写着 1、界面待办按 1 算(不报)。同 PR 的 handler 改成**填了就抛**并在报错里指出该改的地方。幂等(键不在就不动),坏 JSON 跳过不「顺手修好」;downgrade 刻意不还原 —— 还原等于把 bug 装回去。 |
| 630 | **「对象定位」评测套件的那一行 cron(纯数据).** 第二行 `scheduled_jobs` + 复用 `assistant.eval_regression` 的 handler_key + `config={"suite":"object_lookup",…}`(周一 04:40 上海,排在回归评测 03:30 之后 —— 两跑占用同一个模型后端)。每周那道任务只跑 `config["suite"]` **一个**套件,少了这一行,新套件是一套永远不会亮红灯的闸。**本行是 #3520 补记的** —— #3517 落 630 时漏了迁移索引。 |
| 527 | **统一 IT 服务运营主线。** 新增 `support_service_requests`（一次员工需要，不等同于工单）、`support_external_ticket_links`（仅精确外部关联）和 `assistant_message_feedback_events`（追加式评价历史）；Assistant 会话、每条消息、deflection 与 Helix 工单关联服务请求。终态后的下一条 Agent 需求自动开启新请求，七日无动作请求归档为放弃。工单新增意图发起人/实际建单方/建单方式/入口、员工确认时间、首响与解决 SLA、暂停时钟和双向催办计数。种子任务 `support.ticket_sla_reminder` 每 15 分钟按当前责任方提醒未认领、临近/已超时、等待员工补充及待确认关闭；通知走 Outbox。`support_operations_analytics` coordinator 跨 support/assistant/feishu_helpdesk/notification 聚合管理报表，并只按 `support_external_ticket_links` 去重。 |
| 001 | initial MySQL baseline |
| 002–003, 007 | password / payment / pinyin backfills |
| 004–005 | search + custom fields |
| 006, 008–013, 044 | CMDB v1 (domains, DNS, LB, CDN, OSS, IAM users, app resources, FK tightening) |
| 014 | audit log insert-only trigger |
| 015–016, 036 | HRIS sync staging + multi-instance |
| 017–018 | indexes + work_email uniqueness |
| 019, 022, 024 | software license + permissions |
| 020 | API key domain |
| 021 | provisioning framework |
| 023 | offboarding + transfer |
| 025–029, 034 | integration unification + seeded standard integrations |
| 030–031 | unified employee_requests |
| 032–033 | HR lifecycle automation + offboarding policy simplification |
| 035 | email templates |
| 037, 050, 064, 081, 094, 095 | scheduled jobs seeds |
| 038–039 | lifecycle automation + unified provisioning + ownership |
| 040, 080 | cloud_admin role lifecycle (drop, then revive with new semantics) |
| 041 | provisioning dispatch seed |
| 042–043 | drop legacy CMDB applications + HR finance roles |
| 045, 051, 056, 077 | integration seeds (HRIS prod, Zoom, Slack, Jira) |
| 046, 053 | merge cloud_provider into integrations + drop legacy |
| 047 | per-capability cmdb sync + billing |
| 048, 057 | identity contact + email aliases |
| 049 | software catalog owner FK backfill |
| 052, 089 | cloud scope lifecycle + decommission |
| 054, 060, 070 | cloud default roles seeds (AWS, Azure) |
| 055 | scope alert rules |
| 058 | split legacy CMDB + seed clouds |
| 059 | scheduled_jobs id dashes → underscores |
| 061, 065 | grants source markers + discovery |
| 062 | widen provider_native_id columns |
| 063 | software monthly bills |
| 066 | Tencent/UCloud production seeds |
| 067 | Aliyun Intl IAM capability |
| 068, 071 | role curation + scope requests + frontend URL reconcile |
| 069 | Personal Access Tokens |
| 072 | asset brands + models |
| 073 | apikey IAM binding |
| 074–075, 083–084 | Bailian tables + provider seeds + AI summary widening |
| 076, 078 | cloud scope environment |
| 079 | AI spending |
| 082 | software category |
| 085 | grants request state |
| 086 | notification outbox read_at |
| 087 | Bailian usage per-account |
| 088 | asset inventory |
| 090 | scope provider state (offboarding SLA) |
| 091 | workflow leaver relationship |
| 092 | normalize auth role IDs |
| 093, 094 | security auditor + quarterly auto-open |
| 096 | seat self-cancel next month |
| 097 | rebuild rel_relationships schema |
| 098 | DNS self-service request rows |
| 099 | `management` role (read-only leadership view) |
| 100 | `platform_security_tips` table + 5 default tips (rotating header banner) |
| 101 | sso_grants (SSO grant register + leaver handover) |
| 102 | domain_registrations (registered domain register + leaver handover) |
| 103 | Google account auto-lifecycle: `google_orphan_accounts` table, "Google Workspace" base catalog + `user_suspend` connector seed, `google.seat_sync` daily job, `google.account_provisioning` + `google.contractor_company_codes` platform-settings |
| 104 | Notification card polish: rewrite seeded `cloud.scope_request.*` templates with `[r]` money emphasis + `{cta_line}` placeholder (drops duplicated URL line on Feishu card, kept inline for email) |
| 105 | vpn_applications (Feilian Full Mode access flow; no handover) |
| 106 | announcement_requests (Company-wide announcement intake) |
| 107 | ai_tool_applications (AI function/product security review intake; public registry) |
| 108 | seed_approved_ai_tools (backfill historical Feishu xlsx into ai_tool_applications) |
| 109 | seed `feishu.seat_sync` scheduled-job row (daily 03:00 UTC, disabled) |
| 110 | hot-fix for 109 — seed missing `feishu_group` software-provisioning integration row |
| 111 | `identity_persons.hrbp_person_id` self-reference FK (cached HR Business Partner; resolved live from Feishu `custom_attrs`, written back here for SLA dispatch fast-path) |
| 112 | Backfill `asset_software_catalog.department` from each owner's primary org membership; `IT` fallback when owner is null or has no primary org. Pairs with the runtime change in `asset/router.py` that turns the column into a derived field — both create + update paths now strip client-supplied `department` and recompute from `owner_person_id`. |
| 113 | `cmdb_cloud_scopes` server defaults + `last_spike_alert_date`. Backfills NULLs on `environment` / `auto_suspend_after_days` / `default_grant_ttl_days` / `status` and adds DB-side defaults so new rows can never re-introduce the NULL state that was crashing `sweep_expiring_grants` mid-loop (`scope.environment.lower()` on a NULL column). Adds `last_spike_alert_date DATE NULL` for at-most-once-per-(scope, date) idempotency on the daily-spike alert (the docstring claimed idempotent; the code wasn't). |
| 114 | Seed `cron.cleanup_stale_running` scheduled job (hourly, default `stale_after_hours=6`). Sweeps `cmdb_sync_jobs` + `scheduled_job_runs` rows stuck in `status='running'` after a worker SIGTERM mid-run, so the JSON_OVERLAPS lock no longer permanently blocks subsequent syncs. |
| 115 | Pre-launch hardening: `asset_items.deleted_at` (soft-delete) + `asset_items.borrowed_by` FK + `asset_items.borrow_started_at` + 7 missing performance indexes (`approval_requests`, `approval_steps`, `auth_entitlements`, `notification_outbox`, `audit_logs`); seeds `asset.borrow_overdue` + `feishu.approval_pending_reaper` cron jobs. |
| 116 | Pre-launch hardening (5th audit): adds `UNIQUE(user_id, permission_id)` on `user_permissions` (constraint name `uq_user_perm`) so the SELECT-then-INSERT race in `request_permission` can no longer create duplicate active rows on double-click. Includes a TZ-independent dedupe pre-pass (priority: active > pending > approved > revoked > expired > rejected, ties → newest `updated_at`) so the constraint can land on existing data. |
| 117 | Pre-launch hardening — seat / billing / software-integration (sixth 9-pass audit). (a) `software_license_plans.currency VARCHAR(3) DEFAULT 'CNY'` + backfill, (a-bis) `software_monthly_bills.currency VARCHAR(3) DEFAULT 'CNY'` + backfill (close_month writes `plan.currency` at finalize time so post-edit changes to the plan's currency don't retroactively rewrite a closed period; without this column every billing endpoint would 5xx on first call post-deploy — caught in round-2 audit), (b) `software_seat_allocations.drift_observed_at DATETIME NULL` for the Feishu drift-cleanup grace pattern, (c) `software_permissions.deleted_at DATETIME NULL` for catalog soft-delete, (d) new `software_monthly_bill_history` table (full snapshot of every `software_monthly_bills` row a `reopen_month` is about to clear; `supersedes_seq` + `superseded_at` + `superseded_by` so any prior version of any closed period stays reconstructible), (e) new `cmdb_billing_alerts_dedup(integration_id, month, last_alerted_at, last_alerted_amount_usd, last_alerted_budget_usd)` UNIQUE on `(integration_id, month)` — kills the previous "30 alerts/month per over-budget account" cron-spam pattern. Pairs with DECISIONS 2026-05-05 sub-decisions 1, 2, 4, 5. |
| 118 | One-shot cleanup of false stale-grant alerts (PR #170 follow-up). UPDATE-only migration, no DDL. (a) Clears `cmdb_cloud_access_grants.last_notified_stage` (`inactive_owner` / `inactive_it`) on rows whose `person_id` belongs to anyone holding `cloud_admin` / `it_admin` / `global_admin` (~180 prod rows). The `sweep_inactive_grants` runtime fix exempts these going forward; this clears the pre-existing flags so the next run does not re-pick them up via the wrong stage. (b) Marks `(source='discovered', status='active', role_key IN AWS-self-service-managed-policy-set)` rows as `revoked` with `revoke_reason='drift_removed'` (~23 prod rows). Pairs with the new `_is_self_service_managed_policy` filter in `_derive_grants_from_iam_users` and the new mark-and-sweep in `sync_grants_for_integration`. |
| 119 | Pre-launch hardening — seat-allocation uniqueness + billing-history uniqueness + FK CASCADE→RESTRICT (seventh 9-pass audit). (a) Dedup pre-flight: any pre-existing duplicate `(plan_id, user_id)` rows with `status IN ('active','pending')` get the older instance(s) flipped to `revoked` with `revoked_at = allocated_at` (zero-billable-window) so the kept row is unambiguous and no row sneaks into a bill. ROW_NUMBER() with `id DESC` tiebreak for deterministic re-runs. (b) Backfill stuck-pending rows whose linked `approval_requests.status IN ('rejected','cancelled')` to `revoked` with `revoked_at = allocated_at` — the original Feishu rejection callback only flipped the employee_request, leaving the seat permanently stuck pending and silently billed by `_fetch_allocations_overlapping`. (c) New VIRTUAL generated column `software_seat_allocations.active_unique_key` = `CASE WHEN status IN ('active','pending') THEN CONCAT(plan_id, ':', user_id) ELSE NULL END` + UNIQUE INDEX `uq_software_seat_active_pending` on it. The NULL'd virtual column lets multiple `revoked` rows for the same (plan, user) remain legal (a user can request → revoke → request again any number of times) while making double-active / double-pending impossible at the DB level. Pairs with the seat-handlers' widened `existing_*` query so a stuck-pending row doesn't fire IntegrityError on the next sync run. (d) New UNIQUE INDEX `uq_software_monthly_bill_history_period_seq_bill` on `(period, supersedes_seq, original_bill_id)` — belt-and-braces alongside the new `reopen_month` advisory lock so two concurrent reopens cannot both write history rows with the same composite key. (e) Idempotent FK rewrite: `software_license_plans.software_id` foreign key changed from `ON DELETE CASCADE` to `ON DELETE RESTRICT` (prefer `SHOW CREATE TABLE` for the actual droppable FK name, with `information_schema` only when SHOW CREATE is unavailable → `DROP FOREIGN KEY` → `ADD CONSTRAINT … ON DELETE RESTRICT`). Hard-deleting an `asset_software_catalog` row was previously a CASCADE-wipe of every license plan + every historical bill's plan-anchor; now it requires admin to explicitly delete plans first (which themselves now refuse delete when seats or bills exist). |
| 120 | Pre-launch hardening (8th audit) — adds `cmdb_iam_users.sub_account_id` (`String(64)` NOT NULL DEFAULT `''`) and rebuilds the unique constraint as `(integration_id, user_name, sub_account_id)`. Without this, the AlicloudIntl provider — which iterates Resource Directory member accounts and returns RAM users from each — silently collapsed cross-account users with the same `user_name` (`cloud-sso-admin`, `terraform-runner`, etc.) into one row, mis-attributing ownership and breaking offboarding revoke targeting. Single-account providers (AWS, Tencent, plain Aliyun) write `''` and behave exactly as before. Backfills from `tags->>'$.__sub_account_id'` so existing rows keep their attribution. |
| 121 | Backfill-only — finishes what migration 119 step (b) attempted. UPDATE-only, no DDL. Migration 119 joined `software_seat_allocations` to `approval_requests` via `sa.approval_request_id = ar.id`, but `request_seat` never populates that column (the linkage is one-directional via `approval_requests.source_payload->>'$.seat_allocation_id'`), so 119's backfill matched zero rows on TiDB and the orphan pending seats remained — silently blocking re-applications via `check_no_active_seat_request` (409 `DUPLICATE_SEAT_REQUEST`). 121 walks the correct linkage: pending seat rows whose linked approval is `cancelled` / `rejected` get `status='revoked'` with `revoked_at = allocated_at` (zero-billable-window — the seat was never live). Pairs with the new cancel-cascade in `cancel_employee_request` so future cancellations no longer create new orphans. Idempotent (`status='pending'` filter excludes already-fixed rows). |
| 122 | **Identity Sync Center** — three new tables (`identity_sync_targets`, `identity_sync_state` UNIQUE(target_id, person_id), `identity_sync_runs` indexed on `(target_id, started_at)`); seeds the `identity_sync.dispatch` ScheduledJob (60s interval); when a primary `google_workspace` integration exists, seeds an equivalent `google-dept` target taking over the legacy `google.dept_sync` ScheduledJob's behavior; **deletes** the legacy `google.dept_sync` row outright (cascades to its run-history) — the deprecated handler was removed entirely from `google_handlers.py`, no stub-for-safety registration. |
| 123 | Add `identity_persons.cost_center VARCHAR(64) NULL`. Backfill is left to the next HRIS sync (`successfactors_sync` now selects `EmpJob.costCenter` and writes it on upsert) — the column lands NULL and the SF sync's normal pass fills it in. The Identity Sync Center exposes `{cost_center}` as a mapping source token (used by the Navan SCIM enterprise.costCenter slot). |
| 124 | History protection — converts every `cmdb_ai_resources` / `cmdb_ai_resource_cost_daily` / `bailian_workspaces` / `bailian_api_keys` / `bailian_usage_daily` parent FK to `integrations` from `CASCADE` to `RESTRICT` (and `workspace_pk` to `SET NULL`) so an admin hard-deleting an `integrations` row can't silently wipe months of AI/Bailian billing audit history. Operators must soft-disable via `Integration.enabled=False` instead. |
| 125 | Re-encrypt every `integrations.secrets_encrypted` row using AES-GCM with AAD bound to the row id (`integration:{row.id}`). Migration 124 introduced the AAD binding in code but pre-existing rows decrypted via the no-AAD legacy path; this migration walks each row, decrypts legacy, re-encrypts with AAD, writes back. Out-of-tx so a single corrupted row is logged + skipped instead of rolling the upgrade back. Idempotent — already-AAD rows are no-ops. |
| 126 | **SSO Apps catalog** — new `sso_apps` table (one row per Google Workspace SAML SSO profile) with provider / external_id / display_name / platform / auth_type / user_access_state / certificate_expires_at / acs_url / entity_id / x509_cert / **owner_person_id** (FK `identity_persons` SET NULL) / source / raw JSON / last_synced_at; UNIQUE on `(provider, external_id)` (NULL external_id allowed for `helix_created` rows). Extends `sso_grants` with four nullable columns (`acs_url`, `entity_id`, `x509_cert`, `saml_metadata_xml`) and `created_app_id` FK back to `sso_apps` (SET NULL on delete). |
| 128 | UNIQUE on `sso_apps(provider, display_name)` — closes a check-then-insert race in `upsert_app` that two parallel CSV imports / a CSV import overlapping with the grant approval executor could trigger (both SELECT-empty → both INSERT → duplicate rows sharing display_name with different `id`s; grant-linking attached to whichever the SELECT found first; the duplicate became orphan catalog junk). The pre-existing UNIQUE on `(provider, external_id)` didn't catch it because admin-added rows have NULL external_id and MySQL treats multiple NULLs as distinct. With the new constraint, the loser of the INSERT race raises `IntegrityError`; `upsert_app` catches it, rolls back its pending row, re-SELECTs the winner, and applies the caller-supplied non-`_UNSET` fields as an UPDATE-in-place. Round-2 audit fix. |
| 127 | **Pivot the SSO Apps catalog from Google-sync to admin-managed.** Migration 126 was built on the wrong assumption that Google's Cloud Identity `inboundSamlSsoProfiles` API would return the "Apps → Web and mobile apps" SAML catalog (Google as IdP for external SaaS apps). It actually returns INBOUND SSO profiles (third-party IdP → Google). Verified empirically 2026-05-07: a tenant with 34 outbound SAML apps in admin returned exactly 1 inbound profile (a stale Salesforce IdP test). Google has NO public API for the outbound catalog — long-standing community-known gap. So 127: (a) wipes the bad sync row from `sso_apps`; (b) drops the `'google_synced'` server default; (c) Python ORM source values become `'admin_added'` (manual UI/CSV) and `'helix_created'` (post-grant-approval). The grant approval executor still runs (every 5 min) — but instead of calling Google, it inserts the local catalog row + sends the new `sso.app_create_pending` notification template to global_admins so IT can mirror the app in Google admin manually. The daily `sso.google_saml_app_sync` cron is removed. The Cloud Identity scope constants and three driver methods (`list_inbound_saml_sso_profiles` / `list_idp_credentials` / `create_inbound_saml_sso_profile`) are removed from the Google Workspace driver. Admin UI gains "+ 添加应用" modal + "批量导入 CSV" + delete; HelpCenter SSO Apps section rewritten. New backend endpoints `POST /api/v1/sso/apps`, `DELETE /api/v1/sso/apps/{id}`, `POST /api/v1/sso/apps/import-csv`. Lesson recorded in memory `feedback_verify_third_party_api_direction.md`. |
| 517 | **Separate "the provider said no" from "the provider could not answer" on MFA rows.** Adds `auditor_mfa_status.enrollment_observed` / `enforcement_observed` (both nullable Boolean, no backfill). `is_enrolled` / `is_enforced` are NOT NULL, so a provider that cannot answer had to write `False` — indistinguishable from an observed non-compliance. All 528 prod Atlassian rows carry `policy_status='policy_absent'` (Atlassian exposes no per-user 2FA attribute; the org authentication policy is unreadable), and six surfaces consumed that unknown as failure: the `security_compliance.mfa_enforcement_coverage` health metric (0.0% → 30/100 at a claimed 100% confidence), the findings worklist (412 unactionable items), `auditor_risk_scores` (`mfa_missing_provider` on 528 of 570 people), the `mfa_coverage` ISMS control (`fail — 0/412 enrolled`), the insights headline, and the ISMS assessment evidence table. Written only by `MfaSyncService` — google ✓/✓, atlassian ✓/✓ only when `policy_status != policy_absent`, feishu ✓ only when `security_strategy` came back / ✗, github ✓/✗. **Deliberately no backfill**: every consumer reads NULL as "not observed", which drops the row out of coverage denominators instead of turning it into a false accusation, and the next daily sync restates each row with what the upstream API actually said. Nullable-add is TiDB-safe (no default rewrite, no generated-column dependency, no FK). |
| 518 | **`assistant_messages.sources` — keep an answer's citations with the answer.** Adds one nullable JSON column, no backfill. The answer card renders the mirrored Feishu docs the reply cited, and the employee taps 👍/👎 on that card — but Feishu REPLACES the whole card with whatever the callback returns, and the replacement was rebuilt from `content` alone, so rating an answer silently deleted the 参考文档 links it was approving. The links were never stored: they lived only in the turn that sent the card. JSON rather than a child table because it is a short, ordered, read-together list (≤3 `{title,url}` pairs — `answer_card.MAX_DOC_LINKS`) that nothing queries by. No backfill on purpose: rows written before this column genuinely have no recorded sources, and an empty array would claim we know they had none. Written by `conversation._record` (note it runs BEFORE `turn.docs` is populated — `sources` must be passed in, not read off the turn), read by `card_actions._record_verdict` → `answer_card.rated_card`. Nullable-add is TiDB-safe. |
| 571 | **通知卡片记下自己在飞书里的 `message_id`.** One nullable `VARCHAR(64)` on `notification_outbox` plus its index. Exists because the platform pushes ~3,785 cards a month through the SAME Feishu app the assistant listens on, into the SAME 1:1 chat — so a reply typed under a card has always reached the assistant, and arrived context-free only because nothing recorded what was sent. `_send_feishu` discarded the API response; `IncomingMessage` never parsed `parent_id`. Both halves existed; this column is the middle one. Indexed because the inbound path resolves a reply's parent by it, once per reply, against a table growing ~3,800 rows a month. Distinct from `provider_delivery_keys` in both direction and use: those are idempotency keys WE mint before the call and cannot be indexed; this is the id the provider hands BACK. Add-column + index, `information_schema` guarded on `DATABASE()`; the downgrade drops both. |
| 572 | **助手提交的东西,到底变成了哪一行.** New `assistant_object_links` (polymorphic `resource_type` + `resource_id`, **no FK to the target** — it spans nine tables across as many domains), plus four columns on `assistant_action_proposals`: `attempt_count` / `last_attempted_at` / `last_error_code` / `link_miss_reason`. The link also records `source_tool` — `action_key` is not always the tool that ran (a curated action may delegate, e.g. buyback runs `submit_approval_request`). Exists because `status = executed` only ever meant *the call returned* — not approved, not provisioned, not applied by the provider, which are the only things the employee was asking about. `phase` on the link is a funnel **cache**, never the answer given to an employee: the live state is always re-read from the owning domain. The proposal columns exist because a failed execution is rolled back to `pending` on purpose (the card stays pressable) and that made pressed-and-failed byte-identical to never-pressed — production held 14 proposals reading as unexecuted of which **two had been pressed**. Create-table + add-column, `information_schema` guarded on `DATABASE()`; the downgrade drops the table and all four columns. |
| 573 | **助手提交结果对账 cron.** Seeds one `scheduled_jobs` row (`assistant.submission_reconcile`, every 30 min). No schema change. Exists because every other part of this feature answers on demand, which left a request whose approval landed last week sitting `active` until the employee happened to come back — the funnel then reads as "nobody's requests ever complete" and the abandonment sweep eventually files it as `abandoned`, which is the platform recording its own success as a disappearance. The sweep sends NOTHING (domains already notify on their own terminal states) and never closes on a failure. Guarded on the table's existence; the downgrade deletes the row by name. |
| 574 | **知识缺口的关闭证据.** Seven columns on `assistant_message_feedback` (`fix_type`, `evidence_ref`, `verification_passed/at/before/after`, `in_regression_set`) + one index. Exists because `resolve` required only a non-empty note — written by whoever did the work, never checked, and the queue emptied whether or not the question could now be answered. `verification_passed` is nullable rather than defaulting to false: "not replayed" and "replayed and failed" are different states and only one is somebody's fault. `verification_before/after` store the SHAPE of the turn (tools, sources, outcome), never the reply — the reply is the asker's own data and a triage queue must not become a second copy of it. Add-column, `information_schema` guarded on `DATABASE()`; the downgrade drops all seven and the index. |
| 575 | **账号终态保障的两张表 + 两个 cron.** `offboarding_account_assurance` (one row per person × identity system — `vendor_state`, `helix_action`, `verdict`, `exempt_reason`, `evidence_at`, `at_risk_since`; unique on `(person_id, system)` so the sweeps upsert idempotently) and `identity_chain_heartbeats` (`chain_key` unique, `last_beat_at` = the VENDOR's newest beat, not "when Helix last looked", plus `expected_period_minutes`). Seeds `jira.idp_sync_heartbeat` (hourly, UTC) and `google.leaver_account_audit` (03:20 UTC — deliberately ahead of `jira.leaver_account_audit` at 03:30 so both systems' evidence on the page comes from the same morning). Exists because Helix could prove it revoked access but not that anybody was actually locked out, and the step that produces the lockout — the org-side IdP directory sync — is one Helix neither owns nor observed; it was off for 16 days in August 2026 while every daily reconcile reported clean. Both tables guarded on `inspect().has_table`, cron seeds on `NOT EXISTS(handler_key)`; the downgrade deletes the two jobs by handler_key and drops both tables. |
| 576 | **催办节奏的计数表.** `notification_nag_state` (`recipient_person_id` × `template_key` × `subject_key` unique, `first_sent_at` / `last_sent_at` / `sent_count`). Backs `app/notifications/cadence.py`. **Deliberately not derived from `notification_outbox`**: that is a delivery log with its own retention and several rows per logical reminder (retries, per-channel splits), so counting it would make the cadence drift with delivery mechanics instead of with how often a human was actually asked. `subject_key` is the THING (an inventory-cycle id, a confirmation id), so two open asks taper independently — slowing one must never mute the other. Rows are DELETED when the ask settles, never archived: this is a counter, and a stale counter is worse than none (it starts the next unrelated ask on the weekly rung). Guarded on `inspect().has_table`; downgrade drops both indexes and the table. |
| 579 | **GitHub ID 自助修改 + 一道防回滚闸.** `github_id_change_requests`(`person_id` / `old_github_id` / `new_github_id` / `status` / `approver_person_id` / `decided_by_person_id` / `github_exists` / `hris_write_status`)加 `identity_persons.github_id_locked_at`。生成列 `open_request_key`(仅 `pending` 时非 NULL = `person_id`)+ UNIQUE = 「一个人同时只有一张在途单」,终态行不占坑所以被驳回后可以重提(MySQL/TiDB 无部分索引,`email_group_join_requests` 迁移 397 同款手法)。**`github_id_locked_at` 才是这张迁移的重点**:HR 同步对 `github_id` 是 write-when-present,所以「Helix 改了、SF 还没落地」这个中间态会让下一次同步把员工刚改的值**静默改回去**;非 NULL = 员工自助改的、Helix 当权威,SF 报回同样的值时自动清掉(收敛是观察到的,不是假设的)。放在人员行上是为了让 hr 域不必 import github_org 域,并且不给全量同步的每个人加一次查询。`approver_person_id` 与 `decided_by_person_id` 分开:前者是提交时解析下来的责任人(人员主数据会变,三个月前的单子还要能答「当时该谁批」),后者是真的点了按钮的人(代批允许)。**不建 handle 唯一索引** —— 存量是否已有重复未知,而一条建不上的唯一索引会让迁移在生产上直接失败;拦截放在提交与审批两处入口。`information_schema` 守卫在 `DATABASE()` 上;downgrade 先删列再删表。 |
| 580 | **飞书建号权限预检 cron.** 播种 `identity_sync.feishu_write_scope_preflight`(每天 07:30 Asia/Shanghai)。存在性守卫在 `scheduled_jobs` 表 + 同名任务上,downgrade 按 name 删。**这条迁移不改任何 schema** —— 它存在的唯一理由是:#3084 换了建号的写入面(Contact v3 → Directory v1)却没开新 scope,而**权限只在真正写的那一刻才被检验**,接下来 12 天恰好没有新人需要建号,于是建号面死了 12 天、每天的同步全绿。探针是对**故意非法的 `employee_id`**发 PATCH:改不动数据,且该端点**先查权限再校验 id**(生产实测:`99991672` = 没权限,`99992360` = 已过闸)。缺权限时 handler `raise`,复用既有定时任务失败通知 page IT。 |
| 582 | **HR 指定的邮箱名进黄金记录.** `identity_persons.hris_email_name` VARCHAR(190) NULL + 播种 `hr.email_name_reconcile`(每天 08:20 Asia/Shanghai)。存在的理由:邮箱名(SF `PerPersonal.customString4`)此前**只活在事件载荷上** —— `_stage_event` 塞进 `person_data['email_name']`,dispatch 闸读一次就丢,而那道闸只认 `action in {create, reactivate}`。P10026(2026-08-31)HR 在 create 事件两天后才填,正确值此后每 4 小时被读到一次、共 40 多次,**一次都没生效**,Day-1 建号用的还是 11 天前派生的 `nicholas.muniz`。落成列之后 `_upsert_person` 每次 apply 都写(**含 onboard `skip`** —— 它走 `_apply_event` 的 refresh-only 通道,正是那些被丢掉的 tick),`account_provisioner` 在**建号那一刻**优先读它。**apply-only**:非空才覆盖,HR 清空不回删 —— 空 =「没有指令」,不是「改用拼音」。VARCHAR(190) 而非 255:这是 local-part,而且要留在 utf8mb4 索引友好的宽度内(本列未建索引,留余量)。cron 是兜底网,不依赖建号路径正确。`information_schema` 守卫在 `DATABASE()` 上,cron 按 `handler_key` NOT EXISTS;downgrade 删 cron 行 + 删列。 |
| 583 | **GitHub 组织:坐席快照 + 治理摘要 cron + 成员画像与「确认是外部人员」.** `github_orgs` 加 `seat_plan/seat_filled/seat_total/seat_checked_at`;`github_org_memberships` 加 `github_profile` JSON + `profile_fetched_at` + `external_confirmed_at/by/note`,并加组合索引 `ix_github_org_memberships_link_triage`(`person_id, external_confirmed_at, state` —— 治理面每次都问「还没认的有多少」);播种 cron `github_org.governance_digest`(周一 09:30 Asia/Shanghai,`{"seat_warn_threshold": 10}`)。**`external_confirmed_at` 是让「认不出是谁」能收敛的那一列** —— 导入后 331 条认不出,抽样画像发现相当一部分 company 写着别家公司/基金会/高校,永远认不出来;没有这个终态,那个待办数永远非空,而永远非空的待办数等于没有。**原本编号 582,与同日先落 main 的 `582_hris_email_name_golden_record` 撞号,改为 583** —— 迁移号要对着 `origin/main` 抢,不是对着自己的 worktree。 |
| 587 | **合作方通讯录:名册镜像 + 打标结果 + 每日 cron.** 新建 `partner_directory_members`(自然键 `(integration_id, external_id)` —— **不是邮箱**:对方把离职者的邮箱给新人时,按邮箱做键会让新人静默继承前任的全部标签)与 `partner_directory_links`(判断 + 依据 + 待确认队列),播种 `partner_directory.sync`(每天 06:40 Asia/Shanghai)。**`members.email_name` 单独一列并建索引**是这次迁移的核心:对方员工在自己租户里是 `@pingcap.cn`,在我们这边是 `@pingcap.com`,生产实测 `google_orphan_accounts` 里 `@pingcap.cn` 是 **0 行** —— 按完整邮箱串匹配,Gmail 那 32 个人一个都对不上;匹配只能落在邮箱名上,而现算 `SUBSTRING_INDEX` 会让每天一次的匹配变成两张表的全表扫描。链接表单独存在的理由是**结论留不住依据**:标签最终写在 `external_account_registry` 和 `github_org_memberships` 上,那两处答不了「凭什么说这个 Google 账号是 CBG 的」。 |
| 585 | **GitHub 组织:机器账号与外部人分成两类.** `github_org_memberships.external_kind` VARCHAR(16) NULL(`bot` / `outside_human`,只在 `external_confirmed_at` 非空时有意义)+ 播种 `platform_settings.github_org.bot_handle_patterns`。**存在的理由:583 那一条「已确认外部」把别家公司的人和机器账号塞进了同一格**,而两者要问的问题完全不同(前者「他还需要访问权吗」,后者「这个自动化还在用吗、token 谁管」)。**为什么是策略而不是自动判定:GitHub 的 `type` 字段在本租户全部报 `User`** —— 7 个机器账号逐个实测,`Bot` 类型只给 GitHub App 身份,这些是人建的普通账号当机器人用。因此只能靠命名约定,而命名约定会误伤真人(`robot-liu` 命中 `*robot*`),所以写入永远经过人确认、没有自动 cron。刻意不加 CHECK 约束(TiDB 支持反复),不变量由 service 层守(`confirm_external` 是唯一写入口,不认的值落 NULL 而非原样存)。**原本编号 584,与同日先落 main 的 `584_cost_facts_raw_retention` 撞号,改为 585** —— 本会话第二次撞号,教训是「抢号」之外还要「合并前再核一次」。 |
| 589 | **Seed the `cmdb.decommission_reconcile` cron (no DDL).** `decommission_scope` writes `status='closed'` when the provider *accepts* the closure, but AWS/GCP/Azure/Aliyun all finish asynchronously over weeks — so a closure that later **fails** (Aliyun `CheckFailed`/`DeleteFailed`, no retry) or is **undone** by a human (`projects.undelete`, Azure re-activation — both self-service for the whole grace window) left Helix asserting a closure the cloud does not agree with, with every grant on the scope already disabled and every sweep skipping closed scopes. The cron re-reads the provider daily (04:20 Asia/Shanghai) and writes back; it never reopens a scope and never turns a failed read into a confirmed closure. Creates one `scheduled_jobs` row (`config.limit=200`, the per-run coverage cap — the only knob worth turning without a deploy). Idempotent insert-if-absent; downgrade deletes the row. **0 DDL / 0 RBAC / 0 MCP-tool-count.** |
| 590 | **`github_org_membership_events.trace_id` 加宽到 128.** `github_org.invitation_reconcile` 每次真有邀请要推进时都失败,报的是 `PendingRollbackError` —— 但那只是残骸:cron 的 trace_id 是 `f"github_org.invitation_reconcile:{uuid4()}"`,**68 个字符**,而这一列是 VARCHAR(64),事件行的 INSERT 被 1406 顶回来,那次失败的 flush 毒化了 session,之后每一次 execute 都翻成 PendingRollbackError。加宽而不是把 key 起短一点:同域的 `github_org.member_reconcile` 算出来正好 64,差一个字符就是同一个故障,而「以后谁都不许把 cron key 改长」不是一条能被执行的约束(同类列在 data_access 里本来就是 128)。无生成列引用、无索引挂在这一列上,所以 TiDB 不会以 3106/3108 拒绝这次 MODIFY;重跑先查 `CHARACTER_MAXIMUM_LENGTH` 再决定是否 ALTER。**无需回填** —— 超长的 trace_id 从来就没落进去过(落不进去正是这个 bug)。 |
| 588 | **播种 `employer:cbg`,并把标签规则形状对齐到「可启用」.** CBG(平凯星辰)从 PingCAP 分出去,痕迹留在数据里:名册命中的 41 个 handle 里 **12 个的 GitHub `company` 写着 PingCAP**(`crazycs520`/`iamxy`/`winkyao`…)—— 按资料字符串打标签会把这 12 个外部人标成「我们自己的人」,而一个被标成自己人的外部账号,没人会去看它还留着哪些私有仓库权限。依据的正确顺序是「对方 HR 名册 > GitHub 资料」,`employer:cbg` 把这个顺序在数据里表达出来。该标签在生产上是通过词表维护入口现场建的,这里补进播种让新库一致(否则指向它的规则以 `rule points at an unknown label` 失败)。另把存量 9 条规则显式补 `enabled: false` / `source: manual` —— 只有一半行带某个键的 JSON 列是必踩的坑。downgrade 只删标签本身,**不删**链接行:降级不该销毁「当年被标成什么」。 |
| 586 | **GitHub handle 标签体系(挂 handle,不挂成员资格).** 两张新表:`github_handle_labels`(词表,自然键 `key` 如 `employer:bytedance`,四个分类 `account`/`employer`/`community`/`purpose`,播种 11 条)与 `github_handle_label_links`(handle ↔ 标签,主键 `(github_login_lower, label_key)` ⇒ 重复打标签幂等)+ 播种 `platform_settings.github_org.label_rules`。**理由:585 的两值枚举互斥、自由备注必烂**,而更关键的是它把分类挂在了成员资格上 —— 实测 217 个认不出的 handle 摊在 331 行上,`sre-bot`/`ti-chi-bot`/`crazycs520` 各在 5 个组织里,挂成员资格意味着标一次写 5 行、5 行会漂移。趁 `external_confirmed_at` 全为 NULL(改模型免费)改成 handle 级。`link.label_key` **刻意不建 FK**:词表用「停用」而非删除,FK 会把「停用」和「能否保留历史」绑死;悬空引用由服务层显示成「已停用」而不是消失。585 的 `external_kind` 保留但降级为**派生** —— 打 `account:*` 时 service 把该 handle 所有活成员资格一起确认。downgrade 删两表 + 删设置行。 |
| 591 | **资产处置凭证 `asset_items.disposal_photos`(JSON,可空).** 存管理员在「处置」时上传的 S3 object key 列表(回收单 / 二手买家付款截图 / 租赁归还回执 / 硬盘销毁照片),与 `asset_inventory_items.evidence_photos` 同构 —— 浏览器经 `/uploads/presign-put` 直传,库里只存 key,读的时候现签 presigned GET。**处置往往是一台设备一生中唯一产生纸面凭证的时刻**,在这之前那些凭证只能塞进备注的自由文本。JSON 列而非 TEXT,让 TiDB 在写入时就校验数组形状。key 同时被复制进不可变的 `AssetOperation.detail`(资产行是软删清理候选,operation 行才是审计正本)。**无需回填** —— 历史处置本来就没有凭证,NULL 就是「没传过」的真值。存在性守卫经 INFORMATION_SCHEMA,重跑无害。**0 cron / 0 RBAC / 0 MCP-tool-count**(处置没有 MCP 工具面)。 |
| 596 | **安全宣贯:素材库 + 曝光台账(两张新表).** `secaw_materials` —— 托管素材:`kind` ∈ comic(多图按顺序读)/ article(markdown)/ link(内容在飞书文档,Helix 只承载卡片与曝光),`category` 闭集(8 个,自由文本一年后会有四十种「钓鱼」的写法,覆盖率报表就再也答不出「我们做了多少钓鱼宣贯」),`localized` **一个 JSON 列**装三语内容 —— 三语 ×(标题/摘要/正文/图片列表/外链/封面)= 18 个绝大多数时候是 NULL 的列,而它们唯一的关系(「这 6 个属于同一种语言」)schema 本来也表达不出来;形状在 `schemas.py::LocalizedContent` 强制校验,`zh` 必填(`app.core.locale` 的回退链终点在它)。图片列存 **S3 object key 不存 URL**(URL 要么在列里过期、要么更糟 —— 是一条永久公开链接),每次读现签 10 分钟 presigned GET。`version` 内容变更时 +1、置顶/排序/上下架**不动**它 —— 宣贯活动记下发出时的版本号,「我确认的时候文章写的不是这个」要有答案。`secaw_impressions` —— **只追加**的曝光台账:person × material × `surface`(detail / bubble / campaign_card)× 时间,并**拷贝写入时的 `material_version` 与实际渲染的 `lang`**(素材之后还会被编辑,而争议恰恰是「他当时看到的是哪一版/哪种语言」);service 层在 30 分钟窗口内去重(环境位每次路由变化都重渲染,不去重的话这张表会无界增长而「展示了 400 次」也不再有含义)。`material_id` 上**刻意没有 ondelete** —— 素材只归档,真删只在「一次都没展示过」时允许(service 见到任何一条 impression 就 409);级联会把误点删除按钮变成覆盖率证据的销毁。`person_id` 上是 CASCADE(硬删 person 只发生在数据纠正,那时他的曝光记录本来就是要清掉的个人数据)。三个索引:`(person_id, material_id, occurred_at)` 去重探针 + 逐人「看过没有」、`(material_id, occurred_at)` 逐素材触达汇总、`(person_id, occurred_at)` 留给 PR3 的环境位频控(「这个人今天已经被展示了多少」),先建好免得那个闸上线时还要再动一次 schema。**0 cron / 0 新 RBAC 角色**(读 `can_view_security` / 写 `can_manage_security`)/ **+2 MCP → hint 588**。 |
| 597 | **安全宣贯:活动 + 逐人台账(两张新表 + 一列 + 一个 cron).** `secaw_campaigns` —— 一次宣贯 = 素材 × 受众 × 渠道 × 时机 × 强度。`mandatory` 一个布尔列分两档:必修进 `/my/todos`、有 `due_at`、可催办、算覆盖率、走 `CAT_SECURITY`(**员工关不掉**);运营是纯触达,走**新增的** `CAT_AWARENESS`(可关、INFO 桶、不驱动红点)。不分这两档,每一份安全漫画都会变成关不掉的推送 —— 而那正是一个渠道被静音的方式。`audience_config` 是 `app.core.person_filters.FilterConfig` 的形状(**复用**平台既有的人群引擎:部门+子树 / 国家 / 法人实体 / 在职状态 / 职位 / 邮箱后缀 / person_ids 白名单 / and-or / P99XX 硬排除)+ 本域的 `exclude_person_ids` 叠加层(共享引擎没有排除维度,而它同时是拉取面的授权边界,不为一个内容功能放宽)。`status` **本身就是发送锁**:置 `sending` 是一条带状态条件的 UPDATE,第二个 cron tick 更新到 0 行、直接返回。`secaw_campaign_targets` —— 逐人台账,`(campaign_id, person_id)` 唯一(催办只补欠账,重发变成 UPDATE 而不是第二行,否则「发给了多少人」随催办次数膨胀);`material_version` / `lang` / `person_snapshot` 是**写入时快照**(素材会被编辑、人会换部门,按部门看覆盖率只有快照口径说得通)。🔴 **刻意没有 `delivered_at` 列** —— 投递真值只有一个来源,`notification_outbox` 的那一行(它自己带重试阶梯、`dead` 终态、离职闸),镜像一列就是造第二个真值。读活动详情时按 `(related_resource_type='secaw_campaign', related_resource_id, recipient_person_id)` join 回 outbox,这个形状在 `ix_notification_outbox_related_resource`(迁移 509)上有索引;注意 `related_resource_id` 是裸 `CHAR(36)` **不是 GUID 类型**,比较必须 `str()`(不 str 就永远 0 行而看起来正常)。`secaw_impressions` 补一列可空 `campaign_id`(596 留了 `campaign_card` 这个 surface 却没有列说是哪一次活动;历史行与非活动曝光是 NULL,「不归属任何活动」是真答案不是缺口)。播种 cron `security_awareness.campaign_dispatch`(每 10 分钟,Asia/Shanghai):发到点的活动,一个失败不拦住其它,失败原因写回 `last_dispatch_error`,**并且只有还停在 `sending` 的**(= 台账尚未落库的)才放回 `scheduled` 等下一个 tick 重试 —— 已经 `sent` 的放回去会让下一跳重走全体受众、每人再发一份(2026-09-07 收紧,详见上文「发送是请求内上膛 + 请求外投递」一节),**全部失败**才 raise(handler 返回软失败 dict 会让 `runs.status='ok'`,那样一个一周发不出去的调度器看起来是健康的)。**downgrade 要先按 information_schema 查出自动生成的 FK 约束名再 drop 列** —— MySQL 1828 不让删被 FK 指着的列,而那次半途失败已经把 cron 行删掉了(实测 upgrade → downgrade → upgrade 一整圈才发现)。**+1 cron / 0 新 RBAC 角色**(读 `can_view_security` / 写 `can_manage_security`;顺带新增窄门 `require_person_filter_lookup` 让 `security_officer` 能加载共享筛选器的四个 lookup)/ **+2 MCP → hint 590**。 |
| 598 | **安全宣贯:环境位频控 + 安全提示补日文.** 三个键带描述播种进 `platform_settings`:`security_awareness.bubble.enabled`(true)/ `.daily_new_materials`(**1** —— 一个人一天最多**遇到几个新素材**;环境位靠重复一个想法起作用,不是靠量)/ `.max_repeats_per_material`(**3** —— 之后它要么已经起作用了要么不会,第四次只会教会这个人忽略气泡)。**播种而不是只留代码默认值**:一个只存在于代码里的阈值,运维时改不动;而描述本身就是这几个旋钮的运维文档,所以管理端的写入路径传 `description=None` 以免把它们抹掉。读取端 `ambient.load_settings` 对坏值一律退回默认并记 warning —— 气泡挂在**每一个已登录页面**上,而通用设置页也能写同一个键,一个畸形设置不能把应用外壳搞崩。`platform_security_tips` 补 **`text_ja`**(VARCHAR(500) NULL):这张表是 2 语的而平台说三种语言,气泡没素材可展示时回退到这些提示,于是日语读者一直拿到英文 —— 不算错,但**这张表根本没有一列能存日文**。**刻意不回填**:NULL 是「还没翻译」的真值,渲染按 `ja → en → zh` 回退;把英文复制进去会把「没翻译」伪装成「翻译过了」,之后再也分不出来。**0 cron / 0 RBAC / 0 MCP-tool-count**。 |
| 599 | **安全宣贯:催办 cron(无 DDL).** 只播种 `security_awareness.campaign_reminders`(每天 10:00 Asia/Shanghai —— 上午而不是清早:一条在通勤路上到达的催办,被读到时人已经在做别的事了)。**节奏不在这个迁移里** —— `app.notifications.cadence` 已经是平台的催办阶梯原语,本域在 `reminders.install_ladder()` 里给自己的两个 template_key 注册**更长**的阶梯(催办 7/7/14 天,尾部每 14 天;主管摘要 14 天,尾部每 28 天),而不是自己数天数。平台默认是 1/1/1/2/3/5 然后每周:那个默认对「你的笔记本下落不明」是对的,对「请读一份漫画」是错的 —— 对必修阅读天天催,正是让那条**关不掉的**安全类别被整个静音的方式,之后连真正不能静音的流程也没人读了。**阶梯只能拉长不能收紧**,有测试逐级比较我们的和平台默认。第一次催办在发出后 7 天;三周后给主管发**一次摘要**(一张卡列全部下属,不是一人一张 —— 把九十个别人的待办丢给五个管理员「produced a chase queue and moved no decisions」)。每 tick 有条数上限、最久未处理优先,**被压下的条数写进摘要**;员工一确认立刻 `cadence.clear` 销账(否则他下一次同类被催会继承计数,第一次就落进两周档);**已 `closed` 的活动不催** —— 结束这个动作本身就是停止追问。同 PR 还在 `compliance_catalog` 里加了控制项 `security_awareness_training`(`signal_type='awareness_campaign'`,像 `access_certification` 一样从活动状态派生,无 job 喂它)以及**两条此前目录里没有的框架要求** SOC2 `CC2.2` / ISO27001 `A.6.3` —— 标准里一直有,只是 Helix 拿不出证据,所以它们此前诚实地缺席。目录是代码,`sync_compliance_catalog` 每次对账都会写进去,**不需要 DDL**。 |
| 603 | **访问撤销落地(访问对象闭环 PR1).** `sso_grants` 加 `removal_*` 11 列(`removal_status` 默认 `not_applicable`,历史已撤销行回填为 `unverified_legacy` 而不是 `pending`)+ FK 到 `provisioning_tasks` / `identity_persons` + 索引 `(removal_status, revoked_at)`;新表 `apikey_idle_alerts_dedup`(`assignment_id × billing_month` 唯一,记连续月数与是否已提醒持有人);播种 cron `sso.removal_reconcile`(每日 08:40)与 `apikey.idle_check`(每月 3 日 09:30);播种四个平台设置 `apikey.rotate_on_transfer`(true)/ `apikey.transfer_rotation_grace_days`(7)/ `apikey.idle_alert`(员工投递默认关)/ `sso.removal`(阶梯 3/7/14、7 天核实、14 天开事件)。每一步独立存在性守卫,可在半应用状态重跑。 |
| 611 | **转岗复核差异引擎:每条持有物记下「这次移动对它说了什么」.** `hr_role_change_review_items` 加 `delta_kind` / `delta_evidence` + 索引 `(review_id, delta_kind)`;`hr_role_change_reviews` 加 `delta_summary` / `delta_computed_at`。四列全可空,旧代码不写照样 INSERT。🔴 **刻意不回填** —— 每条既有 item 都要拿今天的策略表与今天的组织树重新分类,而七月那次移动的答案不是今天的答案;留 NULL 才是诚实的状态(页面读 NULL 为「还没算过」并说出来),给 223 条历史行写上一个自信的 `unknown` 会看起来像一个判定。 |
| 610 | **转岗复核:停两条推送 + 给积压盖 withheld 戳 + 把「没有基线就不要推送」变成列上的不变量.** `hr_role_change_reviews` 加四列:`notify_suppressed_at` / `notify_suppressed_reason`(可空)与 `baseline_source` / `delta_items`(NOT NULL,server_default `'none'` / `0`)。把现存所有 `notified_at IS NULL` 的包裹标成 withheld —— **不包括**只标 `status='open'` 的那一部分,一份 superseded 却从未发出的包裹同样不能因为状态流转而复活。`scheduled_jobs` 里 `role_change.review_notify` / `role_change.scope_review_sweep` 置 `enabled=0`(只 UPDATE 不 INSERT:行不存在意味着这套部署从没跑过 236 / 568,插一行禁用状态是在编造状态)。播种 `platform_settings['role_change.review']`(`employee_push_enabled=False` / `require_baseline_for_push=True` / `push_baseline_sources=['policy','provenance']`)。**delta 两列刻意放在这一轮而不是差异引擎那一轮**:发送路径问 `may_push(baseline_source, delta_items)`,既有行全是 `('none',0)` ⇒ 引擎落地前任何包裹都推不出去;推迟两个 PR 就留下一个「翻一个 bool 就能重演 9/5」的窗口。**downgrade 不把 cron 重新打开** —— schema 回滚不等于基线问题解决了,一次悄悄恢复对经理推送的回滚是最糟的意外。 |
| 607 | **宣贯素材机器翻译:`secaw_materials.mt_langs` + 每小时补齐 cron.** 起因是一条员工反馈:**英文界面右下角的安全学习气泡是中文的**。语言链路没坏 —— 前端传 i18next 当前语言、`pick_block` 按 `LANG_FALLBACK` 解析并回报 `is_fallback_lang`;缺的是**内容**:2026-09-05 线上 46 条已发布素材里 30 条漫画只有 `zh`、16 条文章没有 `ja`。加一列 `mt_langs JSON NULL`(哪几种语言的 block 是模型写的、还没人校对)。🔴 **为什么是列而不是塞进 `localized` 的 block**:写入路径 `MaterialWrite.normalized_localized()` 是白名单,多余的键会被管理员下一次保存静默丢掉 —— 包括只改 `kind` 的那种保存(它把三种语言整个过一遍同一个校验器),标记会因为跟「有没有人校对」完全无关的原因消失。列上的它只被两件事清掉:某语言的 block 真的变了(`service.update` **逐语言比对**,所以改分类不会连坐清掉别的语言),或显式「标为已校对」。播种 cron `security_awareness.translation_backfill`(每小时第 17 分,`{"limit": 10}` —— 按天跑会让一条今天发布的中文素材在英文界面上挂一整天)。**无回填**:`mt_langs` 为 NULL 的历史行含义正确,现有的每个翻译 block 都是人写的。 |
| 606 | **数据访问授权进入生命周期 + 权限目录站内审批(访问对象闭环 PR2).** `data_access_bindings` 加 `expires_at / expiry_reminder_stage / expiry_last_reminded_at / renewal_count / last_renewed_at / last_renewed_by_person_id / owner_confirmation_due_at / owner_confirmed_at / owner_confirmed_by_person_id / owner_confirmation_reminder_count / owner_confirmation_last_reminded_at` 11 列 + 索引 `(status, expires_at)` / `(status, owner_confirmation_due_at)`,回填 active 个人授权 `expires_at = GREATEST(granted_at+180d, now+30d)`、服务账号 `owner_confirmation_due_at = GREATEST(granted_at+182d, now+30d)`(至少 30 天缓冲);新表 `data_access_lifecycle_events`(binding_id / event_kind / actor / detail JSON);`user_permissions` 加 `expires_at / approver_person_id / decided_at / decision_note / approval_reminder_count / approval_last_reminded_at` + 索引 `(status, expires_at)`;播种 cron `data_access.grant_lifecycle`(每日 09:30)与 `permission.lifecycle_sweep`(每日 09:50);播种平台设置 `data_access.grant_lifecycle`(四个开关默认关)/ `permission.approval`。每一步独立存在性守卫。 |
| 629 | **转人工路由把「云与网络」拆成「云」和「网络」两类(纯数据).** `cloud` 改名「云」+ 重写 hint,新插 `network`(sort 55,排在 cloud 50 与 security 60 之间),两条各自指派负责人(冯帅 / 孟凡生,按 `work_email` 解析,解析不到就留空 —— 那一类此刻只建工单,是 627 的正常状态)。**为什么是迁移**:后台只能改已有行,没有「新建类别」的入口(`category_key` 是分类台账里的稳定标识)。**不覆盖已指派的负责人**(`cloud` 的 owner 只在 IS NULL 时补;`network` 已存在就整行不动),**显示名和 hint 无条件改**(留着「云与网络」等于让模型继续把 WiFi 判给云)。🔴 两条 hint 都写「什么不属于我、该走哪一类」—— 相邻类别之间这句排除比各自的列举更能决定分类结果。降级:还原 cloud 的 627 原文;`network` 被 `assistant_handoffs.route_id` 引用时只停用不删(删行撞 1451)。存在性守卫,可重跑。 |
| 628 | **安全每日一题按员工语言出题:`secquiz_questions.localized JSON NULL`.** `{"en": {prompt, options, explanation}, "ja": {…}}`;**中文不在里面** —— 三个平面列继续当基线。与 `secaw_materials.localized`(三语都在 JSON 里)不同的两个原因:这一列加在已有 309 行上,搬一次等于重写每一行 + 每一个读者(排行榜/擂台/素材小测/MCP/装备墙);而 `correct_key` 与语言无关,必须紧挨着**唯一一份**权威选项表。🔴 **选项 key 是契约** —— key 集合与基线不一致的 block 不是译文而是另一道题,读路径拒掉并回退基线选项,写路径 422,种子侧直接抛。**不回填**:`localized IS NULL` 的历史行含义正确(这道题目前只有中文),译文由 `scripts/load_security_content.py` 从仓库外的导出灌入,读取面缺语言时回报 `is_fallback_lang`。存在性守卫,可重跑;downgrade 删列。 |
| 624 | **在飞的下线(下线前置清理).** `cmdb_cloud_scopes` +8 列(`decommission_pending_since` / `_by` / `_reason`、`decommission_cleanup_keys` JSON、`_state` / `_attempts` / `_error` / `_trace_id`)+ 索引 `ix_cmdb_scopes_decommission_pending` + 播种 cron `cmdb.decommission_cleanup_resume`(`*/5`,**enabled**)。`decommission_cleanup_keys` 存的是**管理员在弹窗里看到并确认过的 blocker key**,清理永远只动这个清单 —— 确认之后新建的资源不在里面,所以不可能被静默删掉。**`status` 不加值**:`decommissioning` 会连坐每一处读 status 的谓词(申请权限 / 闲置巡检 / 成本归属 / 7 处前端),真正关心的查询读 `decommission_pending_since IS NOT NULL`。存在性守卫,可重跑;downgrade 删 cron 行 + 索引 + 8 列。 |
| 623 | **云角色「默认下拉」的开关活过下一个子账号(无新表).** `cmdb_cloud_roles` 加 `promotion_overridden_by_admin`(NOT NULL 默认 0)。`is_promoted` 存在 `(integration, scope, role_key)` 这一行上,而管理员在目录页做的决定是**租户级**的 —— promote-by-key 只能改点下去那一刻已存在的行,之后新发现的每个子账号都由 roles_sync / 单 scope 预热插入新行,新行取驱动默认值(所有云驱动对自定义角色一律 `is_promoted=True`)。生产实测:10 个 `DBaaS-Prod-*` CloudSSO 角色 2026-06-11 被 demote(审计 `rows_affected=21`),在 6-13 至 8-24 新接入的 8 个子账号上全是开着的,而目录页按跨 scope 一致值上报、显示成「否」—— 管理员看不见也点不掉(开关已在「否」位,再点是开)。**回填只做能证明的那一半**:`is_custom=1 AND is_promoted=0` 一定是管理员关的(七个驱动 + `重置为默认` 都不可能产出这个组合),命中 12 个 alicloud_intl + 66 个 azure + 11 个 gcp 自定义角色键;`is_custom=0` 的内置角色不回填 —— 「部分 scope 关着」在数据上和驱动默认值分不开,方向是管理员的判断而不是猜测(生产上 31 个 AWS permission set 是 32/100 的混合态,交给目录页新加的「不一致」筛选 + 「统一为否」)。存在性守卫,可重跑。 |
| 621 | **自证的 AI 建议 + 「没人回答」的后果(访问对象闭环 PR5).** 新表 `auditor_self_attestation_advice`(`certification_id` × `person_id` 唯一,FK 到 `access_certifications` ON DELETE CASCADE)+ 两个平台设置(`ai_advice_enabled` 默认 false、`no_response_policy` 默认 `report_only`)+ cron `auditor.self_attestation_advice`(每日 01:30 UTC)。**用 `id_type_for` / `table_kw_for`**,不自己读类型也不硬编码 —— 首版自己读 `column_type` 拼裸 SQL,而 `column_type` 不带 collation,在沿真实链建起来的库上同样 3780(守卫因此补了第 (1d) 类)。存在性守卫,可重跑。 |
| 617 | **云 IAM 三件套按账号串链 + 三对具名边(数据,无 DDL).** scopes → roles → grants 共用同一把 `cloud_sync:{integration}` 锁却各跑各的 cron,`cmdb.scopes.aws-primary` 因此 31 天里有 29 天没跑;串链让争锁在结构上不可能。按 cron **形状**守卫(13 账号 × 2 边,逐行写死不现实),上游不存在就不连。三对具名边其一是在**修 bug**:`usage_signal_refresh` 自称排在 `idle_reclaim` 之前,而 02:40 在 02:00 之后,消费者一直在用晚一天的信号。**账单路径一条边都不连**(会让下游更早开始,而它们拉厂商前一天的批次),有测试对着边列表断言。降级还原成同小时、名字派生的分钟。 |
| 616 | **播种链推进对账 cron + 转换第一批六条顺序边(数据,无 DDL).** 入选门槛:下游本来的排程必须已经落在上游的**同一天同一窗口**内,转换才只改变「窗口内什么时候跑」。故意排除三对 —— evidence_precompile(2 号)→ posture_summary(**3 号**)会把月报提前一天(产品变更,不是基础设施变更);monthclose(5/12 号)→ cost_recon(14 号)的两天间隔正是排程的用意;efficiency_owner_digest 是**周报**而它消费的 compute 是日跑,接上去会静默变成日跑 = 七倍通知。每条边只在下游仍带着代码播种的 cron 时应用,downgrade 精确还原。 |
| 615 | **`scheduled_jobs.chain_after_job_id`(可空自引用 FK + 索引).** 无数据变更。`ON DELETE SET NULL` 而非 CASCADE:删上游让下游变孤儿并在体检里现形,而不是把它一起删掉。downgrade 先把残留的 chained 行停成禁用 cron(没有这一列它不可调度,留着启用就是一行永远不触发的任务),并**按列**而不是按名字删外键 —— ORM `create_all` 建的库上外键是自动命名的,只找自己那个名字会让索引删不掉(这个 downgrade 的测试第一次就是这么红的)。 |
| 614 | **把「被部署打断」从 failed 改判成 interrupted(数据,无 DDL).** 谓词只认 reaper 机器生成的那句前缀 —— 一条被人诊断过、写了真实原因的 failed 行永远进不了这个桶(测试连「文案里提到这句话」的行都钉住不动);原文案原样保留 + 追加标记,所以改判从行本身看得出来;两个计数器不动,因为创建这些行的 bulk UPDATE 从来没抬过它们。downgrade 只还原带标记的行,不碰上线后新代码正常写出的 interrupted。 |
| 613 | **定时任务节奏纠偏 + 台账体检 cron.** 五条节奏改写(`cmdb.resource_directory_refresh` 15 分钟 → 每小时:41,095 slot-秒/天 = 重量级 77% 的算力,而 94 次成功里只有 27 次有变化;`cmdb.resource_inventory_refresh` 每小时 → 每日,不停用,让它自己发现 opt-in;两个半年度开盘任务 → `1-7 6,12`,与 handler 的 `[boundary, boundary+7d)` 谓词同集合;`cmdb.auto_owner_grant_backfill` 600s → 3600s)+ 播种 `scheduler.ledger_reconcile`。**每条改写都带 `AND cron_expr = :expected` 守卫**,管理员调过的行不动;downgrade 用对称守卫,升级后又被改过的行同样不盖。无 DDL。 |
| 612 | **多厂商坐席使用信号(访问对象闭环 PR4).** 新表 `asset_seat_usage_signals`(`person_id` × `provider` × `product_key` 唯一,带 `state` / `last_active_at` / `evidence` / `collected_at`,三个索引)+ 播种 cron `software_seat.usage_signal_refresh`(每日 02:40 UTC,排在 idle_reclaim 之前)。无回填:信号由采集器产生,历史无从追溯。存在性守卫,可重跑。**2026-09-05 修正**:首版用 `sa.Uuid()`(MySQL 上是 CHAR(32))声明 `id` / `person_id`,对 CHAR(36) 的 `identity_persons.id` 触发 3780,把生产链卡死在 611;改用 `id_type_for` / `table_kw_for`,并给 TiDB-parity 守卫补了第 (1c) 类。 |
| 609 | **离职 T+1 安全网按 RES 注册表驱动 + 账号终态推广到名册(访问对象闭环 PR3).** 无新表、无新列。把 open/accepted 的 `leaver_orphan` 合规事件里四个私有 resource_type 别名(`cloud_domain` / `cloud_dns_record` / `cloud_cdn_domain` / `cloud_load_balancer`)改成注册表名字(`domain` / `dns_record` / `cdn_domain` / `load_balancer`),否则同一条残留会以新名字再开一条事件、旧的永远挂着;resolved 是历史不动。播种 cron `offboarding.account_assurance_rosters`(每日 04:10 UTC)。存在性守卫,可重跑。 |
| 600 | **安全宣贯:素材挂小测题 + 答题记录(两张新表).** `secaw_material_questions` 是**链接表**而不是在题目上加一列 `material_id`:一道题可以既是某份素材的配套题、也是每日一题题库的一员,一列会把它锁死在一个素材上;链接表还顺带给了顺序和「同一道题被几份素材复用」。判分复用既有 `secquiz_questions` 的 `options`/`correct_key` 形状(第二个题目模型 = 第二个编辑器 + 第二个判分器 + 两种「正确答案」的写法);答案归一化沿用安全每日一题域的 `_normalize_keys` 语义,因域间不互相 import 而在本域重述,并有测试拿同一批输入把两个实现钉在一起。`secaw_quiz_attempts` 记**每一次**尝试,包括失败的 —— 只留「最后通过了」会丢掉这个域最有价值的信号:哪几道题大量的人**第一次**答错,正是「这份材料这一段没讲清楚」的直接证据。🔴 **`attempt_no` 显式存一列,刻意不从 `created_at` 推**:那是秒级 DATETIME,而最有价值的那个序列(答错→看解释→马上重来)整个发生在一秒之内,按时间排分不出第一次和第二次,于是「第一次答错率」有约一半概率取到已经看过解释的那次,让每道题都显得很简单 —— **这条是被测试抓出来的真缺陷**。`passed` / `answers` 冗余存不现算:一次尝试的判定要用**当时**的正确答案,而题目之后会被编辑。答案永不出服务端(员工端接口不含 `correct_key`,提交后只对**答错**的题回传答案与解释 —— 对答对的题回传等于把一次错答在重试时变成完整答案卡)。同 PR 还在 gamification 加了 2 个事实(`awareness_materials_read` / `awareness_quizzes_passed`,前者只算 `detail` surface 的**去重人数** —— 奖励被动曝光等于奖励开着一个标签页)+ 5 枚徽章,以及 arcade 账本的新 reason `awareness_quiz`(经 `core/coordinators/awareness_points_coordinator`,每素材一次、金额刻意小、**钱包写入走 `best_effort` SAVEPOINT**:钱包抖一下不能让人丢掉刚挣到的合规记录 —— 本 PR 落地时是个裸 `try`,做不到这一条,2026-09-03 修)。**0 cron / 0 新 RBAC 角色 / 0 新 MCP**(小测的写工具**刻意不给**:替员工答题产出的是假证据 —— 理由写在 `_REGISTERED_TOOLS_COUNT_HINT` 上方)。 |
| 584 | **`cmdb_cost_facts.raw` 保留期 cron(无 DDL).** 只播种 `cmdb.cost_facts_raw_purge`(每天 03:35 Asia/Shanghai,`{"batch_size": 2000, "max_days_per_run": 30}`),**不含任何 `ALTER`** —— 清理是按天分批的 `UPDATE ... SET raw = NULL`,不删行、不动金额与维度列。背景:`cmdb_cost_facts` 是全库 11.19 GB 里的 10.5 GB(**94%**),`raw` 实测 311 字节/行 × ~106 万行/月 ≈ 0.33 GB/月、11 个月 ~3.6 GB,而它**全仓没有任何读者**(唯一引用是 `ingest_cost_facts` 的写入)。保留期读 `platform_settings['cost_facts.raw_retention_days']`(默认 90,**下限 45** —— 厂商重新计价窗口最宽约 45 天)。水位线 `cost_facts.raw_purged_through_date` 让稳态每晚只推进一天;**它单独存在会漏**,所以摄取侧配了对称闸门(低于保留期的日期直接不写 `raw`),否则一次跨老月份的回填会把 `raw` 写进水位线以下、cron 再也不回头看。 |
| 577 | **`offboarding_account_assurance.system` → `identity_system`.** `SYSTEM` is a RESERVED WORD in MySQL 8.0.3+. The ORM quotes identifiers so application code never noticed, but this repo documents hand-written read-only SQL against prod TiDB as an ops path and this is a governance table people will query by hand — `SELECT system FROM offboarding_account_assurance` is a 1064 whose message never says why. Found by hand-writing an INSERT while seeding the page for a UI check. Free to fix at this moment (575 had not deployed, so the table does not exist in prod yet); `ALTER ... CHANGE COLUMN`, guarded on the old column still being present so a re-run is a no-op. The Python attribute stays `system`, so no call site changes. |
| 578 | **`support_kb_articles` 补三列,承接飞书服务台知识库(`source='helpdesk_faq'`,2026-08-30)。** `search_aliases TEXT NULL`(「相似问题」——**换行分隔的纯文本,不是 JSON**:关键词那一路 `ILIKE` 直接打得到,语义那一路直接拼进 `_embedding_text`,前端就是一个 textarea;在 embedding 文本里排在**标题之后、正文之前**,既因为它们本来就是标题,也因为这样 4000 字符截断永远切不到它们。任何来源的条目都能用)、`language VARCHAR(8) NULL`(zh/en/ja —— **只做展示与筛选,不参与排序**,embedding v3 本身跨语言可召回)、`source_category VARCHAR(64) NULL`(来源系统自己的分类标签,因为 `category` 只有 6 个固定桶)。三列全可空,manual / ai_ticket / feishu_doc 老行不受影响,无需回填。守门按列存在性,可重跑。 |
| 568 | **转岗复核包裹 —— 把「转岗要看什么」从「只有云授权」扩成这个人真正持有的一切.** Two tables (`hr_role_change_reviews` / `hr_role_change_review_items`) plus the `role_change.review_backfill` (03:40) and `role_change.review_notify` (09:30) crons, both seeded **enabled**. Exists because a role change reviewed exactly ONE of the ~24 categories the offboarding scanner already enumerates: measured on production, one senior engineer's real holdings are **238 items across ten categories** (44 domains, 111 DNS records, 19 software IT-contact rows, 18 software ownerships, 13 seats, 9 cloud grants, 8 approval flows, 3 mail groups), and twenty-three of those categories were never looked at on a move. `resource_type` uses the offboarding `RES_*` vocabulary on purpose so a structural test can fail the build when the two enumerations drift. Unique on `(person_id, effective_date)` (one packet per move, replay-safe) and `(review_id, resource_type, resource_id)` (a re-scan tops up and can never touch a decision). The packet ASKS and never acts — issue #623's no-auto-revoke-on-a-move policy is unchanged. Downgrade drops both tables and both cron rows. |
| 565 | **Mover 复活:把「初始播种」从 `sync_mode='full'` 里拆出来.** One non-null boolean on `hr_sync_batches` (`is_seed`, server_default 0, existing `full` rows backfilled to 1 so a resumable `applying` batch replays identically) plus the daily `role_change.dispatch_reconcile` cron, seeded **enabled**. Exists because `is_initial_seed` was `sync_mode == 'full'`, which made the WEEKLY `hris.full_sync` — a routine reconciliation whose entire purpose is recovering what the 30-day incremental window cannot see — wear the semantics of a one-time historical import: every org move, hire and termination it recovered was applied to the person row and then had its coordinators suppressed. Prod correlation was exact (4 moves in `full` batches → 0 `role_change` events; 1 in `incremental` → 1). `is_seed` is resolved once at batch creation by `_full_sync_is_seed` (no persons AND no lifecycle events for the instance) and overridable via `POST /sync/full-employees/preview?as_seed=`. Add-column + backfill, `information_schema` guarded on `DATABASE()`, unindexed — the downgrade is a plain drop plus a `DELETE` of the cron row. |
| 564 | **「转交」改得了台账,改不了飞书后台.** One nullable column on `registry_approval_flows` (`feishu_admin_drift_notified_at`) plus the daily `approval_registry.feishu_admin_drift` cron, seeded **enabled** (the backlog it reports exists the moment it ships — every hand-over since 553 is already drifting — and it cannot storm: one card per operator, a flow re-raised only every 14 days). Exists because the approval v4 definition surface has **no update verb and no write for 流程管理员**, so the register and the console disagree from the instant an owner presses 「转交」, and the console is what grants the rights. The disagreement itself is NOT stored — it is derived on read from `feishu_admins` vs `owner_person_id`, so the 03:30 sweep clears a fixed row with no write here; this column is bookkeeping about the PUSH, so a standing backlog is re-raised every `DRIFT_RENAG_DAYS` (14) instead of once-and-forgotten or nagged daily, and it is nulled when a row heals so a re-broken flow is a new finding rather than a silenced one. Add-column only, `information_schema` guarded on `DATABASE()`, unindexed — the downgrade is a plain drop plus a `DELETE` of the cron row. Verified up → repeat-up → down → re-up on a scratch schema stamped at 563 (the pytest suite builds its schema with `create_all` and never runs the migration): the column and the cron row both land, and the repeat upgrade adds no second cron row. |
| 559 | **飞书早就知道谁管这条流程,是我们没问.** Two columns on `registry_approval_flows`: `feishu_admins` (JSON list of the definition's 流程管理员, resolved to people) + `feishu_admins_synced_at`. `GET /approval/v4/approvals/{code}` omits `approval_admin_ids` entirely unless the request carries `?with_admin_id=true`; the sweep never sent it, read the missing key as 「飞书没有负责人字段」, and wrote that conclusion into five places — so 32 of 49 flows published 「还没人认领」 while the Feishu console named a manager. Re-measured with the param: 42 of 49 definitions return an admin, 37 distinct people, 33 matching an active person. A **list**, not an FK, because the upstream fact is a list (7 flows name three or more) — collapsing it would force the register to invent a single owner; only a lone still-employed admin is adopted, as `owner_source='feishu_admin'` rendering 待确认. The second timestamp is not redundant: earlier sweeps DID sync these definitions, without asking for admins, so NULL means never-asked rather than none-exist. Add-column only, `information_schema` guarded on `DATABASE()`, neither column indexed so the downgrade is a plain drop. |
| 558 | **MDM 纳管关系改挂设备.** One table (`asset_mdm_devices`) — one row per enrolled device, keyed by the tenant's own `(integration_id, device_type, external_id)`, with the holder as an attribute the nightly sync recomputes. Exists because Jamf entered Helix through the seat-reconciliation door: "seat == device" balanced the arithmetic and made a managed Mac look like a licence somebody holds until they resign — so every departure emitted a "revoke the Jamf seat" todo whose correct resolution was always to do nothing. **Every resolution column is a plain UUID with no FK**: TiDB locks the parent row on each child INSERT, and a nightly batch rewriting the whole fleet would queue ~50 inserts behind a lock on one integration row (the 03:00 cron-collision shape this platform has already paid for). Readers LEFT JOIN and render a dangling id as "—". `serial_key` is the stored lowercase serial rather than an expression index, so the disposal lookup is a plain indexed equality. `cleanup_confirmed_via` separates a human's claim (`admin`) from the vendor's confirmation (`sync`) — a ledger that cannot tell those apart eventually reports the wrong number to an auditor. `cleanup_contradicted_at` records the third thing that can happen to a claim: the roster overturned it. A recorded fact and not an inferred one, because overturning a claim CLEARS `cleanup_confirmed_at`, so anything derived from that field is true exactly when no contradiction has happened. It is history — kept until the cleanup resolves, cleared by all four resolution paths (withdraw / release / a fresh disposal / the device actually leaving the tenant) and deliberately NOT by the re-raise, which is what the banner reads. Retires `jamf.seat_sync` (row disabled, run history kept) and mints `jamf.device_sync` + `mdm.cleanup_notify`, inheriting the old schedule and enabled flag. The data cleanup is part of the schema change, not a follow-up: the offboarding scanner reads allocations, not sync jobs, so leaving them behind would keep the old behaviour running off stale rows. |
| 557 | **新建流程申请.** One table (`registry_approval_flow_requests`) — the front door for an approval flow the company does not have yet, and the loop back to the register. `purpose` is NOT NULL because it is both what IT decides from and what the all-staff page publishes as the flow's description. `approved` is an OPEN state and only `registered` is done: an approved request nobody built looks finished from IT's side and is an employee still waiting from theirs. `registered_flow_id` is `SET NULL` rather than CASCADE — if the flow is later removed, the request is still a record of what was asked and answered. No Feishu approval template of its own (this request's approver is IT), and **Helix never creates the definition through the API**: one created that way can never be deactivated or deleted, which would take away the owner's ability to stop their own flow. |
| 556 | **半年度负责人确认轮次.** Two tables (`registry_flow_confirmation_rounds` with UNIQUE `label`, `registry_flow_confirmation_items` with UNIQUE `(round_id, flow_id)`) plus two crons, seeded enabled. A round is an object rather than a query over `owner_confirmed_at` because a flow registered after the round opened was never asked and one whose owner changed mid-round was asked of somebody else — the item's `owner_person_id` is a snapshot of **who was asked**. `lapsed` is kept distinct from `pending` (at close, "still waiting" vs "nobody ever answered" is the whole finding) and `dropped` is excluded from the denominator (an archived flow or a departed person is not a governance gap). `final_stats` freezes the numbers at close. Downgrade drops children before parents. |
| 553 | **审批流程台账 —— 全公司审批清单 + 负责人.** Two tables (`registry_approval_flows` with UNIQUE `approval_code` and four read indexes, `registry_approval_flow_events` append-only) plus the nightly `approval_registry.feishu_sync` cron, seeded **enabled** (the scopes it needs were granted and verified the same day, so it has nothing to wait for — unlike 548's mail poller). `owner_source` exists because ownership had to be established in Helix and the provenance of every name recorded: a machine's candidate and a person's confirmation are different facts. (This row originally justified that with 「Feishu's approval definition carries no owner at all — `approval_admin_ids` returns null」. **That was wrong**: the field is absent unless the request sends `?with_admin_id=true`. Corrected by migration 559; the design conclusion survives, its stated reason does not.) `source_group` + `category` are separate columns because the console's taxonomy and the API's genuinely disagree here. Downgrade drops children before parents; `DROP TABLE` takes each table's own indexes and FKs with it, so no index-before-FK 1553 dance. Verified up → down → up on a scratch schema. |
| 554 | **No report is 未定级 any more.** Migration 551 gave *new* reports an arrival level and deliberately left history alone — the reasoning being that a level nobody chose, written across the backlog, is indistinguishable from triage that happened. That protected a distinction nobody needed at the cost of the thing the change was for: 142 of 149 still showed as ungraded, the console still displayed the wall, and the report page still nagged the operator to grade something. So the backlog gets the same treatment, and the distinction survives where it earns its keep: `severity_is_default` stays TRUE, which keeps the reply composer off the tier's reward letter — a critical finding nobody graded can never be offered the $0 informational one. Touches only `severity IS NULL`, so a graded report keeps its grade and re-running changes nothing. Deliberately does NOT touch `reward_amount_usd` (the arrival level is not a reward decision) or `fix_due_date` (a deadline from a level nobody chose would make reports overdue on a judgement no human made). Downgrade reverses only the rows still flagged as the default. |
| 555 | **What the researcher actually attached.** One table (`bounty_report_attachments`), bytes in S3 via `core.storage` — the bucket/region rows and the presign path already existed (the auditor domain's evidence files use them), so no new infrastructure. Exists because the write-up import walked the MIME tree for text and stepped past every attachment part: measured against the real mailbox, **319 files / 143.1 MiB across 54 of 149 reports** — 266 images, 25 logs, 19 PDFs, **9 PoC screen recordings**. Nothing was destroyed (all of it is still in Gmail) but Helix held no record any of it existed. `sha256` + `report_id` is the dedupe index, not a UNIQUE constraint — sha256 is nullable and MySQL treats NULLs as distinct, so UNIQUE would not hold the invariant it appears to. `is_inline` exists because a signature logo and a pasted PoC screenshot are the same kind of MIME part and one report carries 90 of them. `gmail_message_id` + `gmail_attachment_id` keep provenance: an attachment id only resolves against the message it hung off. **Downgrade is `DROP TABLE` only** — `idx_bounty_attach_report` backs the FK and MySQL refuses to drop it while the constraint stands (1553), so the tidy index-then-table order fails on its first statement and leaves the table behind; caught by running the downgrade, invisible in the schema. Verified up → down → up on a scratch schema. |
| 552 | **The AI's triage read, stored next to the report.** Adds `bounty_reports.ai_assessment` (JSON) + `ai_assessed_at`. Once the write-ups are imported the console has the text, but the person triaging still has to answer "is this actually a problem" for 149 reports of somebody else's English — which is the reason 118 never got an answer. One column holds a verdict with its reasoning, the attacker's prerequisites, the worst case, the 2-4 concrete things to CHECK to confirm it, and what to ASK when it is not checkable as written. JSON rather than columns because it is one artefact with one provenance: the model and timestamp belong to the whole reading and a re-run replaces it wholesale. **Advisory — wired to nothing**: the verdict never sets the status, the level lands only in `suggested_severity`, and the reply composer never reads it. An LLM writing into a ledger that authorises payments is not a feature. |
| 551 | **Every report arrives graded, and the register remembers nobody chose it.** Adds `bounty_reports.severity_is_default` (Boolean NOT NULL default 0). Grading gated everything downstream — reward tier, fix deadline, reply letter — so 130 of 149 reports could not move until a person picked a level, which is why the queue never drained. Reports now arrive at the lowest enabled tier (`informational`: $0, no SLA, no money attached) and the flag records that the level is the arrival default rather than a decision. Without it the console cannot tell "somebody looked and said informational" from "nobody has looked", and two things go quietly wrong: the reply composer would offer a genuinely critical finding the **$0 informational reward letter** (a figure stated to a researcher cannot be withdrawn), and 「待定级」 would read 0 while nothing had been triaged. With it, a defaulted level routes and displays but the letter stays "under evaluation", the gap panel and matrix keep counting it, and the two actions that genuinely depend on the grade — confirming a finding, attaching money — still require a real one. Existing rows are NOT back-filled: a level nobody chose, written across 130 historical reports, is indistinguishable from triage that happened. |

| 550 | **Record whether the 年假基数 cap was applied, per block.** Two NOT NULL DEFAULT FALSE booleans on `hr_tools_annual_leave_calculations` (`basis_cap_applied_full_year` / `basis_cap_applied_settlement`), shipped with the 2026-08-26 annual-leave formula change (365.25 denominator + truncate-then-ceil + 基数 cap). Kept SEPARATE from the existing `cap_applied_*` (15-day combined cap) rather than folded into one "a cap happened" flag, because the calculator's whole purpose is letting HR defend a number to the employee who received it, and the two caps produce different sentences — "your result was capped at your own basis" vs "statutory + welfare exceeded 15 so welfare was reduced". `false` is the literal truth for every pre-existing row (the rule did not exist when they were written) and history replays from persisted columns rather than recomputing, so no row changes meaning. Verified upgrade → downgrade → re-upgrade against a scratch DB stamped at 549, with a row written at the pre-550 shape in between: it backfills to `0/0` and the second upgrade is a no-op through the `information_schema` existence guard. |
| 549 | **Greet the researcher by name in the four letters that did not.** Seven of the eleven shipped reply letters already opened with `{researcher_name}`; the other four opened "Dear submitter," / "Dear valued submitter," / "Hello," / "Hi,". One of the four is `under_evaluation` — the acknowledgement sent to every unanswered report, so the highest-volume letter in the system and the first thing most researchers ever receive. The name was already in the database. Safe because the token degrades to the literal word "submitter" (`rendering.py`: `name or "submitter"`), so the worst case is the wording these letters already had — that is what makes it a copy fix rather than a risk. **Diff-and-apply, not overwrite**: a row is updated only while its body is byte-identical to the old shipped default, so an operator who reworded their own acknowledgement keeps it and opts in later through the console's per-template 「恢复默认」; overwriting every row would have silently discarded operator work with no way for them to know. Matched as a body *prefix*, so the rest of an unedited letter does not have to be restated in the migration and an edited body simply fails to match. `variables` is kept in sync in both directions. It is the console's insertable-token hint and **not** what the money guard reads — that calls `declared_tokens(subject, body)` and parses the text, so it already sees the new opening — but a row whose body carries a token it does not declare is a quiet inconsistency the next reader should not have to re-derive. Downgrade is the symmetric prefix swap, so a body edited after the upgrade is not clobbered on the way back either. Verified upgrade → downgrade → re-upgrade against a real DB with one row deliberately edited first: spared in all three passes. |
| 548 | **`bounty_mail_intake` — hold what arrived on security@ until a person has looked at it.** Deliberately NOT the report table: security@ receives vendor pitches, internal mail and automated scan output as well as researchers, and the precedent for an unattended loose predicate is concrete (an HRIS import created 26 accounts for system users because nothing asked a person first). A poll writes rows here with the AI's reading attached and a human accepts or ignores each one; accepting creates the report, ignoring keeps the row so the same mail is never re-offered. Two unique-indexed dedup keys: `message_id` (the RFC822 header — one mail is offered once however often the poller runs or however far its watermark rewinds) and `thread_id` (used to ATTACH follow-ups to an existing report instead of creating a second one; researchers chase their own threads, and 118 unanswered reports meant a lot of chasing). A snippet is stored, never the body — researcher correspondence stays in the mailbox, because researchers send tax forms and bank details unprompted. **No FK to `bounty_reports`**, so the row survives a report being soft-deleted rather than letting the same mail come back around. Seeds the `bounty.mail_intake_poll` cron **disabled**: the Google service account has only `gmail.send` today, and a job failing every fifteen minutes until a Workspace admin adds `gmail.readonly` would train people to ignore the failure notifier. |
| 547 | **`bounty_researchers.merged_into_id` — let two addresses be one researcher.** Confirmed in production: `suhaspandya55@` and `suhaspandya124@` are one person counted as two, so every per-researcher figure was wrong for anyone who ever used a second address — "how many has he sent us", "how much have we paid him", and the repeat-submitter signal the reply templates lean on. Modelled as a merge **pointer**, not a rewrite of history: reports keep `researcher_id` pointing at the address that actually sent them, because a reply has to go back to the address the mail came from and "which address did this arrive on" is a real question during a dispute; only the aggregates resolve through the pointer. Rewriting `researcher_id` on the reports would have been less code and destroyed both facts. Self-FK `SET NULL` rather than CASCADE — deleting a canonical researcher must not delete alias rows the reports still reference. Chains are refused at the service layer (an alias may not itself be a merge target, and a researcher that already has aliases may not become one), so resolution is always one hop: no recursive CTE, and no cycle that can make a report page hang. Downgrade order FK → index → column (1553). |
| 546 | **`bounty_reports.system_not_applicable` — "somebody looked and there is no internal system".** Without it the unlinked gap counted a report nobody could ever link (an open-source finding, a CI workflow) forever, and a gap number that never goes down stops being believed. Distinct from `system_id IS NULL`, which means "nobody has looked yet" — the two are different jobs for the operator, and only the first is work. |
| 545 | **One reward letter per severity tier, plus `payment_sent`.** Replaces the single `reward_confirmed` template, whose verdict was frozen into the body, with five tier letters (`reward_critical` … `reward_informational`) that each state the amount, and adds the letter for "the money has gone out". This is the schema half of the fix for the complaint that "the reply never mentions the money" — the tier is what decides the amount, so one shared "confirmed" letter is how a critical finding got told it was low-impact. Deletes `reward_confirmed` only when its body is still the shipped default; an operator-edited row is left in place rather than removed under them. |
| 542 | **Internal system registry + the bounty report's link to it.** Two tables (`registry_systems` with UNIQUE `slug` and four read indexes, `registry_system_events` append-only) plus `bounty_reports.system_id` (nullable FK `SET NULL` + `ix_bounty_reports_system`). Nullable and **not backfilled on purpose**: a guessed link puts a wrong owner on a real finding, and the free-text `affected_system` already carries what the researcher said. Seeds the eight systems that appear in real vulnerability correspondence (ticard / clinic / tidb-cloud / pingcap-www / jira-crm / one-engine / docs / labs) with their hostnames, exposure, budget track and bounty-scope flag — but **without inventing owners**: only the two whose owners the platform owner named are resolved (by work email, skipped with a log line when absent), because an ownerless row is a finding the coverage read will chase and a fabricated owner is not. A slug that already exists is left completely untouched, so an operator's edited row survives a re-run. Downgrade order is FK → index → column, verified against a real downgrade: MySQL/TiDB back the FK with the composite index whose leading column is `system_id`, so dropping the index first fails with 1553 ("needed in a foreign key constraint"). Children before parents on the table drops. |
| 541 | **`security_officer` (安全员) — the 10th canonical role.** Seeds the `auth_roles` row and grants it to the four people who run the security desk. Grants resolve by work email and are **skipped with a log line when the person is not found** — a name that does not resolve is a data question, not a reason to abort a deploy mid-chain (the DDL above has already committed). Idempotent on both halves: the role row is created only if absent, and an existing grant is not re-inserted. Downgrade deletes the grants before the role row (FK). The role passes `can_view_security` + `can_manage_security` and nothing else; see the 2026-08-25 ADR for why it is equal to `auditor` *inside* the security domain rather than a narrower seat. |
| 539 | **Bounty budget split into two funding tracks.** Adds `bounty_reports.budget_track` (`String(16)` NOT NULL, server default `product` — every existing row becomes product, which is where the historical findings genuinely belong; a nullable column would leave an "unknown track" bucket no budget owner would claim) plus `ix_bounty_reports_track_budget` covering the `(track, fiscal_year, payment_status)` the budget report groups by. Rewrites the `bounty.budget` platform setting from the flat shape into `{"tracks": {"product": …, "it": …}}`, **carrying an operator-edited flat default across into the product track** rather than replacing it with the shipped 5000. Downgrade drops the index BEFORE the column (TiDB 8200 refuses a covered DROP COLUMN; MySQL's silent auto-rewrite hides it locally) and collapses the setting back to the flat shape so the pre-539 code can still read it. Guarded by schema-local `information_schema` probes on both the column and the index; re-running changes nothing. |
| 533 | **Bug bounty operations — 5 tables + seeds.** `bounty_researchers` (external submitter, keyed on lowercased email; **no banking / address / identity-document columns, deliberately**), `bounty_reports` (`code` `BB-YYYY-NNN` unique and never recycled; the three axes; attribution snapshot; money + FY stamp; soft delete only), `bounty_report_events` (append-only timeline: transitions, comments, and the verbatim text of every reply sent to a researcher, with load-bearing `internal|shared` visibility), `bounty_severity_tiers` (reward ladder + per-report cap + fix SLA days), `bounty_reply_templates` (English bodies, tri-lingual names, declared placeholder list). Seeds the ladder and the 6 shipped reply templates from `app.domains.bounty.catalog` (same source the console's "restore default" reads, so there is one copy of each body in the tree) plus the `bounty.budget` / `bounty.reply_signature` platform settings. All FKs point at `identity_persons` or intra-file parents, so one `table_kw_for` charset satisfies every child column (no mixed-parent collation trap). Guarded per table by a schema-local `information_schema` probe; re-running inserts nothing new and preserves operator-edited reward amounts. Downgrade drops children before parents. |
| 519 | **`assistant_messages`: one message per `(conversation_id, seq)`.** Replaces the non-unique `idx_assistant_msg_conv` with `uq_assistant_msg_conv_seq`. `seq` is assigned from `conversation.turn_count` under a Redis turn lock — and that lock FAILS OPEN, as does the event-dedup claim, so a Redis outage lets two concurrent turns read the same count and write the same pair of sequences. A non-unique index accepted it and the transcript silently interleaved, with nothing afterwards able to say which reply answered which question. **Prod-verified before adding**: 78 rows, zero duplicate pairs, so no cleanup. ⚠️ **CREATE before DROP** — `conversation_id` carries an FK and the old index was what MySQL used to enforce it; dropping first fails with errno 1553 (the first cut did exactly that and the scratch-DB round trip caught it). Downgrade mirrors the order. |
| 515 | **SSO grant flow: the return leg + delivery proof.** Adds five nullable columns to `sso_apps` — `idp_sso_url` / `idp_entity_id` / `idp_certificate` (what **Google** generated when IT created the custom SAML app by hand; Google is the IdP in this flow, so these are the values the requester must configure on the vendor side) and `config_delivered_at` / `config_delivered_by_person_id` (FK `identity_persons` SET NULL, typed via `id_type_for`). Before this the flow only carried metadata INBOUND: nothing stored the IdP-side trio and no notification went to the requester on approval, so the last hop lived in IT's memory while a stalled request rendered as `approved` + complete catalog row = done. New `POST /sso/apps/{id}/idp-config` (`deliver=false` saves, `deliver=true` notifies the requester via the new `sso.idp_config_ready` template and stamps — **stamped strictly after a successful dispatch**; a failed send returns 502 and leaves the row unstamped so the column can never be the same false-done signal it exists to remove). Employee recipient is an explicit exception to "new pushes are IT-only": nothing fires it automatically (no cron, no state machine — one admin, one app, one click), so existing rows cannot batch-trigger on deploy. |
| 521 | **Anonymous community feed.** Adds non-null `author_visibility` (`named` default) to `feed_posts` / `feed_comments`, retaining author foreign keys for ownership and audit while ensuring normal DTOs omit anonymous author PII. Adds `feed_reports` with a one-report-per-reporter/resource unique constraint, moderation status, resolution attribution and indexed queues. |
| 129 | Add `google_orphan_accounts.deleted_at DATETIME(timezone) NULL` + index. Schema-only — existing rows land NULL ("not yet detected as deleted upstream"); the next `google.seat_sync` Phase D pass stamps any whose `last_seen_at` is already older than the grace window (default 3 days). List endpoints filter `deleted_at IS NULL` by default; admins surface deleted-upstream rows via `include_deleted=true`. Soft-delete only — owner + purpose are preserved for audit; revive via re-observation in the tenant list. |
| 130 | **Backfill stale `<empno>@successfactors.local` placeholders** — data-only migration (no DDL). Pairs with the `HrSyncService._resolve_unique_work_email` no-downgrade fix in the same PR. Scans `identity_persons` for any row whose `work_email` ends in `@successfactors.local`, looks up the matching `successfactors` `external_identity.provider_email`, UPDATEs `work_email = real_email, deleted_at = NULL` only when (a) the real email is itself non-placeholder, AND (b) no other live row owns that email (case-insensitive collation match — mirrors the UNIQUE-index semantics). Rows with live collisions are skipped + logged at WARNING level so admin can resolve manually; the migration never trips the UNIQUE constraint. Idempotent on re-run. Downgrade is intentionally a no-op — re-poisoning real employees on rollback would actively make prod worse. Resolved 2 PingCAP-prod rows: empno 100113 (朱赟 Claire Zhu → `yun.zhu@pingcap.com`) and empno 80100057 (Terry Purcell → `terry.purcell@pingcap.com`); a third row (P01146) was handled out-of-band by the IT admin. |
| 135 | **Seed `offboarding.reconcile_processes` scheduled job** (interval=300s, enabled). Pairs with the new handler in `scheduled_jobs/handlers.py` and the new `OffboardingService.reconcile_all_active_processes()` method. Renumbered from 132 → 135 during rebase: PR #209 / PR #211 / PR #210 landed migrations 132 / 133 / 134 ahead of this branch. Idempotent (`SELECT 1 FROM scheduled_jobs WHERE name = …` short-circuit). Downgrade deletes the row. Closes the gap between async provisioning task completion and offboarding blocker status — admins no longer have to remember to click "Reconcile" in the offboarding panel; the cron walks BLOCKED / READY processes every 5 min and flips blockers whose backing task is COMPLETED to RESO_REVOKED_AUTO + flips processes that hit zero-pending-blockers to READY. Skips PROC_SCANNING (assumes another tx in flight) and terminal states. |
| 141 | **`cmdb_cloud_scopes.parent_scope_id` self-FK** (`String(36)` NULL, `ON DELETE SET NULL`, indexed). Models hierarchical scope_types: currently only `azure_resource_group` → its parent `azure_subscription`. Tenant-level scopes (the members of `TENANT_LEVEL_SCOPE_TYPES`: `aws_account` / `azure_subscription` / `gcp_project` / `aliyun_account` / `standalone_iam`) always carry NULL. Pairs with the new Azure OpenAI auto-grant flow ([`azure_openai_autogrant.py`](../../server/app/domains/apikey/azure_openai_autogrant.py)) — every approved API key request mints an RG inside the standing PingCAP Azure OpenAI subscription, mirrors it as a child scope row, and binds `apikey_assignments.cloud_scope_id` there. ON DELETE SET NULL is the safe direction — deleting a parent subscription scope keeps the child RG row queryable (other domains may still hold FKs), and the NULL parent_scope_id is the recoverable signal. No backfill needed; existing scopes were all tenant-level. |
| 146 | **`api_key_assignments.notify_recipients`** (`JSON` NULL). Per-assignment budget-alert recipient override; tri-state semantics — `NULL` = default (applicant + HRIS manager — the cron's pre-PR behaviour), `[]` = admin "suppress alerts entirely" (audit-logged), `[<uuid>, ...]` = exactly these `identity_persons.id` strings receive 80%/100% alerts. The applicant is NOT auto-included in the explicit-list case; admins pick every recipient. Stored as JSON (not join table) — the cron reads this column once per assignment per tick, recipients are dereferenced against `identity_persons` at notification dispatch time, deleted ids silently drop out. Pairs with the new `PATCH /api/v1/apikeys/assignments/{id}` endpoint + `ApiKeyEditModal.tsx` (shared between `ApiKeyManagementPage` and `UserItUniverseTab`) + MCP tool `update_api_key_assignment` + audit action `apikey.assignment.updated`. Downgrade drops the column. |
| 181 | **`cmdb_cost_facts.ix_facts_scope_date` composite index `(scope_id, date)`.** Pins the cron's pre-window DELETE in `sync_cost_facts_for_integration` AND every per-scope read path (cost dashboard scope drill-down, MCP `list_cost_facts` with `scope_id`, recompute summary per-scope breakdown) to an index-range scan. Before this index, all existing indexes led with `date` or `integration_id`, so TiDB picked `ix_facts_date_int` for `WHERE integration_id=? AND scope_id=? AND date=?` and had to load the full day's row payload (45k rows × ~16 KiB JSON `raw` ≈ 720 MiB working set on the AWS `385595570414 PingCAP` payer scope) before applying the `scope_id` filter. Same `_index_exists` idempotency pattern as migrations 165 / 178 / 180. Prod TiDB online DDL on ~10.9M-row table: no table lock, ~5-15 min backfill. Verify post-deploy with `EXPLAIN DELETE FROM cmdb_cost_facts WHERE integration_id='<…>' AND scope_id='…' AND date='…'` — must show `IndexRangeScan` on `ix_facts_scope_date`. **Note**: this migration also briefly removed PR #380's `DELETE … LIMIT 1000` chunking in `sync_cost_facts_for_integration` on the theory that the index alone would bound working set; 2026-05-18 prod 8175 falsified that — the chunking was restored in Round-39 because the index bounds rows scanned, not row-payload total memory. |
| 186 | **`asset_items.holder_kind` + `holder_office_id` + composite index — office-held mode (PR #406, 2026-05-19; renumbered from 183 because PR #403 landed migration 183 first).** Adds `holder_kind VARCHAR(16) NOT NULL DEFAULT 'personal'` (`'personal'`/`'office'`) and `holder_office_id CHAR(36) NULL` FK → `asset_office_addresses.id` (`ON DELETE SET NULL`) plus composite index `ix_asset_items_holder(holder_kind, holder_office_id)`. FK column type sourced from `information_schema.COLUMNS.DATA_TYPE` of the target so TiDB doesn't reject with error 3780 (same template as migration 173). Idempotent column/index/FK guards so a partial apply re-runs cleanly. Powers the new `held_by_office` status (free string, no enum migration needed — `status` is `VARCHAR(32)`) and reroutes inventory's `_classify_asset` to prefer `holder_kind='office'` → `holder_office_id` over the legacy `asset.location` city string match. Application-layer invariant `holder_kind='office' ⇒ assigned_to IS NULL AND holder_office_id IS NOT NULL` — service-layer enforced (`AssetService.create_asset` / `intake_asset` / `_verify_office_active`); no DB CHECK to avoid TiDB 8.0 partial-row UPDATE quirks. |
| 187 | **`offboarding_processes.hardware_disposition JSON` — per-asset T+0 disposition map (PR #406, 2026-05-19; renumbered from 184).** Adds nullable JSON column carrying the offboarding-console admin's choice per hardware blocker: `{ "<asset_id>": { "action": "keep" \| "recycle_to_office" \| "transfer_to_person", "office_id": "<uuid\|null>", "person_id": "<uuid\|null>", "note": "<optional>" } }`. Replaces the implicit "force-transfer to manager fallback" T+0 path with a disposition-aware handler in `hr_lifecycle/advance_service.py::_apply_hardware_disposition`. Default when entry is missing = `recycle_to_office` to the leaver's home office (resolved via `AssetService.resolve_office_for_person` → `identity_persons.work_location_name` ↔ `asset_office_addresses.city`); fully degrades to `mark_held_by_leaver` if no office can be inferred. Validated by `HardwareDispositionRequest` Pydantic schema with cross-field `@model_validator` (transfer_to_person requires person_id, keep rejects office_id/person_id) before persist; refuses mutation once `proc.status ∈ {completed, cancelled}`. NULL on existing rows means "no decision yet — use the safe default" and the handler treats it that way without backfill. |
| 188 | **Backfill `asset_items.holder_kind` + `holder_office_id` (PR #406, 2026-05-19; renumbered from 185).** Data-only migration that reclassifies existing rows by 6 rules: (1) terminal statuses (`disposed`/`retired`/`scrapped`/`sold`/`lost`/`held_by_leaver`) — skip; (2) T+0 force_transfer victims (`asset_history.action='force_transfer'` with `from_user` ∈ left/leaver_pending) — roll back to `held_by_office` routed via the leaver's `work_location_name` (this is the prod fix for the "everything stacked on Meng" pattern); (3) idle + `location` matches an office (exact city, `市` suffix strip, country-name single-office fallback for 美国/日本/新加坡/马来西亚) — promote to `held_by_office`; (4) idle with no office match — leave at `holder_kind='personal'` (truly idle stock); (5) `claimed`/`assigned` still pointing at a left person (T+0 handler missed it) — promote to `held_by_leaver`; (6) everything else — default `personal`. Walked with KEYSET pagination (`id > :last_id`) — NOT OFFSET — because the WHERE filter shrinks as rows get promoted out of `holder_kind='personal'`, and OFFSET would silently skip new rows that shifted into already-advanced positions. ~3300 row writes total, fits one alembic-managed transaction (no batch COMMIT — that pattern would corrupt alembic's bookkeeping). Each touched row writes one `asset_history` row + one `asset_operations` row + one `audit_logs` entry (action=`asset.holder_kind_backfill`, actor_type=`system`) so the bookkeeping is greppable post-merge. Downgrade is intentionally a no-op — reverting would re-introduce the original bug. |
| 173 | **CMDB network-asset modifier tracking + LB owner FK + legacy business columns.** Adds `last_modified_by_person_id` (FK → `identity_persons`, ON DELETE SET NULL) + `last_modified_at` to all five network-asset tables (`cmdb_servers/_domains/_dns_records/_load_balancers/_cdn_domains`). Deliberately separate from `updated_at` because that column advances on every sync upsert — surfacing it as "修改时间" in the UI would falsely attribute sync activity to a human edit. Adds `owner_person_id` (FK, indexed) to `cmdb_load_balancers` with a one-time backfill from existing `owner_raw` strings via `LOWER(work_email)` match (mirrors migration 160's pattern for CDN). Adds legacy business columns the Go CMDB (`amber-lzz/cmdb-backend`) carried but Helix's sync-driven schema didn't model: `app` / `remark` on servers + LB; `grade` / `possessor` / `remark` on domains; `cert_id` / `remark` on CDN; `remark` on DNS records; plus cloud/IDC server detail (`instance_type`, `image_name`, `system_disk_gb`, `data_disk_gb`, `vpc_id`, `subnet_id`, `project_id`, `bandwidth_mbps`, `expired`). Bare-metal-specific fields (SN, IPMI, cabinet/rack/switch, model IDs) land in `raw_data` JSON, not as schema columns — they only apply to legacy KSYun/UCloud rows and live cloud sync never fills them. FK column types mirror `identity_persons.id` (CHAR vs VARCHAR — TiDB error 3780 guard from migration 160). Idempotent + guard-checked so a partial-apply (column landed but FK rejected) can re-run cleanly. Pairs with the rewritten `scripts/migrate_cmdb.py` which threads the OwnerResolver chain (departed owner → walk `manager_person_id` up to depth=10 → first active non-CEO lead, else `fansheng.meng@pingcap.com`/孟凡生; CEO `liuqi@pingcap.com`/Max Liu blocked as a lead via id-equality, NOT email substring), routes DNS records under `tencent-primary` integration (vendor_id=5) instead of UCloud catch-all, and the new DNS request `enable` / `disable` actions + DNSPod `ModifyRecordStatus` / Aliyun `SetDomainRecordStatus` driver methods. |
| 172 | **Seed `cmdb.manual_revoke_retry_sweep` scheduled job** (cron `15 * * * *`, enabled, `config={"min_age_seconds": 3600}`). Closes the cloud-side leak surfaced by the pre-launch 12-pass audit (CR4 P1): `_revoke_grant_locked(suppress_driver_error=True)` stamps the Helix row REVOKED + `detail.requires_manual_cloud_revoke=True` whenever the cloud-side call failed (5xx / throttle / IAM lock / propagation delay) — but pre-fix no cron ever re-attempted, so a leaver's cloud-side permission persisted indefinitely despite Helix showing it revoked. The new cron runs hourly at :15, discovers grants where `status='revoked' AND detail.requires_manual_cloud_revoke=true AND revoked_at < now - min_age_seconds`, calls `CloudLifecycleService.retry_manual_cloud_revoke(grant_id)` (in [`lifecycle_service.py`](../../server/app/domains/cmdb/lifecycle_service.py)) which re-invokes `driver.revoke_access` in its own session per row, clears the flag on success / `already_absent` (audits `cloud.grant.revoke_retry` status=success), keeps the flag + updates `last_error` on failure (audits status=failure, next tick retries). `min_age_seconds=3600` (1 h) gives transient cloud issues time to clear and avoids racing the original revoke transaction's commit. Handler `cmdb.manual_revoke_retry_sweep` lives in [`scheduled_jobs/handlers.py`](../../server/app/scheduled_jobs/handlers.py) and is auto-registered by the existing `import app.scheduled_jobs.handlers` in `app/worker.py::_run` (no worker.py change needed). Discovery query uses `func.json_extract(detail, "$.requires_manual_cloud_revoke").is_(True)` — MySQL 8 / TiDB syntax matching the writer at `_revoke_grant_locked` line ~2540. Idempotent INSERT guarded by name-based NOT EXISTS. |
| 171 | **`cmdb_cloud_scope_alert_fired` — claim-first cloud-bill alert dedup.** `(scope_id String(36), rule_type String(32), period_key String(16), threshold_usd Numeric(14,4), fired_at, current_usd, created_at)` with `UNIQUE(scope_id, rule_type, period_key, threshold_usd)` + two lookup indexes (`(period_key, rule_type)`, `(scope_id, fired_at)`). Replaces the `scope.alert_rules[*].last_fired` JSON map as the **authoritative** dedup for cloud-bill alerts — the JSON map mutated post-dispatch and only persisted via the outer cron commit, so any exception between dispatch and commit (unregistered-reconciliation crash, FX-rate hiccup, transient driver failure) rolled back the JSON while the Feishu card was already in flight → next cron tick re-fired. Prod 2026-05-16: a 6-month `backfill_cost_facts.py` replay issued ~7 cards per scope across 60 scopes for this reason. `_try_claim_cloud_scope_alert_slot` inserts inside `begin_nested()` (SAVEPOINT) — success owns the slot; `IntegrityError` (UNIQUE collision) → already claimed → skip. Covers monthly rules (`rule_type='monthly'`, `period_key='YYYY-MM'`), daily rules (`'daily'`, `'YYYY-MM-DD'`), and spike alerts (`'daily_spike'`, `threshold_usd=multiplier`). **No FK** — TiDB/local-MySQL collation drift on the FK target column (same pattern as migrations 152 / 158 / 160 / 168 / 169); referential cleanup is application-layer. Idempotent — guarded by `information_schema` existence check. Same TOCTOU-fix shape as PR #298 for `apikey.budget_alerts`. Pairs with: `dispatch_alerts: bool` switch on `sync_billing_daily_for_integration` (backfill script passes `False` so historical chunks claim slots but don't dispatch); legacy `last_fired` JSON + `scope.last_spike_alert_date` are read as deploy-day fast-path skips BEFORE the claim attempt so the 2026-05-16 prod hotfix (140 scopes stamped) isn't steamrolled by an empty new table. |
| 170 | **Seed `sso.execute_pending_google_grants` scheduled job** (interval=300s, enabled). Pairs with the new `import app.domains.sso.scheduler_handlers` line in `app/worker.py::_run`. Pre-fix the handler was decorated with `@register_scheduled_job` but no migration ever seeded a row AND the worker process didn't import the module — so the API process saw the registry entry (via `app.main`'s router tree, local-dev hid the gap) but the worker had nothing to attach to APScheduler. Approved Google SSO grants stayed in `status='approved' AND created_app_id IS NULL` forever, IT never got the "manually create SAML app in Google admin" push, applicants never got landing notification. Same bug family as PR #316 (identity_sync.dispatch). Idempotent INSERT guarded by `WHERE NOT EXISTS (SELECT 1 FROM scheduled_jobs WHERE name=...)`. Downgrade deletes by `handler_key`. |
| 165 | **`ix_cmdb_sync_jobs_integration_status_finished`** composite index on `(integration_id, status, finished_at)`. Renumbered from 163 during rebase — PR #301 (HR Tools) landed 163 + 164 ahead of this branch. Backs the sync-to-sync baseline lookup added in the same PR to `_safe_sweep`: `WHERE integration_id = ? AND status IN (success, partial_failure) ORDER BY finished_at DESC LIMIT 20`. Without the composite, the per-integration sort on `finished_at` runs unindexed and grows linearly with the table (~60k rows/year/integration in steady state); with it, the lookup is an index-range-scan + LIMIT 20 — sub-millisecond at any table size. Pre-checked via `information_schema.statistics` so re-runs (or an operator who pre-created the index chasing a hot query) are no-ops. Downgrade drops the index symmetrically. Required only when the per-cap cron load is heavy enough that the `cmdb_sync_jobs` table is consulted on every sweep — not load-bearing for a fresh tenant. |
| 164 | **`hr_tools_annual_leave_calculations` history table.** Stores every run of the 年假计算器 with the full computation graph (inputs + per-segment formula list + raw/rounded/capped totals for both entitlement and settlement blocks + final unused balance + cap flag). Indexed on `(person_id, created_at)` for the per-person history drill-down. No FK to `identity_persons` because sandbox runs (no person selected) are explicitly supported — `person_id` is nullable. Companion to migration 163. |
| 163 | **`identity_persons` annual-leave fields.** Adds `work_start_date DATE NULL` (社会工龄 N 起点 — sourced from SF `EmpEmployment.originalStartDate`, distinct from `hire_date` which is 司龄 S 起点) and four `Numeric(4,1)` leave-balance columns (`annual_leave_carry_legal_days`, `annual_leave_carry_welfare_days`, `annual_leave_used_legal_days`, `annual_leave_used_welfare_days`). NULL means "HRIS didn't carry the value, HR enters manually in the 年假计算器". Pairs with the HR Tools / 年假计算器 feature. |
| 160 | **Add `cmdb_cdn_domains.owner_person_id`** — aligns CDN with the domains/DNS/servers pattern (migration 042 swept those three; CDN was missed). Backfills via `work_email` join. `CdnDomainResponse` now exposes `owner_person_id + owner_label` instead of `owner_raw`; `annotate_cdn_domain` resolves incoming `owner_raw` → `owner_person_id`; `GET /cmdb/cdn-domains` accepts `?owner_person_id=` filter. Frontend: `CloudCdnPage` owner column uses `owner_label`; edit drawer uses `UserSelect` (same pattern as CloudDomainsPage). |
| 159 | **Normalize residual `cmdb_iam_users.sub_account_id='__master__'` → `''`.** Heals the data-shape bug introduced by migration 120: that migration backfilled `sub_account_id` from `tags->>'__sub_account_id'` verbatim, but for AlicloudIntl master-account RAM users that JSON tag literally contains the sentinel `"__master__"`. The runtime upsert in [`_sync_iam_users`](../../server/app/domains/cmdb/sync.py) normalises `"__master__"` → `""` for the unique key — but the migration didn't, so 16 master rows in prod stayed orphaned at `sub_account_id='__master__'` while every subsequent sync re-inserted them with `''`. The `_safe_sweep` >5%-drop guard refused to clean up (existing=92, returned=76) and the IAM sync job has been `last_status='failed'` since 2026-05-05 — 79 consecutive failures. Two-step heal (MySQL/TiDB reject `UPDATE ... WHERE EXISTS (SELECT FROM same_table)` — error 1093): (a) materialise the id-list of `__master__` rows whose `''` counterpart already exists for the same (integration_id, user_name), soft-delete those orphans (`deleted_at=NOW()`) so the live `''` row remains authoritative — preserves `owner_person_id` audit history; (b) rewrite the rest in place. Idempotent re-run is a no-op; downgrade is intentionally a no-op (reversing would re-break the bug). Companion code change: shared helper `normalize_iam_sub_account_id` in [`iam_upsert.py`](../../server/app/domains/cmdb/iam_upsert.py) is now the single source of truth for the `(None|''|"__master__") → ''` mapping so future drivers can't drift again. **Originally landed as 158 in PR #278, renumbered to 159 in a fast-follow when alembic two-head conflict with PR #277's `158_domain_environment_dns_gate` was caught.** |
| 158 | **Add `cmdb_domains.environment` + `allow_dns_request` columns + fix domain-list load crash** (PR #277). Phase 1 of the domain-environment gate that lets admins mark a domain as `prod` / `staging` / `pre-prod` / `dev` and selectively allow self-service DNS-record requests against `dev` / `staging` domains. The pre-fix domain list page hard-failed on render when any historical row had a NULL environment value. Companion frontend rewrite of the DNS request form. |
| 157 | **Consolidate frontend base-URL platform setting + re-enable `notification.outbox_retry` cron** (PR #275). DELETEs the stale `platform_settings.portal.base_url='http://localhost:3000'` row that 7 caller-side notification sites were pre-pending into `url=` and `context["portal_url"]`, causing the persistent `localhost:3000` leak in Feishu cards + email bodies even after `app.frontend_base_url` was set correctly. All callers switched to read `app.frontend_base_url` as the single source of truth. Also flips the outbox-retry job back to `enabled=1` so transient Feishu/email failures recover instead of becoming silent loss. Downgrade re-inserts the deprecated row with empty value (not the leaked `localhost:3000`) so a rollback doesn't restore the leak. |
| 156 | **Strip residual multi-line continuation blocks from `offboarding_blockers.notes`.** Migration 148's single-line strip regex missed the `[SQL: ...]` / `[parameters: ...]` / `(Background on this error at: ...)` lines of the legacy IntegrityError dumps; the round-22 follow-up to round 21 (PR #273) cleans them up. Idempotent in Python (regex flag semantics differ between TiDB and MySQL 8.0). |
| 155 | **Seed `asset.held_by_leaver_reminder` scheduled job** (cron `0 9 * * 1`, timezone `UTC`, enabled, config `{}`). Weekly Monday-morning digest that counts `asset_items.status='held_by_leaver'` and pushes ONE in-app notification per active IT admin / global admin per ISO-week. Per-week idempotent via `notification_outbox` (template_key + recipient + week_start_dt probe). Silence-by-default: held_count=0 produces no notification (no news = good news). Pairs with the new `held_by_leaver` non-standard asset state (set by `OffboardingService.skip_blocker` on hardware blockers + manual admin entry point `POST /assets/{id}/mark-held-by-leaver`); recovery via `POST /assets/{id}/recover-from-leaver` flips status back to `idle`. No new column or table — the status string lives in the existing `asset_items.status` `VARCHAR(32)` column. Renumbered from 149 → 155 during rebase: migrations 149-154 (external-origin approval flows + AI-spending Phase 2 cleanup, PRs #256/#257/#258) landed on main ahead of this branch. |
| 154 | **Delete `ai_spending.azure_openai_resources_sync` scheduled_jobs row.** Companion to migration 151's drop of `cmdb_ai_resource_cost_daily`. The handler was removed in PR #257 (Phase 2 AI Spending refactor — 4-bucket model). Only caller of that table was this cron; with both gone the row is dead. Idempotent `DELETE WHERE handler_key='ai_spending.azure_openai_resources_sync'` — no-op on tenants that never ran past migration 079. Downgrade re-seeds the row (assumes Phase 1 code is back); safe direction in practice is one-way since the handler module no longer exists. |
| 153 | **Drop `cmdb_cloud_scopes.is_ai_dedicated` column.** Companion to migrations 151 + 152. The boolean drove the "100% AI subscription" attribution path; in the 4-bucket model AI cost is identified at the cost-fact row level via the service whitelist (migration 150), so the per-scope flag is no longer load-bearing. PR #257 (Phase 2 AI Spending refactor). Downgrade re-adds the column NULLable so on rollback the legacy reader at least won't crash; tenants on rollback get blank "is_ai_dedicated" for every scope until they re-flag, which is the honest pre-migration state. |
| 152 | **Drop `cmdb_ai_resources` table (+ FKs).** Phase 2 retires the parallel AI-resource ledger; the dashboard now reads `cmdb_cost_facts` directly via the service whitelist. Drop order: FK from `cmdb_ai_resource_cost_daily` is gone (migration 151), then the table itself, then any inbound FKs from soft-references (none observed in prod). Idempotent `DROP TABLE IF EXISTS`. Downgrade re-creates the empty schema; data is not recoverable post-drop (a backup is in TiDB binlog for 7 days but the migration doesn't attempt a restore). |
| 151 | **Drop `cmdb_ai_resource_cost_daily` table.** Daily cost facts for AI resources duplicated `cmdb_cost_facts` rows and routinely drifted. Phase 2 model uses `cmdb_cost_facts` directly. Idempotent `DROP TABLE IF EXISTS`. Downgrade re-creates the empty schema. |
| 150 | **Seed `platform_settings.ai_spending.service_whitelist`** (JSON object keyed by provider slug). Initial value covers Azure Foundry / OpenAI / Cognitive Services + AWS Bedrock + Cohere/Anthropic Bedrock editions; GCP list empty for now. Loaded at request time by [`app.domains.ai_spending.whitelist.load_whitelist`](../../server/app/domains/ai_spending/whitelist.py) — admin edits propagate without a deploy. Validation at write time: max 64 patterns per provider, 128 chars per pattern, unknown provider keys rejected. Idempotent `WHERE NOT EXISTS key='ai_spending.service_whitelist'`. |
| 148 | **`offboarding_blockers.last_error_*` columns** + migration that backfills legacy `[T+0 自动转让失败] ...` lines from `notes` into `last_error_message` / `last_error_at` / `error_count`. Pairs with the round-18 unified offboarding console which renders structured per-blocker error state instead of free-text notes. |
| 147 | **Seed `cmdb.cost.nightly_recompute_allocation` scheduled job** (cron `30 3 * * *`, timezone `Asia/Shanghai`, enabled, config `{"days": 90}`). Nightly auto-recompute that closes the gap left by PR #241: scope-owner fallback only takes effect on NEW fact writes via `ingest_cost_facts`; existing facts written before the fallback existed stay un-re-attributed until someone clicks the manual "重算分摊" button. Prod evidence (2026-05-10): $7,316.10 / 99.8% 未分摊 was 100% attributable but never recomputed. The 03:30 UTC = 11:30 SH slot sits after the per-integration `cmdb.cost_facts.<slug>` daily ingest (minute 4-offset by integration slug hash, migration 140), so the recompute walks a freshly-ingested table. Idempotent `WHERE NOT EXISTS handler_key='cmdb.cost.nightly_recompute_allocation'`. Failure semantics inherit the platform-wide failure-notifier via `register_scheduled_job` — no bespoke wiring. |
| 145 | **`cmdb_cost_facts.auto_owner_source`** (`VARCHAR(16)` NOT NULL DEFAULT `'none'`) + secondary index `ix_facts_source_date(auto_owner_source, date)`. Records which path stamped `cost_owner_person_id` on the row: `rule` (matched a `CmdbCostAllocationRule` admin override), `scope_owner` (no rule matched, but `fact.scope_id` resolved to an active `cmdb_cloud_scopes` row whose `owner_person_id` was set — the new fallback layer added in this same PR), or `none` (genuinely unattributed → surfaces under the dashboard's 未分摊 KPI). Stored as VARCHAR not ENUM so a future `iam_owner` / `rg_owner` etc. addition doesn't require an `ALTER TYPE` on TiDB. Default `'none'` so existing rows backfill safely without scanning every row (TiDB handles this as a metadata-only DDL). Powers (a) the dashboard 未分摊-KPI tooltip's per-source counts, (b) the recompute summary's `{matched_by_rule, matched_by_scope, still_unallocated}` breakdown, and (c) future per-source filtering. Pairs with `_build_scope_owner_lookup` + the rewritten `ingest_cost_facts` / `recompute_allocation` two-stage attribution chain in `cost_service.py`. **Production state at fix time**: 10,502 facts / $7,332.99 over 90 days were 100% unattributed because the rule table was empty AND the engine had no fallback; post-fix the chain `fact.scope_id → cmdb_cloud_scopes.owner_person_id → identity_persons.primary_org_membership.leaf_dept` attributes 100% of the same dataset on the next recompute. |
| 194 | **Seed `storage.s3.{bucket,region,key_prefix}` into `platform_settings`.** Three rows pointing at `helix-prod-uploads-ap-southeast-1` / `ap-southeast-1` / `""` (empty prefix). The runtime resolver in [`server/app/core/storage.py`](../../server/app/core/storage.py) force-overrides `key_prefix` to `"dev/"` whenever `APP_ENV != "production"` so a local dev DB pointed at the prod bucket can never write into the prod top-level namespace. Idempotent — re-run leaves admin-edited rows untouched. Downgrade deletes the three rows. |
| 195 | **`asset_inventory_items.evidence_photos JSON NULL`.** Per-item list of S3 object keys uploaded by the holder when responding with `response_type='not_mine'` (`记录有误，不属于我`). Schema-level validation lives in [`inventory_schemas.ItemRespondRequest`](../../server/app/domains/asset/inventory_schemas.py) (≥1 photo required when `response_type='not_mine'`, ≤10 photos, ≤512 chars per key); service-level defence-in-depth in [`inventory_service.respond_to_item`](../../server/app/domains/asset/inventory_service.py) drops the list on every non-`not_mine` branch so a stray payload can't leave orphan keys on a confirmed-mine row. Admin's `ResolveModal` resolves each key to a presigned-GET URL via `POST /api/v1/uploads/presign-get` and renders an `Image.PreviewGroup`. JSON column (not TEXT) so MySQL/TiDB validates the array shape on write. Idempotent column-add. |
| 197 | **Refresh `scheduled_jobs.description` for `asset.inventory_reminder`** to reflect the new daily-in-local-working-hours cadence. The behaviour change itself lives in code: [`inventory_service.send_cycle_reminder`](../../server/app/domains/asset/inventory_service.py) gains a `working_hours_only` flag and a per-recipient local-day dedup against `asset_inventory_items.reminded_at`; the grace window collapses from 7 days to 1; the cron handler ([`asset_inventory_handlers.run_inventory_reminder`](../../server/app/scheduled_jobs/asset_inventory_handlers.py)) passes `working_hours_only=True` so global hourly ticks only deliver inside each recipient's Mon–Fri 10:00–17:59 local window. Country→IANA TZ lookup in the new util [`app/core/working_hours.py`](../../server/app/core/working_hours.py); unknown country falls back to `Asia/Shanghai`. Companion shared `sanitize_evidence_photo_keys` helper in [`inventory_schemas`](../../server/app/domains/asset/inventory_schemas.py) is now called from both the Pydantic field validator AND `inventory_service.respond_to_item` so internal callers (tests / scripts) can't slip oversized / path-traversal-shaped keys past the route boundary. Frontend bug fix: [`MyInventoryPage` `RespondModal`](../../web/src/pages/asset/MyInventoryPage.tsx) detaches the photo `<Upload.Dragger>` from its `<Form.Item name="evidence_photos">` parent — that pairing made Antd auto-inject an `onChange` that fired with `{file, fileList}` event objects on every status change (including remove), racing the explicit setFieldValue calls and landing an event object in the form state; "修改回应 → 删一张照片 → 提交" reproducibly hit Pydantic's "Input should be a valid list". Photos now live in `photoList` state, validated manually at submit time; submit throws on validation failure so the `<Modal onOk>` doesn't close on a swallowed error. Migration is description-only and idempotent. Admin's `POST /cycles/{id}/remind` still works as a manual override (bypasses working-hours gate; same-local-day dedup respected unless `?force=true`). Tests: [`tests/test_core/test_working_hours.py`](../../server/tests/test_core/test_working_hours.py) (16 cases) + [`tests/test_asset/test_inventory_reminder.py`](../../server/tests/test_asset/test_inventory_reminder.py) (8 cases incl. null display_name fallback + photo-key sanitiser). |
| 200 | **`apikey_assignment_members` — shared-access roster for API Keys.** New table `(id CHAR(36) PK, assignment_id FK CASCADE → api_key_assignments.id, person_id FK CASCADE → identity_persons.id, added_by FK SET NULL → identity_persons.id, added_at DATETIME, created_at DATETIME)` with `UNIQUE(assignment_id, person_id)` + indexes on `(person_id)` and `(assignment_id)`. FK column types mirror their targets via the `_fk_source_col_type` helper introduced in migration 192 (TiDB-strict-collation defence — naïve `CHAR(36)` rejected with `ERROR 3780`). Powers the new "Owner-maintained team-mate roster" surface on each API Key: members see the Owner's spend / detail / reveal surfaces but cannot perform Owner-only mutations. Authz helper `ApiKeyService.is_owner_or_member` is the single source of truth all read paths consult. Companion code: (a) `apply_transfer` auto-promotes the old Owner to a member after a transfer accept (preserves visibility through the handover), (b) `LeaverBlockGuard.flip_to_left` calls `cleanup_leaver_memberships` to drop a left employee from every roster row, (c) `reveal_key` PAT-gate now also recognises members (admin-on-behalf-of still requires browser session). HTTP trio `GET / POST / DELETE /apikeys/assignments/{id}/members[/  {person_id}]` + MCP trio `list_api_key_members` / `add_api_key_member` / `remove_api_key_member` (`_REGISTERED_TOOLS_COUNT_HINT` 175 → 178). Idempotent table-create; downgrade drops the table. |
| 208 | **Strip Slack-specific copy from the generic `manual_notify` template.** Data-only (no DDL). `DELETE`s the `platform_settings` row `email_template.provisioning.manual_notify` seeded by migration 056 — guarded by `value LIKE '%Slack 升级到 %'` so an admin rewrite that already removed the Slack-only sentence is left untouched. With the row gone, `_load_template` falls back to the clean built-in default `_DEFAULT_TEMPLATES["provisioning.manual_notify"]` (bilingual zh+en, channel-aware `{cta_line}`, no provider-specific text), making the built-in the single source of truth. Why: every `manual_notify` task (any provider) renders through this one template, so the seeded row's "如果该软件后续升级到… Slack Business+…" sentence leaked onto unrelated pushes (Jira leaver-revoke). No-op downgrade. Pairs with two code fixes in the same PR: (a) `ProvisioningService._resolve_execution_mode` is now target-aware — `account`/`api_key` revoke tasks (which carry `software_id=None` by design) resolve to `auto_api` instead of being downgraded to `manual_notify`, so Jira `revoke_all_access` (#537) actually auto-runs instead of spamming "请手工回收 (未绑定软件)" cards; (b) `NotificationService.render_inbox_body` + `InboxService._serialize_rows` render the template body for the in-app bell so the notification center shows real copy instead of the `(see template)` sentinel. 0 cron / 0 RBAC / 0 MCP-tool-count change. |
| 207 | **Seed `jira.leaver_account_audit` scheduled job** (`cron='30 3 * * *'` UTC, **`enabled=1`**, `config={}`). Read-only governance reconcile that cross-checks the Atlassian Org Admin managed-account roster (`GET /admin/v1/orgs/{org}/users`) against Helix `employment_status='left'` people and reports anyone still holding `product_access` (i.e. still in some group) — the safety net for offboarding's new remove-from-ALL-groups (`JiraSoftwareDriver.revoke_all_access`). Detection only (never modifies access; pushes a dedup'd IT-admin card). Seeded enabled because it only exercises the same read scope `software_seat.idle_reclaim` already uses. Idempotent `WHERE NOT EXISTS name=...`; downgrade deletes by name. Pairs with the offboarding `software_account` blocker + `target_type='account'` provisioning dispatch + driver-base `revoke_all_access`/`supports_full_access_revoke` hook — all code (no other schema). 0 RBAC / 0 MCP-tool-count change. |
| 271 | **`platform_policy_notices` grows into 制度中心 (searchable policy library).** Adds `summary_zh` / `summary_en` (`VARCHAR(500)` NULL — library-card summary, falls back to title), `keywords` (`JSON` shared tag array, language-agnostic), and splits the single `enabled` flag into two: `is_published` (employee can see/search it in the 制度中心 library) + `pin_to_banner` (ALSO rotates in the header ribbon). Backfill maps the legacy `enabled` → both flags (an enabled notice was visible AND rotating), then drops `enabled` (no dead column); downgrade rebuilds `enabled` from `is_published`. Service enforces the invariant `pin_to_banner ⟹ is_published` (banner is a curated subset of the library; unpublishing auto-unpins). New public read `GET /api/v1/policy-notices/library?q=` (any signed-in user; AND-token search over title/summary/keywords in either language, Python-filtered over the small published set) alongside the existing banner read (now pinned-subset only). New employee page `/policy-center` (universal top-nav, read-only card grid + search); admin page extended with summary / keyword-tag / publish / pin controls. **MCP-sync (+1)**: `search_policy_notices(query?)` mirrors the library read 1:1 (read-only); `_REGISTERED_TOOLS_COUNT_HINT` 262 → 263. Also fixes a latent `NotFoundError(...)` one-arg call in the service (was a `TypeError`/500 on update/delete of a missing id, now a clean 404). Idempotent column-add (information_schema guards); FE/i18n zh/en/ja. |
| 272 | **created_at indexes for the execution-plane health surfaces** (post-review fix, 2026-06-10). `ix_notification_outbox_created_at` + `ix_provisioning_tasks_created_at` — the `automation-health` insights endpoint and the outbox `error-clusters` view filter both tables by `created_at` ranges; neither had an index, so every admin-page load full-scanned two unbounded tables. information_schema-guarded for re-run convergence; symmetric downgrade. |
| 273 | **`identity_sync_run_items` — 逐条同步台账** (Authing 替代 PR2/5, #973). One row per (run, person, operation) with full request/response round-trip for the 数据同步 per-run drill-down. Person FK created out-of-line after `normalize_fk_column_collation` (prod TiDB 3780 — see #980). |
| 274 | **`identity_sync_vendor_records` — 对端数据视图** (Authing 替代 PR3/5, #974). One row per vendor-side user/department/group from the read-only snapshot refresh; backs the 对端数据 tab + PR4 drift diff. Same out-of-line person-FK treatment as 273. |
| 275 | **Seed the `asset_global_admin` role (全局资产管理员, 2026-06-11).** 9th canonical RBAC role: manually-granted global tier of the asset domain — full asset write authority over EVERY office, zero authority outside the asset domain. Idempotent `INSERT INTO auth_roles` (dashed `UUID()`); downgrade deletes assignments then the role row. Code companions (same PR): `rbac.ASSET_GLOBAL_ADMIN_ROLE` + `has_global_asset_scope()` (replaces the 7 hand-rolled `"global_admin" in role_names or "it_admin" in role_names` short-circuits across `person_can_manage_asset`, the asset-router `_enforce_*_region`/`_assert_target_*` gates, and their 2 MCP twins); holders co-derive `asset_admin` via `office_admin_sync._person_should_hold_role` + the daily reconciler's should_set union, with immediate sync on the 3 role grant/remove endpoints; `apply_view_role_narrow` collapses asset_admin ⇄ asset_global_admin narrowing to the pair for holders (the asset-admin chip must not strip global scope); `/auth/me` materializes ALL office ids into `managed_office_ids` for holders; leaver flip strips the grant alongside the office-admin sweep. Tests [`tests/test_asset/test_asset_global_admin.py`](../../server/tests/test_asset/test_asset_global_admin.py) (registered in the `asset-a` CI shard). 0 cron / 0 MCP-tool-count. |
| 321 | **Dedupe the orphan duplicate `NexusCRM Seat` plan left by 315+317** (post-merge audit, 2026-06-22). On the prod env 317 targets, 315's plan guard (`name='NexusCRM Seat'`) missed the pre-existing `Default` plan and inserted a second empty $0 `NexusCRM Seat` plan; 317 then renamed `Default`→`NexusCRM Seat`, leaving TWO same-named active plans (no `(software_id,name)` unique key). Forward-only cleanup: deletes a duplicate ONLY when it has zero references across all six plan-referencing tables (`software_seat_allocations` / `software_external_holder` / `software_seat_sync_stat` / `software_monthly_bills` / `software_monthly_bill_history` / `procurement_order_items`.`target_license_plan_id`), so the seat-holding plan is always the keeper. No-op on fresh/single-plan envs; idempotent. No FK / no cron / no MCP / no RBAC. |
| 328 | **Software catalog description.** Adds the long-form software catalog description field used by the admin/software catalog surfaces. |
| 329 | **Finance fiscal-year start month seed.** Seeds `finance.fiscal_year_start_month` in platform settings without clobbering administrator edits. |
| 330 | **Security quiz UUID normalization.** Normalizes `secquiz_*` UUID values to the platform GUID format and pairs with the model type correction. |
| 331 | **Data-access resources (DAX-006).** Creates `data_access_resources`, the projected BigQuery dataset identity/resource-owner table used by request validation and callbacks. Active locator uniqueness is DB-enforced with the generated `active_locator_key`; unresolved/fallback owners do not grant runtime owner authority. |
| 332 | **Data-access entitlement catalog (DAX-007).** Creates `data_access_entitlements` and seeds the single V1 `read:v1` semantic entitlement for BigQuery dataset reads by Google Groups, compiling to `roles/bigquery.dataViewer` and `group:` provider members. |
| 333 | **Data-access requests/items (DAX-008).** Creates `data_access_requests` and `data_access_request_items` for Helix-originated approval intent, Feishu instance linkage, idempotency, and resource/group staleness snapshots. No binding/effect/provider mutation is created by these rows alone. |
| 334 | **Data-access bindings (DAX-009).** Creates `data_access_bindings`, the approved semantic desired-state row keyed by resource, `email_groups.id`, and entitlement. Current uniqueness is DB-enforced with `active_semantic_key`; provider email snapshots are compile/revoke evidence only. |
| 335 | **Data-access compiled effects (DAX-010).** Creates `data_access_compiled_effects`, the reconciler input table for provider-specific desired output, including target locator, provider member/role, desired state, provenance, status, provider outcome, and adoption marker. |
| 336 | **Data-access effect runs and reconcile rollups (DAX-026).** Creates `data_access_effect_runs` and adds compact reconcile roll-up fields to requests/bindings so observe/apply attempts have durable per-effect evidence. |
| 337 | **Data-access resource labels (DAX-045).** Adds `data_access_resources.labels` for catalog-projected request display and audit context. Runtime owner authority remains `owner_person_id` plus `owner_resolution_status`. |
| 338 | **Data-access resource catalog id (DAX-049).** Adds nullable `data_access_resources.catalog_resource_id` plus `ix_data_access_resources_catalog_id` so catalog feed replacement can match stable catalog identities before provider locators. |
| 317 | **Rename NexusCRM catalog + seat plan to professional names** (2026-06-22). Cosmetic data fix: prod had a pre-existing `Nexuscrm` catalog (lowercase) with a generic `Default` plan that migration 315 attached to (case-insensitive name match) instead of creating a fresh one. Normalises the display names the 软件费用/坐席分配 page shows — catalog `Nexuscrm`→`NexusCRM`, its `Default` plan→`NexusCRM Seat`. Scoped (only a `LOWER(name)='nexuscrm'` catalog + ONLY that catalog's `Default` plan) + idempotent; no-op on fresh envs where 315 already created `NexusCRM`/`NexusCRM Seat`. Ships with the `hide_free` cost-stats param + frontend「不显示免费坐席」toggle (so ¥0 NexusCRM seats are visible on that page). |
| 316 | **`feishu_helpdesk_tickets` — 飞书服务台工单镜像 + 12h 同步任务** (2026-06-22). External read-only mirror of Feishu Helpdesk (Lark 服务台) tickets for the 服务台分析 reports (`app/domains/feishu_helpdesk`). No hard FK (external mirror + TiDB CHAR/VARCHAR FK strictness); `integration_id` / `guest_person_id` are plain indexed CHAR(36). Durations stored verbatim (seconds; API `-1` → NULL at sync). Seeds the `feishu.helpdesk_sync` scheduled job at `interval_seconds=43200` (12h), enabled (no-ops until a `helpdesk`/`feishu` integration row exists). New driver `feishu_helpdesk` + MCP `get_feishu_helpdesk_analytics` (hint 329→330). Downgrade deletes the job + drops the table. |
| 276 | **idle_reclaim job-description correction (data-only)** — the reclaim step was rewired to the `product_access` pipeline (multi-group holders auto-reclaimed; manual-review punt removed); the 204-seeded `scheduled_jobs.description` still claimed 「多组授权一律不自动回收」. WHERE-guarded UPDATE keyed on the stale claim (admin edits never clobbered), verbatim restore on downgrade. Mirrors the 209 precedent. |
| 254 | **`platform_policy_notices` — 制度公告 (policy / manual notice) ribbon.** New table backing the header `PolicyNoticesBanner`: bilingual title (`title_zh` / `title_en`, both NOT NULL) + `feishu_url` (the click target, http(s)-validated at the schema layer) + per-row `enabled` + `display_order` + audit columns (`created_by_person_id` / `updated_by_person_id` FK → `identity_persons.id`, CHAR(36) via `id_type_for`). Mirrors the `platform_security_tips` shape (migration 100): admin-CRUD + public-read + bulk reorder, `require_admin` (global_admin) on mutations / `require_config_read` on the admin list / any signed-in user on the public read. Reuses the header slot freed when security tips moved to the IT-service-desk mascot bubble — the ribbon cycles enabled notices in display order and links each to its Feishu doc; renders an invisible flex spacer when zero enabled. New domain `app/domains/policy_notices/` + routers `/api/v1/policy-notices` (public) + `/api/v1/admin/policy-notices` (CRUD/reorder) + FE admin page `/admin/policy-notices`. Seeds 3 starter notices (AI 使用手册 / AI 帐号&API 申请指引 / 全球 IT 硬件资产管理系统 → their Feishu wiki URLs) in display order, empty-table-only so re-run / admin edits never double-insert (hyphenated uuid4 ids). Idempotent CREATE (information_schema guard + VARCHAR-id drop-for-converge); downgrade drops the table. 0 cron / 0 RBAC role / 0 MCP-tool-count change (admin-config CRUD, no self-service flow — matches the security-tips precedent which also ships no MCP tool). |
| 238 | **Feed edit-history columns — `feed_posts.last_edited_at` / `feed_posts.edit_count` / `feed_comments.last_edited_at` / `feed_comments.edit_count` + `feed_comments.updated_at` (back-filled from migration 235).** (Row number corrected 2026-06-11 — this is `238_feed_edit_history.py`; it was previously mislabeled 237.) Powers the author-edit feature added 2026-06-01: `PATCH /api/v1/feed/posts/{id}` and `PATCH /api/v1/feed/comments/{id}` rewrite the body and bump `last_edited_at`/`edit_count`. The FE renders a "已编辑" Tag whenever `last_edited_at` is non-null — it never surfaces the previous body. Full before/after lives in [`audit_logs`](../../server/app/audit/service.py) under action `feed.post.edit` / `feed.comment.edit` so admins (HR + IT) can drill into the history via `/admin/audit`. `last_edited_at` is intentionally separate from `updated_at` (the latter auto-bumps on counter increments such as `like_count` / `comment_count` write-through, which shouldn't flip the "已编辑" badge). System posts (`post_type` ∈ `system_birthday` / `system_anniversary` / `aggregate_*`) and blessing-mirror comments (`blessing_id IS NOT NULL`) are **not** editable — admins can soft-delete them via the existing route, but rewriting a system-authored row or breaking the 留言板 1:1 with `feed_blessings` would corrupt the celebration contract. Schema additions are pure ADD COLUMN (nullable + zero defaults), idempotent via information_schema, downgrade drops in reverse. 0 cron / 0 RBAC role / 0 MCP-tool-count change. |
| 234 | **Re-trigger `hris.full_sync` after fixing `list_all_users` filter bug** (renumbered 233 → 234 because PR #637 and PR #639 both landed migration 233 concurrently — git saw no conflict since the filenames differed, but Alembic refused with "Revision 233 is present more than once" and blocked every post-merge deploy until this PR). UPDATEs the seeded `scheduled_jobs.hris.full_sync.last_run_at` back to `1970-01-01` when its current value > `1970-01-02` (idempotent). Why: migration 230's first run on 2026-06-01 08:19 UTC fired correctly but staged 0 offboard events because `SuccessFactorsClient.list_all_users` omitted `$filter` entirely — PingCAP's SF tenant returns only `status='t'` rows without an explicit filter. PR #637 fixed `list_all_users` to pin explicit `$filter=status eq 't' or status eq 'f'`; this migration arms the missed-beat re-fire so the next worker restart picks up the corrected SF call. After the second run the cron self-updates `last_run_at` to `now` and the row settles into its weekly Sunday 05:00 SGT cadence. 0 schema change. |
| 232 | **`auditor_privileged_role_holders` — `assigned_to_person_id` + `decided_at_deadline` + `escalated_at` columns + new `auditor.privileged_review_escalate` cron (PR-5 of symmetric-onboarding, 2026-06-01; renumbered from 229 — PR-1 took 229, PR #635 took 230, PR-2 took 231).** Promotes the per-quarter access-certification primitive to SailPoint / Okta IGA shape: per-row reviewer assignment, per-row deadline override, 14d-past-deadline escalation that writes a `compliance_incident` (type `access_review_overdue`, severity warning) once per holder (idempotent — re-running is a no-op once `escalated_at` is set). 2 composite indexes: `(decision, decided_at_deadline)` for the daily sweep + `(assigned_to_person_id)` for the self-scoped "my pending reviews" tool. Cron `auditor.privileged_review_escalate` seeded enabled at `30 3 * * *` Asia/Shanghai. 2 new MCP tools (`_REGISTERED_TOOLS_COUNT_HINT` 221 → 223): `assign_privileged_review_holder` + `list_my_pending_privileged_reviews`. New compliance_incident type `access_review_overdue`. Idempotent column-add + cron-seed (information_schema probes + NOT EXISTS). Downgrade drops the cron row + the 3 columns + 2 indexes. 0 RBAC role change. |
| 231 | **Provisioning bundles — `provisioning_bundles` + `provisioning_bundle_members` + `provision_policies.bundle_id` (PR-2 of symmetric-onboarding, 2026-06-01; renumbered from 230 — PR #635 took 230 first).** Bundles group N ProvisionPolicy rows under named kits ("SDE 标准包" / "销售标准包" / "管理者标准包" — 3 starter bundles seeded). Admin applies a bundle to a single onboarding process via the cockpit detail page (`POST /bundles/{id}/apply-to-person`) — fans out `ProvisioningTask` rows with `source='bundle_apply'` (new VALID_SOURCES entry) for every active policy in the bundle that the person doesn't already have. Matching engine (`_match_policies_for_person`) is UNCHANGED — bundles are a grouping + one-click apply surface. `bundle_id` FK on `provision_policies` is SET NULL ondelete so retiring a bundle leaves the individual policies intact. 8 new HTTP endpoints + 4 new MCP tools (`_REGISTERED_TOOLS_COUNT_HINT` 213 → 217). Frontend: new `OnboardingProcessDetailPage` (3 blocker buckets + per-row inline actions + apply-bundle dropdown) + `ProvisioningBundlesPage` (CRUD + member drawer) mounted as the 4th tab of `OnboardingManagementPage`. Idempotent seed (NOT EXISTS by name). Downgrade drops FK + column + both tables. 0 cron / 0 RBAC role change. |
| 230 | **Seed `hris.full_sync` weekly cron + immediate-fire trick.** Inserts one row in `scheduled_jobs` (`handler_key='hris.full_sync'`, `cron_expr='0 5 * * 0'`, `timezone='Asia/Singapore'`, `enabled=1`, `last_run_at='1970-01-01'`). The far-past `last_run_at` triggers `worker.py:310-328`'s "missed beat" branch — after deploy the worker computes "next fire after 1970" (in the past), schedules an immediate run ~90s after boot, then the cron settles into its real weekly cadence (`0 5 * * 0` Sun 05:00 SGT). Companion handler: [`run_hris_full_sync`](../../server/app/scheduled_jobs/handlers.py) calls `SuccessFactorsLifecycleService.run_scheduled_full` per HRIS instance → `preview_full_employee_sync` (PR #588 `list_all_users` path) → `confirm_batch` with auto_confirm. Mass-leaver brake already exempts `sync_mode='full'` (`hr/service.py:560`); historical leavers land via `_upsert_person` INSERT-time leaver cutoff, no offboarding side effects fire. Why this exists: incremental `hris.incremental_sync` is bounded by a 30-day lookback hard cap (`run_scheduled_incremental`) so any SF `status='f'` user whose `lastModifiedDateTime` is older than 30d lives in a permanent blind spot. The wangdi P0175 case (terminated 2026-03-20, missing for 73 days, surfaced 2026-06-01) is one of 48 such historical leavers prod was carrying. PR #588 fixed the code; this migration wires the code into the scheduler so the gap auto-heals weekly. Idempotent (existing same-name row preserved). Downgrade deletes the seeded row. 0 schema change / 0 RBAC role / 0 MCP-tool-count change. |
| 229 | **Onboarding domain state-machine tables + 3 cron seeds + active-hire backfill.** Creates `onboarding_processes` (UNIQUE person_id, hire_date, status ∈ scanning/preparing/ready/live/completed/cancelled, stage timestamps, current_step + step_log JSON, first_ungated_at + sla_breach_level, workflow_run_id, default_buddy_person_id FK SET NULL, hardware_allocation JSON, backfilled BOOL) + `onboarding_blockers` (process_id CASCADE, resource_type, resource_id nullable, label, severity blocker/advisory, resolution pending/provisioned/skipped/identity_sync_managed, provisioning_task_id FK, last_error_* + error_count) + `onboarding_reminder_log` (UNIQUE process_id × reminder_kind). Seeds 3 scheduled_jobs rows (`onboarding.reconcile_processes` 5min, `onboarding.escalation_tick` 30min, `onboarding.t7_verify` daily 02:30 SH). Backfills every active person with hire_date ∈ [today-60d, today+60d]: state bucketed by distance from today, `backfilled=1` so T+7 verify skips compliance_incident emission. Pairs with the new `OnboardingService` state machine + `OnboardingT0AdvanceService` + `OnboardingT7VerifyService` + 10 admin endpoints + 8 MCP tools (`_REGISTERED_TOOLS_COUNT_HINT` 205 → 213) + HR sync coordinator hook calling `start_process` unconditionally on every confirmed hire. New compliance_incident type `onboarding_day1_gap`. Idempotent (NOT EXISTS guards on backfill + seed rows). Companion FE: cockpit + offboarding cockpit fold into management-page tabs (legacy /admin/{on,off}boarding/cockpit redirect to ?tab=cockpit). 0 RBAC role added (admin-gated via existing `require_admin` / `require_admin_read`). |
| 228 | **`asset_software_catalog.version` column + PCI inventory backfill + 褚杰→孟凡生 rename.** Adds `version VARCHAR(64) NULL` to `asset_software_catalog` for the free-text product version / edition (e.g. `Enterprise Standard`, `2.222.4`, `Cloud Service`). Same migration backfills `version` and `remark` for the 19 rows whose `name` matches the PCI DSS Software/System Inventory case-insensitively (`remark` is filled ONLY when currently empty — admin-curated text is preserved). Same migration also renames 褚杰 → 孟凡生 across the platform: `identity_persons.display_name` + `asset_software_catalog.{owner, it_contact}` exact matches + `REPLACE` on `asset_software_catalog.remark`. Idempotent (UPDATE no-ops when the source already matches the target); `downgrade()` reverses the rename and drops the `version` column (remark backfill intentionally NOT reverted — we cannot distinguish "we wrote this" from "admin edited it"). Pairs with the schema additions (`SoftwareCatalogCreateRequest` / `UpdateRequest` / `Response` all carry `version`), the new `GET /api/v1/assets/software-catalog/export.xlsx` admin endpoint (filter superset of the list endpoint, 5k row cap, audit `asset.software_catalog.export`, CSV-injection defense), and the FE 「导出 Excel」 button on `/admin/assets/software-catalog`. 0 cron / 0 RBAC role / 0 MCP-tool-count change. |
| 227 | **Retire the unverified `Confluence (PingCAP US)` subscription to "pending contract" + stop counting raw `product_access` as paid Confluence seats.** Data-only (no DDL). Root cause (prod audit + Atlassian renewal contract 2026-05-31): seat_sync derived `vendor_license_holders` from Org-Admin `product_access` and treated it as the paid-seat count — correct for Jira/JSM (holders ≤ contracted `max_seats`: 372≤400, 71≤100) but **wrong for Confluence**, where ~106 (tidb) + ~28 (pingcap-us) active holders exist against a **10-seat** contracted Cloud Premium subscription (Confluence grants broad access that doesn't consume a paid license). The display layer now clamps each plan to its contracted ceiling via [`billing_service._effective_seats`](../../server/app/domains/asset/billing_service.py) (`min(vendor_license_holders, max_seats)`; `None` → raw count) — applied at the three count chokepoints (`_vendor_seats_by_software`, router `_compute_seat_reconciliation_overview`, router seat-recon `_detail`) so SoftwareCostPage「生效坐席」, the seat-recon overview, and the drawer all show the contracted paid number (Confluence→10, Jira 372 unchanged). `vendor_license_holders > max_seats` surfaces `under_licensed=True` + `licensed_seats` in the overview/detail responses → FE shows an under-licensing badge/alert ("active access N vs contracted M") instead of silently hiding the over-use. The family-dedup key (`_stat_family_key`) is **deliberately unchanged** (its `max` is load-bearing for same-site add-ons). This migration handles the pingcap-us org (genuinely independent paid org, but its contract isn't on file): set its plan/connector `inactive`, DELETE its stale `software_seat_sync_stat` row (the fabricated ~30 that `max`-collapsed the headline), resolve its `software_external_holder` rows, and remark the catalog "pending contract". `get_jira_plans` (status='active' only) then skips it. **When the pingcap-us contract arrives**: reactivate + set `max_seats` + move `_stat_family_key` to `(product_key, frozenset(paid_sites))` so the two disjoint-site subscriptions SUM instead of `max`-collapse. Idempotent (no-op if catalog absent / already retired); downgrade reactivates plan+connector (does not re-fabricate the deleted stat row — next reconcile recreates it). Tests: `test_billing_seat_family_dedup.py` +3 (clamp to contract / healthy-below-ceiling no-op / `_effective_seats` unit). 0 cron / 0 RBAC / 0 MCP-tool-count change. |
| 226 | **Seed 13 new `asset_locations` master rows for the managed-location resolver (write-time gate).** Adds 3 China PingCAP cities (`武汉` / `大连` / `宁波`, codes `P-CN13-15`) and 13 country rows (`澳大利亚` / `加拿大` / `印度尼西亚` / `爱尔兰` / `台湾` / `泰国` / `韩国` / `阿联酋` / `越南` / `波兰` / `英国` / `意大利` / `罗马尼亚`, codes ISO-3166 alpha-2: AU/CA/ID/IE/TW/TH/KR/AE/VN/PL/GB/IT/RO) so the new [`ManagedLocationResolver`](../../server/app/domains/asset/location_resolver.py) has a managed home for every country / China city PingCAP's HR roster carries (`identity_persons.work_location_name`). Without these rows the resolver would have to drop `Sydney`/`Toronto`/`Würzburg`/etc. to `None` even though they map cleanly via the built-in 70-entry normalization map (`Sydney→澳大利亚`, `Toronto→加拿大`, `Würzburg→德国`, …). Natural-key probe (`code` UNIQUE + `name` logical UNIQUE) makes the migration idempotent; re-run is a no-op. **`downgrade()` raises `NotImplementedError`** — prod `asset_items.location` may already reference these names (e.g. `Würzburg→德国` via the 2026-05-30 backfill); dropping a master row would orphan asset rows referencing it. Pairs with the resolver helper + per-write-boundary wiring + `get_return_defaults.resolved_location` API addition + 22-case test pin in `test_location_resolver.py`. 0 RBAC role change / 0 MCP-tool-count change. |
| 225 | **`procurement_budgets.last_alerted_threshold` — edge-trigger marker for the budget-threshold reminder.** Nullable INT holding the highest utilization tier (80 / 95) already alerted to the budget owner. The `procurement.reminders` cron now pushes ONLY when the live tier rises above this value (and resets it downward when utilization falls below a previously-alerted tier), replacing the prior level-triggered behavior that re-notified any pool sitting above 80% **every ISO week forever** (the docstring had claimed "push only on transition" — now true). Pairs with `budget_service.reconcile_budget_for_order` (keeps `SUM(lock)-SUM(release)` for a PO equal to its desired open commitment across amount edits / cancel / reopen / scope change — previously `update_order` only released on cancel, so reopened or re-priced POs silently drifted the pool) and the `find_matching_budget` determinism fix (`order_by(created_at).limit(1)` instead of `scalar_one_or_none()`, which raised `MultipleResultsFound` on NULL-scoped duplicate budgets and silently killed every lock via the create_order best-effort wrapper) + a `create_budget` same-scope dedup guard + a `_push_inbox` `employment_status='active'` recipient gate (no nagging leavers). FE: shared `EntitySelect` (server-search supplier/contract/order pickers — kills the prefetched-200 silent-miss) + supplier-edit secondary-contact preservation + contract `change_summary` carried through + `delete_order_item` audit snapshot. Purely additive + idempotent (column-exists guard). 0 RBAC / 0 MCP-tool-count change. Round-56 三轮 sweep (#590). |
| 224 | **Procurement budgets + budget consumption log + `asset_items.procurement_order_id` column + `procurement.reminders` scheduled job seed.** Two new tables: `procurement_budgets` (uniqueness `(year, department, cost_center, category_id, currency)` — total_amount ceiling + owner_person_id + currency-scoped, no cross-currency aggregation in Phase 1) and `procurement_budget_consumptions` (append-only event log keyed on `(budget_id, po_id?, event ∈ {lock, release, actualize})`). Remaining = `total - SUM(lock) + SUM(release)`, open-commitment model — `actualize` is informational. PO creation (`service.create_order`) auto-emits `lock` event when a matching budget exists (year + department + cost_center + category_id + currency exact match, with category_id NULL fallback for "any category" pools); PO status flip to `cancelled`/`rejected` (`service.update_order`) auto-emits `release` event. Both wrap the writes in try/except so a budget-service failure never blocks the PO write — best-effort hook. `asset_items.procurement_order_id` added as nullable FK + `ix_asset_items_procurement_order` index — enables asset → PO provenance once the PR 3 backfill page lands. `procurement.reminders` scheduled job seeded (06:30 UTC daily, enabled, handler_key=procurement.reminders): scans contracts expiring within `renew_notice_days` (or `category.default_reminder_days`), budgets at 80%/95% threshold, and POs in `submitted` status > 5 days without `external_po_number` filled — pushes one in-app notification per (kind, target, recipient) per ISO week. 2 new MCP tools (`list_procurement_budgets`, `get_procurement_budget`); count hint 203 → 205. Idempotent. |
| 223 | **Procurement domain v2 — 9 new tables + legacy backfill + 4 seed category rows.** Establishes the full procurement-domain table set in a single migration: `procurement_categories` (seeded with software/hardware/cloud/service starter set), `procurement_suppliers` (full vendor master data including tax_id / banking / multi-contact JSON / payment terms / status + `legacy_supplier_id` FK back to `asset_suppliers`), `procurement_contracts` + `_attachments` + `_versions` (first-class contracts with multi-version change history), `procurement_orders` + `procurement_order_items` (PR/PO unified; line items decomposed from the legacy `items` JSON blob), `procurement_payments` (outbound payment records), `software_procurement_meta` (1:1 sidecar on `asset_software_catalog` separating procurement basis from operational state). All FK source columns built via `_fk_source_col_type` mirroring target DATA_TYPE+collation for TiDB strict-FK compatibility. Backfill: `asset_suppliers` → `procurement_suppliers` (legacy_supplier_id preserved); `asset_procurement_orders` → `procurement_orders` + `procurement_order_items` (items JSON decomposed line-by-line; legacy `supplier_name` resolved via `procurement_suppliers.name` then falls into `legacy_unresolved_fields` JSON for human review); `asset_business_payments` → `procurement_payments` (`po_number` resolved via `legacy_order_id` JOIN; `signing_entity` resolved against `asset_legal_entities.name` exactly; everything unresolved goes into `legacy_unresolved_fields`). Asset-prefixed tables NOT dropped — they coexist through PR 1 for backward compatibility, PR 2 retires them after the UI swap. 0 cron / 0 RBAC role added (gated on existing `require_admin()` + `require_admin_read()`); 8 new MCP tools (`_REGISTERED_TOOLS_COUNT_HINT` 195 → 203). Tests: `tests/test_procurement/test_procurement_api.py` (10 cases). Companion conftest seed mirrors the 4 starter category rows so `create_all`-based test schemas have them. **`alembic_revision_race` note**: originally written as migration 222 → bumped to 223 after PR #581 (HRIS purged-record tombstone) took 222 first. |
| 222 | **`identity_persons.hris_record_state` — soft tombstone for HRIS-purged people.** Adds `hris_record_state VARCHAR(16) NOT NULL DEFAULT 'present'` ∈ {`present`, `purged`} + `hris_purged_at DATETIME(6) NULL` + `hris_purged_reason VARCHAR(64) NULL` + index `ix_identity_persons_record_state(hris_record_state, deleted_at)`. Orthogonal to `employment_status`: the latter says "does the human still work here", this one says "does the upstream HRIS still carry the row at all". Companion code: [`run_hris_absent_person_check`](../../server/app/scheduled_jobs/handlers.py:185) (key `hris.absent_person_check`, seeded since migration 134, 06:00 UTC daily) is upgraded from detection-only to **detection + stamp soft tombstone + write `hr_lifecycle_events(event_type='hris_purged')`**. Per-person SAVEPOINT wraps {tombstone update, lifecycle insert, audit row} so a failure mid-write rolls all three back and the row stays `present` for next-run retry. Candidate query gains `hris_record_state == 'present'` filter so already-purged rows are NEVER re-scanned — solves the "every daily run re-audits the same person" noise of the pre-222 design. The mass-leaver brake (`max(25, 5%) ∩ 50% per-instance ceiling`) is unchanged and STILL fires before any tombstone is written — an SF roster outage / paginator truncation can't flip the whole company to purged. `employment_status` is intentionally NOT touched — flipping to `leaver_pending` triggers offboarding side-effects (asset transfer scan, SSO suspend, Feishu notice) which must be a deliberate admin decision. Admin list endpoint `GET /users` gains `include_purged: bool = False` (default-hide); `UserListItemResponse` / `UserDetailResponse` expose the new fields; [`UsersPage.tsx`](../../web/src/pages/admin/UsersPage.tsx) renders an "已从 HRIS 抹除 / Purged from HRIS" Tag in the name column + a `Switch` to surface purged rows. Tests: [`test_absent_person_check.py`](../../server/tests/test_hr/test_absent_person_check.py) adds 2 cases (tombstone-stamped on detection; already-purged row is idempotent). **Historical context**: The wangdi@pingcap.com case (2026-05-29) showed Helix had ZERO record of a person SF had hard-deleted before our first sync — the new mechanism only protects future cases. Memory: `feedback_no_speculative_backfill` (no historical backfill possible). Idempotent column/index probes via `information_schema`; downgrade drops index then columns in dependency order. 0 RBAC role change / 0 MCP-tool-count change (reconciliation is a backend job, no self-service flow). |
| 220 | **`asset_office_admins` join + auto-derived `asset_admin` role + region-scoped writes + 国内 location backfill.** New table `asset_office_admins(office_id, person_id, is_primary, created_at)` PK `(office_id, person_id)` with cascade FKs to `asset_office_addresses` / `identity_persons` and `ix_asset_office_admins_person` for the per-person managed-office query. Seeds rows from existing `asset_office_addresses.admin_person_id` (single legacy column kept as a denormalised "primary contact" — offboarding handover / inventory routing / hr_lifecycle still read it). Same migration **rebuilds `auth_user_roles` for `asset_admin`** from `asset_office_admins.person_id ∩ {active persons}` — manual grants prior to this PR are revoked. Same migration also **backfills `asset_items.location='国内' AND category in (laptop, 笔记本, 笔记本电脑)`** to the assignee's `identity_persons.city` (fallback `work_location_name`); rows with neither are left as-is for a follow-up audit. Seeds the daily `asset.asset_admin_role_reconcile` cron (`cron='15 3 * * *' Asia/Shanghai`, enabled) as a backstop for the in-process role sync. Code companions: [`app/domains/asset/office_admin_sync.py`](../../server/app/domains/asset/office_admin_sync.py) holds `replace_office_admins` / `sync_asset_admin_role_for_person` / `reconcile_asset_admin_role` / `get_office_ids_managed_by` / `get_asset_office_id` (3-tier office resolution: `holder_office_id` → assignee `work_location_code/city` → free-text `location`) / `person_can_manage_asset`; [`app/core/dependencies.py::assert_can_manage_asset`](../../server/app/core/dependencies.py) is the per-asset gate every `/{asset_id}/*` write endpoint calls after the route-level `require_asset_admin`. Excel `bulk-update` / `bulk-intake` are routed to `require_admin` (global only) because Excel-row → region mapping can't be enforced pre-parse. Office CRUD (`POST/PUT /assets/office-addresses`) accepts a new `admin_person_ids: list[UUID]` and writes through `replace_office_admins`, which mirrors the first entry into the legacy `admin_person_id` column. New `GET /assets/return-defaults?admin_id=...` powers the 退库 modal auto-fill (`退库后部门` = admin's HR department, `退库后位置` = first managed office's city). `AssetReturnRequest.location` now `min_length=1` (required by schema; UI matches). `/auth/me` carries `managed_office_ids` so the frontend can hide write buttons / show the `asset.region.readOnly` tag on out-of-region rows. `/admin/rbac` marks `asset_admin` `grantMode = autoGrant`; backend `assign_role` / `remove_role` / `remove_role_by_name` reject the role with `ROLE_AUTO_DERIVED`. Tests: [`tests/test_asset/test_office_admin_auto_role.py`](../../server/tests/test_asset/test_office_admin_auto_role.py) (5 cases). Pairs with [DECISIONS.md](DECISIONS.md). 0 RBAC role addition / 0 MCP-tool-count change (existing tools unchanged). |
| 211 | **`offboarding_processes.default_handover_person_id` — per-process default successor.** Adds a nullable FK column to `offboarding_processes` that holds the default handover person used to prefill the per-blocker "指定接手人" modal (SSO grant / domain registration / google_orphan_owner). Auto-populated on `start_process` from the leaver's HRIS `manager_person_id` (only when the manager still exists and isn't soft-deleted — defensive against stale FK writes); IT Admin can edit or clear from the offboarding detail page via `PUT /offboarding/processes/{id}/default-handover` (mirrored by MCP `set_offboarding_default_handover_person`, `_REGISTERED_TOOLS_COUNT_HINT` 185 → 186). Column type mirrors `identity_persons.id`'s DATA_TYPE + collation via `normalize_fk_column_collation` (TiDB strict-FK guard); FK `ON DELETE SET NULL` so a manager hard-delete clears defaults instead of cascade-deleting offboarding rows. Backfill: in-flight processes (status NOT IN completed/cancelled) get `manager_person_id` written through; terminal rows stay NULL. Read surfaces: `OffboardingProcessResponse` carries `default_handover_person_id` + `default_handover_person_name` + `default_handover_person_email` (resolved via the existing `_load_people` batch, which now includes the default handover person in its set to avoid N+1). Per-blocker handover endpoint contract is **unchanged** — `successor_person_id` is still required in the request body, so audit attribution stays at the blocker level (the default is purely a UI prefill / typing-saver). Service-layer validation on the setter mirrors `resolve_blocker_by_handover_to_successor` (400 SUCCESSOR_NOT_FOUND / 409 SUCCESSOR_NOT_ACTIVE / 400 SELF_HANDOVER_NOT_ALLOWED). Idempotent column/FK creation; downgrade drops both. 0 cron / 0 RBAC change. |
| 219 | **`software_seat_sync_stat` + `software_external_holder` — six-bucket real-occupancy reconciliation.** Adds 3 columns to the stat row (`holders_leaver_residual` / `holders_vendor_inactive` / `holders_off_paid_site`) and 2 columns to the per-holder table (`category` discriminator + `person_id` FK to identity_persons). Together they raise the displayed identity from 3 buckets (matched + external + no_email) to 6: `vendor_license_holders = matched + external + no_email + leaver_residual + vendor_inactive + off_paid_site`. **Why**: 2026-05-29 prod showed Atlassian's Admin UI billing 372 Jira-Software seats but Helix only showing 365 — the old `license_holders(family, sites)` filtered to `is_active=True` AND a `production_sites` allowlist, silently hiding 1 leaver-still-on-license + 1 vendor-inactive-but-still-billed + 6 holders on non-paid sites (sandbox/personal). User feedback was unambiguous: "我要求显示和 jira 实际一样的数字 显示出来真的占了坐席的". The new `AtlassianActivityDirectory.all_license_holders(family)` returns ALL accounts with the product across the org (no status/site filter — matches Atlassian's billing semantics); `classify_holder()` assigns each into exactly one of six mutually-exclusive buckets with precedence `no_email > vendor_inactive > off_paid_site > leaver_residual > external > employees_active`. The `software_external_holder` table is generalised from "external-only" to "anomaly-of-any-category" — same upsert key (plan_id, email), category transitions in place across runs (an external email later HRIS'd in as a leaver flips to `leaver_residual` on the next sync). The healthy `employees_active` baseline is NOT row-persisted (no anomaly to drill into); the count-only `no_email` bucket is also not row-persisted (no dedupe key). The 4 row-persisted categories all get per-tab drawers in the UI with severity-ordered Tag colours (red for leaver_residual). Idempotent column adds (information_schema probe), idempotent index creation, downgrade drops FK then column. 0 cron / 0 RBAC change. |
| 210 | **`software_external_holder` + `software_seat_sync_stat` — software-seat reconciliation backbone.** Two new tables every `*.seat_sync` handler (Jira/JSM/Confluence, Zoom, Google, Feishu, Slack) now populates via the shared `record_seat_reconciliation()` helper in [`_seat_sync_helpers.py`](../../server/app/scheduled_jobs/_seat_sync_helpers.py). `software_external_holder`: one row per `(plan_id, email)` for a vendor license holder that matched NO Helix employee (email / vendor_account_id / account_status / product_key+site / first&last_observed_at / `resolved` bool) — powers the per-software reconciliation drawer's drill-down + the "open in vendor admin console" deep-link. `software_seat_sync_stat`: one row per plan = the authoritative headline snapshot (`vendor_license_holders` / `holders_matched` / `holders_external` / `holders_no_email` + `synced_at` provenance) for the "72 = 69 + 3"口径. **Identity (NON-NEGOTIABLE, post-2026-05-29)**: `vendor_license_holders == holders_matched + holders_external + holders_no_email` MUST hold or `record_seat_reconciliation` raises `SeatReconciliationIdentityError` at write time — the run lands `status='failed'` and the failure-notifier pages ops. Silently persisting a snapshot that lies (the prod Jira off-by-1 on 2026-05-29: `taichi.kusaka@` + `japan_tidbcloud_taichi.kusaka@` both holding jira-software via two distinct account_ids but mapping to one Helix person via email-alias) is worse than a loud failure. Handlers MUST: (a) count matched per EMAIL (NOT `len(set_of_person_ids)` — that collapses alias-share-one-person to 1 while external still counts 2 unique emails); (b) set `vendor_license_holders = len(holders_by_email) + holders_no_email` (post-email-dedupe billable identities matching Atlassian's "users" semantics) so identity holds even when two vendor account_ids share an email. The per-person set is still kept for the drift-revoke loop where one-revoke-per-person is the correct semantic — they're two different counts for two different jobs. Both FK columns (`integration_id`/`software_id`/`plan_id`) mirror target type+collation via `_fk_source_col_type` (TiDB strict-FK, same as 192). Per-run resolution flips not-seen-this-run holders to `resolved=True` keyed on the **seen-email set** (NOT a `last_observed_at < now` compare — MySQL DATETIME truncates to whole seconds and would resolve the row it just upserted). Occupancy + scheduled-cancel are NOT frozen here — read live from `software_seat_allocations`. Read surfaces: `GET /assets/seat-reconciliation[/{software_id}]` + MCP `seat_reconciliation_overview` / `software_seat_reconciliation_detail` (`_REGISTERED_TOOLS_COUNT_HINT` 183 → 185). Frontend: reusable `SeatReconciliationDrawer` opened from the software seat page + 运营洞察「软件」对账 table. Idempotent table-create; downgrade drops both. 0 cron / 0 RBAC change. **Scope note — `vendor_license_holders` vs Atlassian Admin UI**: Helix's `license_holders()` filters to `is_active=True` AND the production-site allowlist (`software_seat.idle_reclaim.production_sites`); Atlassian Admin UI counts every billed seat including `inactive` accounts that still hold the license on a production site (e.g. 2026-05-29: lifu.wu still has jira-software on tidb but is `inactive` — Atlassian's "73 / 100" includes him, Helix's 72 doesn't) and accounts on non-production sites (sandbox / personal sites). That's a documented scope difference; `vendor_license_holders` is what Helix can RECONCILE against, not the vendor's billing-page total. |
| 205 | **Adopt `asset_category_configs.asset_category` enum-link + set each row's `code` to the real tag segment + drop dead `asset_tag_sequences`.** Makes `asset_category_configs` the single source of truth for the asset-tag category segment (`IT-{entity}-{code}-{ts}`), replacing the hardcoded `_CATEGORY_TO_TAG_CODE` dict. `asset_category` (the stable enum `laptop`/`desktop`/… that `asset_items.category` stores) **already existed on prod as undocumented drift**, populated, mapping each row to the enum — this migration adopts it (adds the column + UNIQUE index only on envs that lack it; brings it into the model). Sets `code` to `(laptop,NB)/(desktop,DT)/(monitor,MT)/(printer,PR)/(server,SV)/(network_device,ND)/(accessory,AC)/(conference_display,CD)` so generated tags stay byte-identical — `UPDATE` of the 8 pre-existing prod rows (their unused `P-PC`/`P-NB` admin codes are replaced; names/hierarchy/is_active preserved), `INSERT` on fresh DBs. Idempotent. Tag code is now admin-editable via 分类管理; missing/inactive row → `CATEGORY_TAG_CODE_MISSING`; system categories (`asset_category` set) can't be deactivated/deleted. Drops the never-used `asset_tag_sequences` table (downgrade re-creates it + drops the index). 0 cron / 0 RBAC / 0 MCP-tool-count change. |
| 203 | **Inject `travelerApprover` rule into existing `kind='navan'` targets.** Adds a new rule with `target_field='urn:...:navan:2.0:User.travelerApprover'`, `value_map={'PingCAP (US), Inc.': 'nancy.wu@pingcap.com'}`, `default='qiufang@pingcap.com'`, `transform='strip_company_prefix'`. **Critical**: the value_map key contains U+2004 (THREE-PER-EM SPACE) NOT ASCII space — PingCAP's SAP HRIS records the US entity's `FOCompany.name` as `"01_PingCAP (US), Inc."` with U+2004, and DO NOT collapse to ASCII when editing this migration. Without the U+2004 match the rule silently falls through to qiufang for every US employee. Idempotent — re-running on a target whose mapping already has a travelerApprover rule is a no-op. Busts `identity_sync_state.last_payload_hash` for affected targets so the next sweep actually re-pushes with the new field. |
| 202 | **`identity_sync_state.auto_included_reason VARCHAR(64) NULL` + `notified_at DATETIME NULL` + Navan preset rewrite.** Two nullable columns supporting the "auto-pull CN managers of foreign reports into Navan" surface: `auto_included_reason` (free-form so future reasons like `finance_partner_of_country` can land without another migration; sweep-set to `'manager_of_foreign'`); `notified_at` (set once when the first-create Feishu notification is enqueued — never cleared, so re-renders never re-notify). Filtered index `ix_identity_sync_state_auto_included (target_id, auto_included_reason)` powers the admin UI "auto-included" badge query. Same migration also data-migrates existing `kind='navan'` targets: drops the `title` rule, swaps `enterprise.department` source to `{department_combined}`, adds `strip_company_prefix` transform to `enterprise.division` — done as a per-rule replace (NOT full-config overwrite) so admin customisations elsewhere in the mapping survive. Per-target `identity_sync_state.last_payload_hash` is also zeroed so the next sweep actually re-pushes with the new shape (mirrors the eager invalidation in `IdentitySyncService.update_target`). Idempotent — re-running on an already-migrated env is a no-op for both schema and data. |
| 199 | **Per-scope dedup + owner-snooze for `cmdb.scope_idle_sweep`.** Adds two nullable `DATETIME` columns to `cmdb_cloud_scopes`: `last_idle_notified_at` (stamped by the sweep itself after every successful dispatch — next sweep skips scopes still inside `IDLE_SWEEP_DEDUP_DAYS=14d`) and `idle_snoozed_until` (owner-set "ignore for N days" via `POST /api/v1/cmdb/scopes/{id}/snooze-idle`, default +30d, RBAC = scope owner OR cloud/IT admin). Pairs with same-PR rewrite of [`sweep_idle_scopes`](../../server/app/domains/cmdb/lifecycle_service.py): now notifies BOTH owner (per-owner card listing only their candidates) and IT admins (full digest); recipients deduped by `person_id` so admin-who-is-also-owner gets one card; body restructured with structured handling options + explicit "billing data lag is provider-side, not Helix sync issue" disclaimer; summary keys updated to `notified_recipients` / `owners_notified` / `admins_notified` / `stamped_scopes`. MCP twin `snooze_cloud_scope_idle_reminder` mirrors the HTTP endpoint. UI affordance: 「忽略 30 天」 Popconfirm button + `已忽略至 …` info banner on both `CloudScopeDetailPage` (admin) and `MyCloudScopeDetailPage` (owner self-service). Banner i18n `cloudBilling.drawer.dataLag` rewritten to call out provider-side lag explicitly. Idempotent column-add; downgrade drops both columns. Tests: [`tests/test_cmdb/test_scope_decommission.py`](../../server/tests/test_cmdb/test_scope_decommission.py) gains 11 cases (snooze excludes / dedup excludes / owner-included / admin-owner overlap deduped / owner-can-snooze / admin-can-snooze / non-owner-403 / closed-rejected / out-of-range-days). |
| 204 | **Idle software-seat reclaim — schema + cron + default settings.** Adds `idle_warned_at` / `idle_kept_until` / `idle_manual_review_at` (all `DATETIME(6) NULL`) + index `ix_software_seat_allocations_idle_warned_at` to `software_seat_allocations`; seeds the `software_seat.idle_reclaim` `scheduled_jobs` row (`cron='0 2 * * *'` UTC, **`enabled=0`** until a prod-worker probe confirms the Atlassian Org Admin API `product_access` JSON shape); seeds `software_seat.idle_reclaim.{idle_threshold_days=90, buffer_days=14, keep_days=180, admin_group_names, production_sites}` into `platform_settings`. Idempotent column/index/row guards; downgrade drops all three. See §5 "Idle software-seat reclaim". |
| 206 | **Atlassian product-access seat model — Confluence subs + JSM group fix + per-plan binding scope.** Data-only (no DDL). (a) Fixes the JSM connector binding's `group_name` from `jira-servicedesk-users` (confers no JSM license per `/rest/api/3/applicationrole`) → `jira-servicemanagement-users-pingcap-ticket` (the product's `defaultGroups`), scoped by catalog name so add-on catalogs are untouched. (b) Stamps `product_key` + `site_urls=['tidb.atlassian.net']` onto the Jira Software + Jira Service Management bindings so the reconcile counts the exact product × paid-production site (and `product_key` gates counting — Marketplace add-on catalogs reusing `jira-software-users` stay unstamped → skipped). (c) Seeds two independent Confluence subscriptions ("Confluence" @ tidb, "Confluence (PingCAP US)" @ pingcap-us) each as catalog + plan + connector with `offboarding_policy='sso_cascade'` (Confluence access cascades from Google SSO; no per-site group DELETE). Idempotent JSON read-modify-write (preserves other binding keys); downgrade removes only clean never-seated Confluence rows, leaves the JSM/binding edits (reverting would re-break provisioning). Pairs with the `jira.seat_sync` rewrite + the `atlassian_activity.license_holders` helper. |
| 131 | **Cloud-side orphan-principal cleanup history.** Makes `cmdb_cloud_access_grants.person_id` nullable + adds `external_principal_id VARCHAR(128)` and `external_principal_email VARCHAR(255)` (both NULL on existing rows; populated only on orphan-cleanup history rows where the AWS Identity Center principal can no longer be resolved to a Helix person). Adds `ix_cmdb_cloud_grants_status_reason (status, revoke_reason)` to back the admin "已结束" tab range scan, plus a second uniqueness key `uq_cmdb_cloud_grants_scope_role_extprincipal (scope_id, role_key(191), external_principal_id)` so a re-cleanup against the same orphan can't double-insert a history row when a transient driver glitch fails to return `already_absent`. The composite UQ uses an index prefix on `role_key` to fit the MySQL/TiDB 3072-byte composite-key cap (utf8mb4 = 4 bytes/char; 36 + 191 + 128 chars × 4 = 1420 bytes); cloud role keys observed across AWS / Azure / GCP / Aliyun max out around 100 chars so 191 covers them with margin. NULL `external_principal_id` makes the constraint a no-op for normal helix-issued / discovered rows. Schema-only — no data backfill, since "未发生过的清理" is the correct semantics for every existing row. **Implementation note**: deliberately NOT using `op.batch_alter_table` — on MySQL/TiDB it silently no-ops a mix of `alter_column` + `add_column` against a table with FKs (first cut of this migration recorded version=130 but emitted no DDL; observed against `helix-mysql-1` 8.0). Direct `op.alter_column` + `op.add_column` + raw `ALTER TABLE … ADD CONSTRAINT` for the prefix-index UQ emit the expected DDL. Pairs with the new admin endpoints `GET /cmdb/cloud-grants/orphan-scan` and `POST /cmdb/cloud-grants/orphan-cleanup`, the `OrphanCleanupModal` UI in `CloudScopeDetailPage`, and the `orphan_principal_swept` revoke reason. Downgrade preserves nullability — refusing to flip back to NOT NULL would trash any history rows written between upgrade and downgrade points; safe direction is one-way. See [DECISIONS.md](DECISIONS.md) "Cloud-side orphan-principal cleanup" for design rationale. |
| 233 | **Hotfix for migration 232** — TiDB strict-FK collation rejection on the access-review FK (CHAR↔VARCHAR collation mismatch class); re-creates the FK with matching column type. |
| 235 | **Feed domain — 动态广场.** `feed_posts` / `feed_comments` / `feed_likes` / `feed_blessings` + announcements; powers `/feed` plus the 3 feed crons (`feed.scan_today_celebrations` / `feed.notify_celebrants_and_leads` / `feed.expire_pinned_announcements`). |
| 236 | **Role-change scope review** — `cmdb_cloud_access_grants.review_status` (+ review metadata) backing the `role_change.scope_review_sweep` cron: grants held by people whose department/role changed get flagged for owner re-review. |
| 237 | **Cancel duplicate orphan-sweep revoke tasks** (data-only) — cleanup companion to the double-enqueue bug fix in the same PR; cancels the leftover duplicate `provisioning_tasks` rows. |
| 239 | **Seed `cmdb.auto_owner_grant_backfill`** — backfills the auto-Owner grant for scopes provisioned before the post-provision finalize task existed. |
| 240 | **`identity_persons` HRIS extra fields** — `seniority_date` / `probation_end_date` / `fte`, pulled from SF on sync. |
| 241 | **Re-enable `cmdb.auto_owner_grant_backfill`** after the backoff-marker fix (data-only flip of the scheduled_jobs row). |
| 242 | **`hr_lifecycle_events.trigger_source`** + composite lookup index — distinguishes HRIS-sync-triggered vs admin-manual lifecycle events. |
| 243 | **`identity_persons.first_name` + `last_name`** — unambiguous email-order name parts (backs Google email naming + identity-sync mapping). |
| 244 | **`cmdb_cloud_scopes.auto_owner_grant_unresolvable_at`** — per-scope backoff marker so the owner-grant backfill stops retrying scopes whose owner can never be resolved. |
| 245 | **Seed `hr.recover_orphaned_left`** — recovers people stuck in `left`-pending states after interrupted offboarding completion. |
| 246 | **Production cloud-account write lock — default LOCKED.** `prod_write_lock` platform rows: every human prod cloud write is blocked until the per-scope `write_locked` flag is explicitly opened (kill-switch; automation paths unaffected). |
| 247 | **Seed `cmdb.cost_facts_monthclose`** — month-close re-pull of the prior month's cost facts per cost_facts-capable tenant. |
| 248 | **Re-insert dashes into `scheduled_jobs.id` (3rd occurrence)** of the `REPLACE(UUID(),'-','')` seed bug; recurrence led to the `check_scheduled_jobs_uuid_seed.py` CI guard. |
| 249 | **工号分配台账** — `hr_employee_numbers` (`P`+5-digit, `MAX(seq)+1`), one-time seed from `identity_persons.employee_no`. |
| 250 | **GRC spine (PR1)** — control catalog + multi-framework crosswalk (SOC2 + ISO27001) + continuous control monitoring (`compliance.control_status_recompute`); registers the 8 existing detective controls. |
| 251 | **Access certification (PR2)** — generalized periodic access-review engine (campaigns / items / decisions; reviewer = holder's manager). |
| 252 | **工号系统扩展** — 一级部门 (BU) column + `hr_employee_number_overrides` (工号维护 forward-only next-number override ledger). |
| 253 | **Drop legacy privileged-review tables** — folded into the access-certification engine as `campaign_type='privileged_role'` (single-engine end state, GRC PR3). |
| 255 | **Unified `auditor_findings`** — one cross-control actionable worklist (open / acknowledged / accepted_risk / resolved); public-exposure + sign-in project into it. |
| 256 | **Backfill native-control findings** (MFA / secret / cert / PAT) so binary-control posture derives from open findings. |
| 257 | **Seed `auditor.access_certification_revoke_fulfil`** — reconciles access-cert `revoke` decisions into real downstream seat revokes via the coordinator. |
| 258 | **Drop GCP billing-account / org-level roles** from the project-grant catalog (not grantable at project scope). |
| 259 | **`bailian_usage_daily` gross-cost columns** — 折扣前 (原价) cost + backfill from BSS `PretaxGrossAmount`. |
| 260 | **`cmdb_cost_facts` gross-amount columns** — 折扣前 (list-price) cost; AWS Bedrock `pricing_public_on_demand_cost` source, column-guard tolerates missing CUR columns. |
| 261 | **`ai_spending_period_rollup`** — materialised closed-month BucketRow set per period window (kills the per-request 2.7M-row `cmdb_cost_facts` scan); content-derived fingerprint (whitelist + FX + semantic version). |
| 262 | **Seed `ai_spending.period_rollup_refresh`** (06:30 UTC daily) — recomputes the last 2 closed months every tick, older periods only when fingerprint-missing. |
| 263 | **`cmdb_cloud_roles` governance-tier columns** — four-tier cloud role governance model (cloud role governance stack #757–763). |
| 264 | **`bailian_usage_daily` after-discount cost column** — 优惠后 (pre-coupon) tier; Bailian is account-level commerce billing, correct source is BSS `QueryBillOverview` (`ProductCode='sfm'`). |
| 265 | **Seed `ai_spending.cost_drift_check`** — daily comparison of the materialised rollup vs live recompute per closed month × bucket (reconciliation center's cost lens). |
| 266 | **`email_groups.is_dynamic`** — Cloud Identity dynamic-group marker; dynamic groups block self-service leave and point to IT (NULL until the domain-delegation scope is granted). |
| 267 | **`cmdb_certificates` + `cmdb_cert_expiry_alert_state`** — TLS/SSL certificate inventory (probed / cloud / manual provenance) + the `cmdb.cert_probe_sweep` / `cmdb.cert_expiry_alert` cron pair + 网络负责人 fallback setting. |
| 268 | **Widen `core_idempotency_records.scope` 64 → 128** — `{audit_action}:{uuid}` scopes (e.g. force-bypass, 78 chars) overflowed VARCHAR(64) on TiDB strict mode → bare 500 with zero audit. |
| 269 | **Identity data-quality** — findings + runs tables + the daily `identity.data_quality_scan` cron (five checks; deduped, auto-resolved on disappearance); backs `/admin/identity/data-quality`. |
| 270 | **`identity_persons.manager_external_id`** — HRIS-declared managerId kept verbatim alongside the resolved `manager_person_id` FK (data-quality "departed manager" check input). |
| 277 | **Identity consumers — outbound PULL surface (PR1/3 of 身份消费方).** Three tables for machine credentials (`helix_svc_` service tokens, hashed at rest): consumer registry + token ledger + access audit. Scope 三档 (`persons:read` masked / `org:read` / `pii:read` explicit grant); backs `/admin/identity/consumers` governance console, the REST Directory endpoints (PR2) and the read-only SCIM 2.0 server at `/scim/v2` (PR3). |
| 278 | **入职须知 (onboarding acknowledgement) tables — onboarding-parity PR1 (2026-06-12).** `onboarding_ack_items` (admin checklist: title / description / doc_url / required / is_active / sort_order) + `onboarding_acknowledgements` (UNIQUE item×person confirm ledger, `source ∈ {web,mobile,mcp}`, courtesy process back-pointer SET NULL). Backs the now-live `training_ack` readiness dimension; person FK collation via `table_kw_for("identity_persons")`, process FK type via `id_type_for("onboarding_processes")`. |
| 608 | **`asset.stale_reservation_sweep` daily cron seed (2026-09-05).** No schema change. Releases earmarks whose hire no longer has an open onboarding process — a check that previously ran ONLY when somebody opened the 待备机 tab, with no scheduled job looking at reservations at all. Became urgent the same day an earmark started satisfying the Day-1 hardware dimension: a zombie earmark is both unavailable stock AND counted as a rescinded candidate's readiness. 04:10 Asia/Shanghai, seeded enabled, guarded by NOT EXISTS on handler_key. |
| 605 | **财务固定资产台账对账 — two tables (2026-09-05).** `asset_cost_reconcile_runs` (one upload: label, counts, status previewing/applied/discarded) + `asset_cost_reconcile_items` (one register line: verdict, the Helix-side snapshot taken AT PREVIEW TIME, per-row decision). Preview-then-apply — the upload writes nothing, and the apply re-checks each row against its snapshot and refuses it if the ledger moved. No FK to `asset_items` (an unmatched line has no asset; a matched asset may be hard-deleted later). Backs `/api/v1/assets/cost-reconcile*` + 硬件财务 `?tab=reconcile` + 2 read-only MCP tools. |
| 604 | **`asset_items.purchase_cost_source` — provenance of a purchase amount (2026-09-05).** `recorded` / `estimated_model` / `finance_register`, NOT NULL default `recorded`, existing rows backfilled. Depreciation / BvA / cost attribution all read `purchase_cost`, so an ESTIMATE has to stay distinguishable from a recorded invoice figure — a single amount column cannot say which it is. Plain VARCHAR + app-level vocabulary, not a MySQL ENUM (TiDB rejects `MODIFY COLUMN` on enums). Backs `scripts/backfill_asset_purchase_cost.py` + the `estimated_amounts` count on 台账健康度自检. ⚠️ Numbered 604 because the access-lifecycle series claimed 603 concurrently. |
| 279 | **Asset reservation for incoming hires — 备机闭环 (onboarding-parity PR3, 2026-06-12).** `asset_items.reserved_for_person_id` (FK identity_persons SET NULL, out-of-line after `normalize_fk_column_collation` — asset_items predates the collation discipline) + `reserved_at` + index. A reserved device keeps its stock status but every grant path (assign/claim/transfer/change_assignee/borrow) refuses other recipients via `AssetService._ensure_not_reserved_for_other`; granting to the reserved hire fulfils + clears. Voucher ops `reserve_for_hire` / `reserve_release`. Backs `/api/v1/assets/hardware-prep*` + the 资产工作台「待备机」tab + 3 MCP tools. |
| 280 | **`onboarding_processes.hard_ready_at` — BLOCKER-dims-clear timestamp (收官评审 R1, 2026-06-12).** Stamped once when the pending BLOCKER-severity count first hits zero; the Day-1 readiness scorecard reads THIS column (advisory dims like 须知确认 must not gate the metric — one required ack item dragged it to ~0%). Backfill: `hard_ready_at = ready_at` (ready ⇒ hard-ready, conservative upper bound). |
| 281 | **UNIQUE (process_id, resource_type) on `onboarding_blockers` (收官评审 R3, 2026-06-12).** Dedupes existing rows first (keep newest per pair), then enforces the scanner's one-row-per-dimension contract at the DB layer — concurrent rescans (ack-triggered HTTP reconcile vs 5-min worker cron) could dup-create a row whose orphan twin stayed pending forever (process wedged, false SLA/T+7). `_rescan` insert is SAVEPOINT-wrapped to absorb the race loser. |
| 284 | **Drop the onboarding `buddy` concept (2026-06-15).** Drops FK `fk_onboarding_processes_buddy` + column `onboarding_processes.default_buddy_person_id`, and DELETEs leftover `onboarding_blockers` rows with `resource_type='buddy_assigned'`. The buddy was advisory-only (never gated readiness / SLA / provisioning, never notified anyone) — over-design removed end-to-end (model / service / routers / 2 MCP tools / FE / i18n). Existence-guarded for re-run convergence; downgrade re-creates the empty column + FK. Companion: manager-facing 7d/3d/1d/day0 onboarding reminder cards removed (HRIS owns join/leave reminders; Helix only pushes IT-actionable cards), and the IT escalation cards rewritten to plain-language gaps + explicit CTA. |
| 285 | **Identity-consumer field-level authorization grant (2026-06-15).** Adds `identity_consumers.granted_fields` (JSON) — the least-privilege person-field projection the `/persons` serializer applies on top of the coarse scope bundles. Two-step add (nullable → backfill from existing `scopes` via the inlined point-in-time bundle→field map → NOT NULL), portable across MySQL 8 + TiDB; downgrade drops the column. See §10.6. |
| 286 | **Identity-consumer self-service request flow (2026-06-15).** Creates `identity_consumer_requests` (the application a system owner submits; out-of-line FKs to `identity_persons`/`identity_consumers` to avoid TiDB 3780) and idempotently seeds the `integration_approval_flows` row mapping flow_key `identity_consumer_request` → Feishu template `413C2807-…` on the primary chat_approval integration (best-effort; never clobbers an admin-disabled row). See §10.6. |
| 287 | **Identity-consumer periodic re-certification (2026-06-15).** Adds `identity_consumers.last_certified_at` + `recert_lapsed_at`, and idempotently seeds two `scheduled_jobs` crons — `auditor.identity_consumer_recert_auto_open_quarterly` (quarterly campaign open) and `auditor.identity_consumer_recert_lapse_sweep` (daily auto-disable of consumers whose recert lapsed past grace). Reuses the access-certification engine (campaign_type `identity_consumer`). See §10.6. |
| 352 | **Seed `auditor.access_self_attestation_auto_open_semiannual` cron (2026-06-28).** Half-yearly all-employee access self-attestation. No schema change — reuses `access_certifications` / `access_certification_items` (campaign_type `self_attestation`). Daily 08:00 UTC but opens a campaign only inside the 7-day window after Jun 1 (H1) / Dec 1 (H2), so a mid-cycle deploy never retroactively blasts every employee. Idempotent seed (skip if the job row exists). The completed campaign = SOC2 CC6.3 / ISO A.5.15 user-access-review evidence. See the auditor-domain §ledger entry. |
| 360 | **`cmdb_certificates.owner_is_manual` (2026-06-29, cert-expiry-governance PR1/2).** Certificate ownership moves to live resolution (`cert_owner_resolver`); the `owner_person_id` column survives only as a deliberate admin override gated by this flag. Adds `owner_is_manual BOOLEAN NOT NULL DEFAULT 0`; sets it true for existing `source='manual'` rows (their owners were deliberate). Probed/cloud legacy snapshot owners stay in the column but dormant (resolver ignores them unless `owner_is_manual`). Non-destructive + reversible. |
| 362 | **`cmdb_expiry_alert_state` unified table (2026-06-29, cert-expiry-governance PR2/2).** One `(resource_type, resource_id)`-keyed table for the shared graduated expiry-alert engine — `last_bucket` / `last_owner_person_id` / `last_alerted_on` (daily dedup) / `escalation_started_on` (first-enable storm guard) / `acknowledged_until`+by+at (snooze loop). Drops the old per-type `cmdb_cert_expiry_alert_state` + `cmdb_domain_expiry_alert_state` (empty in every env — kill switch short-circuits the write path); downgrade recreates them. No FK on the person columns (plain String(36), TiDB 3780-safe). |
| 367 | **Drop dead `email_template.welcome` row (2026-06-30).** Migration 035 seeded `email_template.welcome` for a thin welcome mail that the 2026-06-29 onboarding consolidation merged into `hire_day_it_info` (and removed the `welcome` builtin). Nothing sends `template_key="welcome"` since — the row was unreachable and only made the 邮件模板 editor show a phantom entry. DELETE-if-present (idempotent); downgrade is a no-op. Companion to the email-template consolidation (catalog `build_registry` excludes `welcome`; the editor now drives off the catalog). |
| 384 | **Refresh `cmdb.cost_facts_monthclose` job description (2026-07-05).** Pure text UPDATE (idempotent, keyed by `handler_key`, no-op where absent) refreshing the seeded row's now-misleading "单例任务遍历所有账号" copy to the fan-out dispatcher behaviour, since the global row's `description` is seeded by migration (247) and not reseeded from the registry at boot. No schema change. |
| 385 | **`hr_sync_staged_events.error_message` nullable column (2026-07-05, code-review #1667).** Adds `error_message VARCHAR(1024) NULL`, the per-event twin of `hr_sync_batches.error_message`. When one staged event's apply raises, `confirm_batch`/`_apply_staged_events` rolls it back, marks the row `status='failed'` and continues; the failure reason previously lived only in the worker log. The per-event except handler now stamps `last_event_error` onto this column (and caches `staged.id` before the rollback so the failure-marker branch doesn't read an expired ORM instance). Idempotent (column-existence guard); downgrade drops the column. No FK, no index. |
| 389 | **Stamp `read_at` on historical dead `notification_outbox` rows (2026-07-07, backend-review-batch-8 dead-letter visibility).** Data-only UPDATE (no schema change) paired with the `inbox_service._inbox_visible_clause` change that now surfaces terminal `status='dead'` **actionable** (non-social) rows on the recipient's in-app bell — the bell is the channel of last resort for an action item whose every push channel (Feishu+email) is exhausted/permanently-failed, which was previously a permanent silent loss (worst case: a new hire with no Feishu open_id / work_email yet, dead-lettered on attempt 1). To avoid retroactively reddening the badge for the historical dead backlog (e.g. the ~4.8k rows from the 90-day disabled-`email` era), this stamps `read_at=UTC_TIMESTAMP()` on every pre-existing `status='dead' AND read_at IS NULL` row: they stay visible in the list but don't count as unread; only rows that die *after* deploy raise the bell in real time. Idempotent (`read_at IS NULL` guard); forward-only downgrade. 0 cron / 0 RBAC / 0 MCP (the `list_my_notifications` tool + `/notifications/inbox` HTTP endpoint both delegate to the shared `InboxService`, so the fix propagates without a new surface). |
| 370 | **`external_account_registry` 新表 (2026-07-01, 跨厂商外部账号统一登记).** One annotation per non-employee email, consumed by every vendor's seat reconciliation: `email` UNIQUE (lowercase) + `classification` (service_account/shared_mailbox/external_guest/contractor = known→subtracted from each panel's external count; former_employee/unknown = still flagged) + `note` + `owner_person_id` FK→identity_persons (NON-Google owners; Google owner stays on `google_orphan_accounts` to preserve the `RES_GOOGLE_ORPHAN_OWNER` blocker). FK column type mirrors `identity_persons.id` (TiDB 3780-safe, out-of-line). Read-time JOIN in `_compute_seat_reconciliation_{detail,overview}` — no seat-sync handler changed. Backs the 「外部 / 共用账号」 admin page (generalised Google-orphan menu) + `classify_external_account` MCP tool. Idempotent (table/index/FK guards); downgrade drops the table. |
| 368 | **`cmdb_cloud_roles` 加 `sort_order INT NOT NULL DEFAULT 0` + `description_override TEXT NULL` (2026-06-30, 云角色目录策展).** Two admin-curation columns for the cloud role catalog (`/admin/cloud/role-catalog`): `sort_order` (manual display rank, lower=earlier, `0`=unranked→falls back to promoted/custom/alphabetical) and `description_override` (admin-edited employee-facing blurb; effective description = `description_override ?? description`). Both are admin-set and **survive `cmdb.roles_sync`** — neither appears in the role-sync `on_duplicate_key_update` (same persistence rule as `is_promoted` / `governance_overridden_by_admin`), so the daily sync keeps refreshing the provider `description` without clobbering admin edits. Set per `(integration_id, role_key)` across all scope rows via `POST /cmdb/role-catalog/update-by-key` (mirrors `promote-by-key`). Both the admin catalog and the employee request dropdown (`GET /cmdb/scopes/{id}/roles`) order by `sort_order` (0 sorts last via `CASE`). Idempotent (column-existence guards); downgrade drops both. No MCP (admin curation, not a self-service flow). |
| 288 | **Identity-consumer request single-use token-issue guard (2026-06-15).** Adds `identity_consumer_requests.token_issued_at` — the authoritative single-use marker for the no-login one-time token-issue link (set under a row lock inside the mint tx), so the link can't be replayed after the minted token is revoked. Security review fix. See §10.6. |
| 289 | **External API clients — standalone scoped-access store (2026-06-15).** Creates `api_clients` / `api_client_tokens` / `api_client_usage_daily` for the External API feature (§10.7) — machine-to-machine access to Helix business resources, distinct from the identity-consumer directory pull. Built with `identity_persons`' collation; out-of-line FKs (TiDB 3780-safe). See §10.7. |
| 315 | **Seed NexusCRM integration + catalog + plan + connector binding + `nexuscrm.seat_sync` (2026-06-22).** End-to-end seed for the NexusCRM (Salesforce-compatible) software-provisioning connector. Integration shell `slug='nexuscrm-primary'` (disabled, secrets empty — filled via `scripts/seed_nexuscrm_integration.py` or `/admin/integrations`). Catalog `NexusCRM` with `offboarding_policy='auto_revoke'` + `owner_person_id`/`it_contact_person_id` resolved to `jie.ni@pingcap.com` (NULL-safe if absent). Plan `NexusCRM Seat` (USD, no cap). Connector binding `execution_mode='manual_notify'` + `action_on_offboard='deactivate'` → a leaver's seat creates a manual-notify task assigned to the owner (jie.ni gets the DM); NO auto-API revoke (decision 2026-06-22, "notify only"). Scheduled job `nexuscrm.seat_sync` (daily 03:00 UTC, disabled until creds plugged in). Reuses the migration-056 `email_template.provisioning.manual_notify` setting. Idempotent. |
| 310 | **`cmdb_dedicated_lines` — 专线 (dedicated/leased line) register + 8-row CSV seed (2026-06-21).** Manually-curated table of inter-office / VPN network circuits (location / contracting_entity / carrier / payment_method / monthly_cost+annual_cost `Numeric(14,2)` + currency CNY / renewal_date / purpose / remark + modifier-only audit + soft-delete). **No upstream provider sync** — distinct from the other `cmdb_*` resource tables: no `integration_id`, no `owner_person_id`, no mark-and-sweep. FK `last_modified_by_person_id`→`identity_persons.id` declared out-of-line with parent collation (3780-safe). Migration idempotently seeds 8 rows from the IT-ops cockpit「网络专线统计」spreadsheet (only when the table is empty). `contracting_entity` values match `asset_legal_entities` names (FE picks from that register); `renewal_date` is a recurring "MM-DD" (year-agnostic, normalised by mig 311). Surfaces: `GET/POST/PATCH/DELETE /cmdb/dedicated-lines` (write `can_manage_cloud`; read `can_read_cloud` — finance/auditor/management incl., plain employee excluded since rows carry cost); FE `CloudLeasedLinesPage` at `/admin/network/leased-lines` (专线管理, under 网络资产). No MCP (admin register, not a self-service flow). |
| 311 | **专线 签约主体 normalisation + 续费日期 free-text → recurring "MM-DD" (2026-06-21).** Pure data migration (no schema change), idempotent + re-runnable. (a) Seeds every distinct `cmdb_dedicated_lines.contracting_entity` not already in `asset_legal_entities` as a CN/中国/CNY/active legal entity (match by exact name — prod was missing the 4 branch-company / 上海楷浦 entities; the 2 HQ entities already existed and are skipped). Lets the FE switch 签约主体 from free-text to a `Select` sourced from 公司主体信息. (b) Rewrites `renewal_date` free text ("每年4月份") to the recurring "MM-DD" form ("04-01") so the FE can use a `DatePicker` (`format='MM-DD'`); unparseable / already-normalised values untouched. |
| 312 | **`hr_lifecycle_events` natural key → UNIQUE (HRIS webhook double-apply fix, 2026-06-21).** Upgrades `ix_hr_lifecycle_events_person_type_date (person_id, event_type, effective_date)` from non-unique to UNIQUE so a concurrent SuccessFactors webhook redelivery can't double-insert the same lifecycle event (which fired onboarding/offboarding + immutable-ledger writes twice — `_upsert_lifecycle_event` did select-then-insert on this triple as a natural key with no constraint). Dedupes first (keep MIN(id) per group; derived-table-wrapped DELETE for the MySQL/TiDB self-reference restriction; no-op when clean). **FK-safe index swap**: the composite is the leftmost-covering index for the `person_id` FK, so a bare drop trips MySQL 1553 — both up & down create a temporary standalone `person_id` index first, then swap, then (upgrade) drop the temp once the UNIQUE composite re-covers the FK; verified on dev against both prod-like (composite = sole FK cover) and standalone states + reversible round-trip. Pairs with the service `begin_nested()`/`IntegrityError` guard in `_upsert_lifecycle_event` (race-loser re-reads winner, returns `event_created=False`, skips coordinator dispatch). 0 RBAC / 0 MCP / 0 cron change. |
| 309 | **`identity_sync_targets.alert_enabled BOOL` — per-target failure-alert on/off switch + removal of the IT-admin fallback (2026-06-19).** NOT NULL, server_default true. When false the notifier sends nothing on a failed/partial run — even with recipients configured (mute a noisy target without losing its list). Paired same-day change in `alert_notifier.py`: the empty-`alert_recipient_ids` IT-admin broadcast fallback is **removed** — empty ⇒ nobody notified (a target alerts only the people the admin picked; see DECISIONS.md 2026-06-19 "不要默认通知管理员"). The cron dispatcher's separate scheduled-job failure backstop still pages admins on a cron-tick failure (kept — independent path). Idempotent (inspector add-column guard). |
| 308 | **`identity_sync_targets.alert_recipient_ids JSON` — per-target run-failure alert recipients (2026-06-19).** Nullable list of `identity_persons.id` (UUID strings) paged when a sync run finalises `failed` / `partial` (gated by `alert_enabled`, mig 309). NULL / `[]` ⇒ nobody is notified (the IT-admin fallback was removed by mig 309 same day — see DECISIONS.md 2026-06-19). Idempotent (inspector add-column guard). Pairs with `app/domains/identity_sync/alert_notifier.py::maybe_notify_run_failure`, invoked fire-and-forget from `IdentitySyncService._finalise_run` (the trigger-source-agnostic chokepoint — closes the gap where event/manual run failures never reached the cron dispatcher's raise-on-failure notifier). Signature-dedup reuses `compute_failure_signature` over the runs table (no new column). Channels in-app + Feishu + email via template `identity_sync.run_failure` (registered in `_CRITICAL_TEMPLATE_PREFIXES`). |
| 307 | **Support deflection events — pre-file self-service funnel (求助域 AI-activation PR4, 2026-06-18).** Creates `support_deflection_events` (person_id, query, kb_article_ids JSON, copilot_answered, outcome[open·deflected·escalated], ticket_id, timestamps). Records each pre-file deflection attempt + its OUTCOME — the honest deflection metric (tickets prevented), distinct from proxy signals. `SupportService.start_deflection` (semantic KB search + `copilot.deflect_answer` grounded reply, opens event) / `mark_deflected` (owner-scoped) / `link_escalation` (best-effort, on ticket create with `deflect_event_id`). `/support/deflect` + `/support/deflect/{id}/resolved` (employee); `/admin/analytics` adds `deflection_rate`. Also `/admin/kb/bulk-draft-from-history` (bulk-draft KB from resolved tickets, bounded, idempotent) + KB keyword search upgraded to tokenised OR-match (`_apply_tokens`). FKs parent-collation out-of-line (3780-safe); `none_as_null` JSON. No new MCP (deflect funnel reuses `search_support_kb`+`create_support_ticket`). |
| 306 | **Support KB semantic embeddings (求助域 AI-activation PR3, 2026-06-18).** Adds `support_kb_articles.embedding` (portable JSON float array, `none_as_null=True` so None↔SQL NULL — NOT TiDB VECTOR, keeps local MySQL tests runnable; ranking is Python cosine) + `embedding_model` + `embedded_at`. `SupportKbService.search_published` is semantic-first (embed query via `copilot.embed_texts` → cosine over embedded published articles → keyword top-up to limit) with graceful keyword fallback when the embeddings backend is off / nothing is embedded yet. Embeddings produced by `BailianLLMClient.embed()` (DashScope compatible-mode `/embeddings`, `text-embedding-v3`, bailian-only, gated). Embed-on-publish/-edit runs fire-and-forget (`run_kb_embed` + `spawn`, idempotent, own session) so the publish response isn't blocked; admin `POST /support/admin/kb/backfill-embeddings` (bounded ≤100) + KB page "rebuild semantic index" button + per-article indexed flag. Idempotent + re-runnable migration. |
| 302 | **Support knowledge base — self-growing help articles (求助域 PR4, 2026-06-17).** Creates `support_kb_articles` (title/body/category/status[draft·published·archived]/source[manual·ai_ticket] + source_ticket FK + author). Published articles are the AI copilot's deflection surface (`search_knowledge_base` tool + `search_support_kb` MCP + employee `/support/kb` browse). Resolved tickets are AI-drafted into `source=ai_ticket` drafts (`copilot.draft_kb_from_ticket`, deterministic fallback) then admin-published — the self-grow loop. Also adds IT-side AI assist (`/support/admin/tickets/{id}/ai-suggest`) + ops analytics (`/support/admin/analytics`). FKs built with parent collation (3780-safe). |
| 301 | **Support tickets — IT help-desk domain / 求助域 (2026-06-17).** Creates `support_tickets` (ticket header + lifecycle: open/triaging/in_progress/waiting_requester/resolved/closed/cancelled, `HELP-YYYYMMDD-XXXXXX` number, category/priority, assignee, resolution, SLA timestamps) + `support_ticket_messages` (append-only timeline: comment/status_change/resolution/system). The free-form "ask IT for help" surface closing the template-only employee dead-end. Person/ticket FKs built with `identity_persons`' collation (3780-safe). Requester-facing updates go through the governed notification outbox (category `requests_approvals` → respects PR1 prefs); new tickets fan out to IT (global_admin) via the per-admin card path. Plugs into `build_my_requests`/`build_team_requests` (source `support`). 5 employee self-service MCP tools. **PR3 (0 migration) adds the AI IT copilot** `app/domains/support/copilot.py` — a gated, server-side manual agent loop (`anthropic` SDK, `claude-opus-4-8`) that answers grounded in the employee's own data via two read-only RBAC-scoped tools (`get_my_support_tickets` / `get_my_requests`), and escalates to a ticket deterministically when it can't resolve. Disabled-until-configured: needs `copilot.enabled` + `copilot.anthropic_api_key` (or `ANTHROPIC_API_KEY`); `/support/copilot/chat` returns a graceful disabled response otherwise. Endpoints `/support/copilot/{status,chat,escalate}`; FE `/me/support/ai`. **Pluggable LLM backend (2026-06-18)**: `copilot.py::_resolve_llm` picks **bailian** (Qwen via the existing AI/LLM connector `ai_spending.llm.*` + `BailianLLMClient.chat()` compatible-mode function-calling) or **anthropic** (`copilot.anthropic_api_key`/env) — auto-prefers whichever key is configured; the same RBAC-scoped tool set drives both agent loops. Admin enable + provider via `/support/admin/copilot/config`. So the org's existing Bailian key activates the whole AI half (copilot + ai-suggest + ai-kb-draft) with no Anthropic procurement. **AI auto-triage (2026-06-18, AI-activation PR2, 0 migration)**: `copilot.triage_ticket` classifies a fresh ticket's `category`/`priority` and `SupportService.apply_ai_triage` fills **defaults only** (category iff requester left `other`, priority iff left `normal` — never overrides an explicit choice), recording a `system` timeline message + `support.ticket.ai_triaged` audit. Runs **fire-and-forget** via `app.core.background.spawn` on its own session after ticket-create commit (never blocks the create response on LLM latency; guards on still-fresh `open`/`triaging` status so it won't fight an agent who already recategorised; full no-op when copilot disabled). Reuses the provider-agnostic `_one_shot_llm`, so it runs on whichever key (Qwen/Claude) is configured. **Workbench/mobile admin polish (2026-07-10, 0 migration)**: admin queue semantics are now explicit — `status='open'` means any still-active ticket, `status='resolved'` means `resolved + closed`, and `status='all'` truly means unfiltered (instead of silently falling back to `open`). `queue_stats()` also surfaces `resolved_total` + `total_total`, which the desktop [`SupportWorkbenchPage`](../../web/src/pages/admin/SupportWorkbenchPage.tsx) uses for clickable KPI cards + tab badges (`处理中` / `已解决` / `全部`) with URL-deep-linkable state (`status` / `page` / `q`). Mobile global-admin support handling stays on the shared [`MSupportTicketDetailPage`](../../web/src/mobile/pages/detail/MSupportTicketDetailPage.tsx): `/m/admin/support` queue browse + `/m/admin/support/tickets/:id` agent detail cover claim, reply, resolve, reopen, and notification-card deep links via `mobileDeepLink`. |
| 300 | **Notification preferences — per-person notification governance (2026-06-17).** Creates `notification_preferences` (PK = person id, 1:1, sparse — a missing row = all defaults): `muted_channels` JSON (`{category_key: [channel]}`) + daily quiet-hours window (`quiet_hours_enabled` + `quiet_hours_start/end` "HH:MM", resolved in the recipient's local tz). Inline FK to `identity_persons` built with the parent's collation (3780-safe). Backs notification governance: `NotificationService._deliver` + `push_inbox_and_feishu` resolve the row at send time to mute push channels (treated like a disabled-integration skip → inbox-only) or park non-critical push to the window end (the existing outbox retry-drain delivers it — no new cron). Critical templates (`is_critical_template`) + the in-app inbox row always bypass the gate. The category taxonomy lives in `app/notifications/categories.py` (single source for the preference matrix + inbox buckets + MCP). |
| 424 | **HR dynamic email-group management (2026-07-22).** Backs the `/api/v1/email-groups/dynamic/*` admin API + `google.dynamic_group_drift` cron: adds the dynamic-rule persistence + drift-state columns on `email_groups` (`dynamic_rule` JSON — structured `{field,match_values,include_emails,negate}`; `dynamic_dead_values` JSON — last-computed dead values; `drift_signature` — dedup hash over dead+missing so IT re-alerts only on state change; `alert_on_missing_values` BOOL — opt-in missing-value drift alert) and seeds the daily `google.dynamic_group_drift` scheduled job. See §12.8. New RBAC gate `can_manage_dynamic_email_groups` (hr + global_admin; direct-effect, no four-eyes, audited + IT-only notify). **⚠ Pre-deploy ops step**: add the Google SA DWD write scope `https://www.googleapis.com/auth/cloud-identity.groups` (currently readonly only) or all dynamic-group writes 403. |
| 429 | **云成本双口径 — 业务折前成本（list cost）schema 底座 (2026-07-27, 成本口径改造 PR1/6).** `cmdb_cost_facts` + `amount_usd_list`/`amount_native_list`（研发口径:白名单 usage 行的实际执行价,spot/阶梯保留、公司折扣剥离;其余行 NULL → 读侧裸 SUM 即口径,未知行类型默认出局）+ `amount_usd_public`（按需牌价;public − list = 研发自主节省）+ `cost_bucket`（9 值闭集,财务瀑布 GROUP BY);`cmdb_cloud_billing_daily` + `total_cost_usd_list`/`total_cost_usd_public`。现有发票口径列零变化。幂等可空加列,无锁。写侧填充自 PR2（AWS BQ CUR / GCP cost_at_list 白名单,均已用真实账单核验）。 |
| 431 | **成本口径读面覆盖索引 (2026-07-28, 财务瀑布 8175 修复).** `cmdb_cost_facts` + `ix_facts_date_measure (date, integration_id, cost_bucket, amount_usd, amount_usd_list, amount_usd_public)` —— 财务瀑布 / methodology 对数区的月度双口径聚合走 IndexRangeScan 不回表（此前 6 个月 GROUP BY 全表扫 840 万行打爆 TiDB serverless 单 SQL 内存配额,错误 8175,页面 500）。读侧同时按月分块（`cost_service.monthly_measure_rollup`）+ Redis 响应缓存（`COST_READ_CACHE_TTL_SECONDS`）。生产已先行手工建成（在线 DDL 54s）;迁移 information_schema 存在性守卫幂等。 |
| 432 | **幂等记录请求指纹 (2026-07-29).** `core_idempotency_records` + `request_fingerprint`（nullable）。同一 `Idempotency-Key` 配不同 body → 422 `IDEMPOTENCY_PAYLOAD_MISMATCH`,而不是把**第一次**调用的成功结果当本次成功返回（一个复用 key 的客户端 bug 会拿到「一次没发生的写入」的成功回执）。NULL = 部署无缝（旧行没有指纹 → 跳过比较);form/multipart 请求也存 NULL（读流会 `Stream consumed` 500,Starlette 只在这两种 content-type 上消费流）。 |
| 433 | **`cmdb_cloud_scopes.budget_measure` + 从审计回填 #2079 已换算标记 (2026-07-29, 数据修复).** 预算口径升为一等列（NULL ≡ 实付）,取代脚本私有的 `platform_settings.cloud.budget_measure_list_scope_ids`（已由迁移消费并删除）。#2079 生产那次换算**不落标记**,留下「list 数字 + 零标记」的行:再跑一次 `--apply` 会二次 ×ratio（实测 675 → 1822.5 → 4920）,且其 ~2.7× 阈值被按实付评估、永远打不到。迁移从那次运行留下的审计行（`cmdb.scope.budget_measure_converted` / `cmdb.scope.alert_rules_measure_converted`）回填 `budget_measure='list'` 与逐规则 `"measure":"list"`,按 `(type, 换算后阈值 ±0.005)` 匹配;被管理员改过的阈值**故意不标**（那个数字是管理员按自己在看的口径填的,实付是保守默认),由评估器的 `cloud.scope_alert measure ambiguity` 日志 + 页面口径徽标兜底。幂等、无审计行时纯 no-op。 |
| 434 | **`cmdb_cost_facts` 非 usage 桶行重键到带桶 `fact_key` (2026-07-29, 数据修复).** 桶分量改为 `cost_bucket` 的纯函数后,旧行需重键。**重键而非删除** —— 删除依赖「下次摄取重建」,而这对滚动 3 天水位之外的窗口（即全部历史）是假的。重建是**验证式**的:从行自身列重算普通 hash,只在等于已存 `fact_key` 时才改写;`scopes_sync` 滞后期摄入的行（`scope_id IS NULL` 但 hash 用了云端 id）走**前像搜索**——遍历该 integration 的所有 `external_id`（含软删）,只接受能重现已存 hash 的候选,这是密码学证明而非启发式匹配。**按批 commit**:中途 abort 留下部分修复但一致的表,`alembic_version` 停在 433,重跑即续（MySQL/TiDB 上 `transactional_ddl is False`,alembic 的 per-step 事务本就独立,故未改 `env.py`）。写侧用 200 行 `UPDATE … CASE id` 批语句而非逐行。残留:`external_id` 在 `cmdb_cloud_scopes` 里彻底不存在的行（scope 被硬删）计入 `not_reconstructible` —— 它们**不会**双计,因为该 scope 已不是摄取目标、窗口永不重放。 |
| 435 | **Bedrock 按 Key 看花费 —— profile-facts 增加标签轴 (2026-07-29).** `cmdb_bedrock_profile_facts` + `profile_tag_key`(默认 `'UsedBy'`,存量行默认值即正确、无需回填)并进 `fact_key` 自然键 + `ix_bpf_int_key_date (integration_id, profile_tag_key, date)`。让同一张隔离表同时容纳**工作负载轴**(`UsedBy`)与 **per-Key 轴**(`iamPrincipal/helix-request-number` —— `bedrock_autogrant` 一直在打的 IAM 标签),从而给「与同名 sibling 共享 `Owner` 标签、被 #1776 钉成 $0」的 Key 一个真实数字。`cost_facts.aws_profile_tag.<slug>` 升级为可配多根轴(每轴一次 CE 扇出、汇总成一次 ingest —— 窗口 DELETE 覆盖所有轴,逐轴调用会互相擦掉)。**cost_facts 零变化 → 所有大盘/分摊聚合零影响**;两根轴各自都是同一 owner 总额的完整拆分,**永远不要跨轴相加**。上线还需人工在 AWS Billing 激活该标签(仅对激活后用量生效),见 ops §9a-bedrock。 |
| 442 | **飞书审批创建持久 intent（2026-08-02，#2283/#2355/#2360；依赖 #2301 的 441）。** 新增 `approval_submission_intents`：按 `(scope, applicant_id, idempotency_key)` 唯一，保存 payload fingerprint、飞书原生幂等 UUID、状态、外部 instance code、预生成的本地 request ids 与恢复所需表单快照；`(state, updated_at)` 索引供既有 15 分钟漂移对账批量恢复。 |
| 449 | **飞书审批 intent 终态最小化与公平重试（2026-08-03，#2504/#2507）。** 新增可空、带索引的 `next_attempt_at`，供全部 11 域持久退避与到期公平排序；迁移按 scope 将既有 `completed` / `compensated` / `failed` JSON 压缩为 `terminal-v1` 资源收据。列、索引与数据回填均可安全重跑，适配 MySQL/TiDB 非事务 DDL。 |
| 469 | **DNS 申请执行面从飞书 webhook 里搬出来（2026-08-14）。** 新增 `cmdb_dns_record_requests.execution_attempts`（存量 `executing` 行回填为 1，不白送一次重试额度）+ 两个 cron：`cmdb.dns_request_executor`（每分钟排空 `approved`）与 `cmdb.dns_request_execution_reaper`（每 10 分钟回收 `executing`）。起因：一条已审批的申请被 DNSPod 以 `LimitExceeded.RecordTtlLimit` 拒绝后，失败兜底自己抛 `MissingGreenlet` 而死，行永久停在 `executing` —— 申请人页面一直「处理中」，无审计、无通知、无任何管理端出口（reaper 只扫 `pending` 审批行，resume 只认写锁失败）。 |
| 470 | **对外 API 客户端的云可见范围白名单（2026-08-15）。** 新增 `api_clients.cloud_visibility` JSON（`{"providers":[…],"subscription_ids":[…]}`，两个列表取并集；`NULL`=不限制，因此对存量客户端零影响）。scope 决定「能读哪类云数据」，白名单决定「能读哪些云账号」—— 在只提供清单元数据时这个区分还能凑合，一旦云**账单**走同一条凭据面就不能凑合了。失败方向恒为关闭：解析不到任何 integration/订阅的受限白名单变成 `false()`，绝不退化成「无约束」。命中范围外统一 404（不是 403，403 等于确认该账号存在）。见 §10.7。 |
| 471 | **云日账单摄取水位加索引(2026-08-15)。** `cmdb_cloud_billing_daily(fetched_at)` 加 `ix_billing_daily_fetched_at`,支撑对外账单 API 的增量拉取(`/billing/changes` 与 `?updated_since=`)。**选哪一列是实测的,不是推断的**:该表 upsert 的 SET 列表里写 `fetched_at`,而 `updated_at` 只有 SQLAlchemy 客户端侧 `onupdate`(`ON DUPLICATE KEY UPDATE` 路径不触发)且 DDL 无 `ON UPDATE CURRENT_TIMESTAMP` —— 索引 `updated_at` 会得到一个永远返回空的增量流。姊妹表 `cmdb_cost_facts` 恰好相反(SET 列表里是 `updated_at`),那一侧的查询已被 `(scope_id, date)` 限定走 `ix_facts_scope_date`,**故意不给千万行表新增索引**。 |
| 478 | **阿里云成本 × 财务数仓月度对账 cron(2026-08-16)。** 种子 `cmdb.cost_recon.aliyun_bq`(每月 8 号 09:00 SH,排在 5 号月结重拉之后三天)。把上一个完整自然月的阿里云折前成本与 BigQuery `gcp-digital-bi.alibaba_cloud.monthly_en_*` 按财务口径逐账号对比 —— **只读、只比、只报告**,发现实质漂移(相对差 >1% 且绝对额 ≥$50)时 raise 走失败通知。口径一致本身由 `cost_view` 的行白名单保证(迁移 429/PR #2758),这个 cron 把它变成每月可证的事实。仅插 `scheduled_jobs` 一行,无 schema 变更,幂等(`WHERE NOT EXISTS handler_key`)。 |
| 481 | **月结加第二遍 + 对账后移(2026-08-16)。** `cmdb.cost_facts_monthclose` `0 7 5 * *` → `0 7 5,12 * *`;`cmdb.cost_recon.aliyun_bq` `0 9 8 * *` → `0 9 14 * *`。起因见 478 行与 CHANGELOG:阿里云在 5 号还没把上月最后一天结算完。UPDATE 带「仍是种子值」判据(不覆盖运维手改),`description` 用 `REPLACE` 外科替换。无 schema 变更,幂等,可 downgrade。 |
| 482 | **两条云账单链路的日级互查 cron(2026-08-16)。** 种子 `cmdb.cost_facts_chain_check`(每天 08:30 SH)。日总额 × 逐行明细之和,抓厂商翻页静默截断;阈值 比值<0.85 且缺口≥$200 在全年 11,716 个账号×天上实测零误报。只插 `scheduled_jobs` 一行,无 schema 变更,幂等,可 downgrade。 |
| 483 | **`cmdb_cost_facts` 索引瘦身(2026-08-16)。** 删掉 4 个最左前缀重复的索引(`fk_*_scope` / `fk_*_integration` / `fk_*_owner` / `ix_facts_date_int`)。该表 938 万行、索引 6.37 GB > 数据 4.38 GB;`EXPLAIN` 过全部真实查询形状,无一走全表扫描 ⇒ 方向是减不是加。`ix_facts_date_id` 刻意保留(非前缀子集)。每个 DROP 带存在性 + 覆盖索引双守卫,且**失败只 WARN 不中断** —— 三个 `fk_*` 是 TiDB 建外键时自动补的、仅存于生产,本地验证不了它是否可删;存储优化不该卡住迁移链。可 downgrade。 |
| 484 | **角色 / 软件权限的时点持有台账(2026-08-16)。** 新建 `auth_user_role_cycles` + `user_permission_cycles` 两张 append-only 区间表 —— 此前 `auth_user_roles` 撤销即 DELETE、`user_permissions` re-request 原地覆盖,「某人在某时点持有什么」无法用 SQL 回答(SOC2 CC6.1/CC6.3 必问)。**无外键**(同 `audit_logs`:取证行要比父行活得久 + 授权热路径不加父行共享锁)、**无 UNIQUE**(证据台账不得让被观察的业务事务失败)、区间列 `DATETIME(6)`(秒级下同秒双周期不可排序 ⇒ 证据包不可复现)。写入见 `app/core/access_cycle_listener.py`,须在 `main.py` **与** `worker.py` 双注册。存量不回填。幂等,可 downgrade。 |
| 485 | **审计证据 bundle 两表(2026-08-17)。** 新建 `auditor_evidence_bundles` + `auditor_evidence_bundle_rows` —— 一次证据查询被冻结成不可变快照(问题 + 版本 + 规范化参数 + 结果 hash + 逐行出处),外部审计师几个月后重开看到的仍是同一份。同一问题重跑产生新 bundle,hash 不同 = 底层数据动了(审计信号,非 bug),故两表无 UPDATE 路径。**不建外键**(同 `audit_logs`):证据行必须比它引用的 person / 目录 / 授权行活得久。幂等,可 downgrade。 |
| 490 | **IT 侧 AI 建议台账 + 知识库缺口周报(服务台收编 PR-4, 2026-08-17)。** 新表 `support_ai_suggestions`(ticket FK / agent FK / suggestion / model / adopted_at / adopted_similarity;`id_type_for` 对齐父表 collation):每次 `/tickets/{id}/ai-suggest` 的草稿落台账,`SupportService._mark_suggestion_adoption` 在 IT 客服回复时比对 2h 内最近未采纳草稿(difflib ratio ≥0.55 → 记采纳)——「AI 帮 IT 省时间」的诚实口径;analytics 补 `ai_suggest_total/adopted/adoption_rate`,工作台加采纳率卡。相似工单召回换 `find_similar_resolved`(CJK 二元组+英文词 shingle,SQL OR-LIKE 预筛 + Jaccard 排序;旧「标题首个空格分词 ILIKE」对中文标题是事实 no-op)。种子 cron `support.kb_gap_digest`(cron `0 9 * * 1` Asia/Shanghai):`support_deflection_events` 中 escalated/expired 且 `kb_article_ids` 为空(检索零命中,含飞书镜像面)的问题,shingle-Jaccard(0.28)聚类 → 模板 `support.kb_gap_digest`(三语)经 `fanout.enqueue_then_drain` 发全体 IT 管理员;**无缺口静默**;IT-only 符合推送红线;窗口去重标记 `support.kb_gap_digest.last_sent_at`(fan-out 成功后写,窗口内重跑 skip)。**PR-5 追加(0 迁移, 2026-08-17)**:制度中心融合——coordinator [`policy_notice_kb_coordinator.py`](../../server/app/core/coordinators/policy_notice_kb_coordinator.py) 把已发布 `policy_notices` 条目的 wiki 链接解析为节点 token 并入镜像同步(`external_root_token='policy_notices'` 哨兵作用域,组内 all-or-nothing 归档;非 wiki 链接计数上报);开关 `support.feishu_kb.include_policy_notices`(默认开;**关闭=主动清扫**制度镜像行出检索,republish/re-enable 自动复活);通用修复:清扫归档时重置 `external_edited_at`,原样回归的文档(水位不变)才能复活。 |
| 498 | **审计证据月度预编译 cron(2026-08-17)。** 种子 `auditor.evidence_precompile`(每月 2 号 03:20 SH)。按**上一个完整自然月**预跑目录里「无必填参数」的公司级证据查询并冻结 bundle。跑哪些查询从目录的参数声明**推导**;只跑已结束的月;逐条 commit + 每条一个 savepoint;幂等按 canonical params 比对(按周期列比会漏掉点时查询);`config.query_keys` 打错字报错而非静默编译 0 条;月窗口按 Asia/Shanghai 算。只插 `scheduled_jobs` 一行,无 schema 变更,幂等,可 downgrade。 |
| 487 | **求助工单满意度回评 + resolved 超时自动关单(服务台收编 PR-2, 2026-08-17)。** `support_tickets` 加 `satisfaction_score`(SmallInteger,1=不满意/2=一般/3=满意,**NULL=未评** — 口径与 `feishu_helpdesk` 镜像的 3 分制/score=0 排除完全对齐,双入口并行期质量可直接对照)+ `satisfaction_at`。评价规则(`SupportService.rate_ticket`):仅求助人、每单一次、resolved/closed 状态;system 时间线消息 + `support.ticket.rated` 审计(detail.via=web/token)。两条路径:已解决通知卡的本地化 CardAction(`LocalizedText` 三语)→ **免登录魔法链接**(`GET /support/rate` 落地页无副作用防邮件预取,页内三键 POST → `consume_action_jti` 单次消费,重复幂等「已评价」;`enforce_action_token_subject` 拒离职/强登出;页面在 [`rating_pages.py`](../../server/app/domains/support/rating_pages.py),视觉同 provisioning quick-complete);已登录路径 `POST /support/tickets/{id}/rate`(桌面+H5 详情页三键)。种子 cron `support.ticket_autoclose`(6h,`config.resolved_days` 默认 7):resolved 超期未确认自动 close(system 消息+审计,**无通知扇出**),终结「永远挂 resolved」并收口回评窗口(closed 仍可评,窗口由 token TTL 14 天兜底)。analytics 补 `satisfaction_scored/avg/rate/by_score`;工作台加「满意率·已评数」卡。**PR-3 追加(0 迁移, 2026-08-17)**:同 cron 顺路清扫悬空 deflection 会话(`outcome=open` 超 `config.deflect_open_expire_days`(默认 3)→ `expired`,decided 行永不触碰,rate 分母不变,analytics 补 `deflection_expired`);新 coordinator [`support_entrance_coordinator.py`](../../server/app/core/coordinators/support_entrance_coordinator.py) 跨域(support × feishu_helpdesk)聚合双入口周新增 → `GET /support/admin/entrance-metrics`(admin-read),工作台「双入口周趋势 + Helix 入口占比」卡 = 切流判据数字;MCP `create_support_ticket` 补触发 AI 分诊(与 HTTP 建单对齐)。 |
| 486 | **飞书 wiki 文档 → 求助域 KB 镜像源(服务台收编 PR-1, 2026-08-17)。** `support_kb_articles` 加 4 个镜像溯源列:`external_obj_token`(unique,一文档一行;MySQL/TiDB unique 索引容多 NULL,原生行不受影响)/`external_url`/`external_root_token`(归档清扫的作用域)/`external_edited_at`(**秒级** `obj_edit_time` 水位 — wiki API 返回秒,服务台工单 API 是毫秒,勿混)。同步在 [`app/domains/support/feishu_docs.py`](../../server/app/domains/support/feishu_docs.py):配置根节点(`support.feishu_kb.root_tokens` platform setting,管理端 KB 页可视化配置)→ BFS 子树(has_more/page_token 分页;≥2000 节点截断上报)→ 变更文档拉旧版 `doc/v2` 或新版 `docx/v1` raw_content upsert 为 `source='feishu_doc'` published 文章(正文变更清 embedding 走既有重嵌链路);空壳文档(去标题后 <20 字符)落 **archived 存根**只携带水位(否则每轮重复拉取);消失文档仅在**该根整树遍历成功**后归档(半次失败绝不下架);状态机器所有(文档回归自动复活)。**恢复规则（2026-08-20）:** 已下架行即使水位未变也必须重新拉取；仅 `published + 相同水位` 才能跳过，避免曾被错误扫下架、但仍在已配置目录中的文档永久不可检索。对员工知识型提问，assistant 在调用模型前先预取已发布 KB；命中正文与来源作为当轮受控证据，避免模型未检索便虚报“没有指南”。人工编辑/改状态被 `kb_service._guard_mirrored` 422 拒绝。凭证零新增:复用 chat_approval 应用 + `FeishuApprovalClient` token 缓存;需 tenant scopes `wiki:wiki:readonly`、`docx:document:readonly` 与旧版 `doc:document:readonly`，且应用加入知识空间(缺权限报 99991672/131006,自助开通链接在报错里)。种子 cron `support.feishu_kb_sync`(6h,`distributed_lock("support_feishu_kb_sync")` 与管理端「立即同步」互斥,后者 202 后台跑、前端轮询 `last_sync` 回显)。检索联邦零消费方改动:`search_published` 天然命中镜像行,copilot/deflection/MCP/员工搜索序列化补 `external_url` 原文档链接。真实租户实测:26 docx→20 published+6 archived 存根,二轮 26 unchanged 零重复拉取。 |
| 514 | **HRIS userId 别名表（一个人两段并行雇佣, 2026-08-18）。** 新表 `identity_hris_user_aliases`（`person_id` FK / `source_system` / `alias_user_id` / `person_id_external` / `hris_instance_id` / `reason` / `first_seen_at` / `last_seen_at`；UNIQUE `(source_system, alias_user_id)` + `ix_identity_hris_alias_person`）。SF 里「人」是 `personIdExternal`、「一段雇佣」是 `userId`，Helix 用 `userId` 当人的主键；某人持有两段并行雇佣时，`EmpJob.managerId` / `EmpJobRelationships.relUserId` 指向的是 Helix 不建行的那个 userId ⇒ 6 个在职员工「无上级」。解析顺序:主键列 → 别名表。`id_type_for('identity_persons')` 对齐父列 collation；`downgrade` 直接 `drop_table`（`ix_identity_hris_alias_person` 是 person_id 外键的支撑索引，单独 `drop_index` 会被 MySQL 1553 拒绝）。幂等，可 downgrade。 |

---

## 17. Cross-domain wiring & non-obvious behaviors

These are the things that cost time when forgotten:

1. **Leaver Blocked guard runs on every flush, not just admin API.** SQLAlchemy `before_flush` event listener is registered at app startup. Background tasks, scripts, sync confirms — all caught. `attempted_via='listener_trap'` in `leaver_block_audit` tells you the path.

2. **Manager scope is NOT a DB role.** Self-reference on `identity_persons.manager_person_id` derives Manager scope at request time. `current_user.subordinates` is the check.

3. **`cloud_admin` is a marker, not an authority.** Holders' grants never expire (`expires_at = NULL`); the inactivity / expiry sweeps skip them. They CANNOT decommission scopes (changed 2026-05-03: `can_manage_cloud_scope_lifecycle` narrowed to `global_admin` only — decommission is irreversible cloud-account closure). They CANNOT mutate integrations (`can_manage_cloud` returns false).

4. **Notification outbox is invariant.** Domain code dispatches via `NotificationService.dispatch()` only. No silent drops.

5. **Audit log is append-only at the DB layer.** Trigger raises on UPDATE. Stricter than application logic; broken contracts surface at the persistence layer.

6. **Encryption key mismatch returns 503, not 400.** It's an infrastructure problem, not input validation. UI / orchestrator treats it as a service-dependency issue.

7. **PAT and JWT share the bearer scheme.** PAT detection is prefix-based (`helix_pat_`). Both set `request.state.auth_via` for downstream logging/audit.

8. **Workflow journal is shared infrastructure, not domain.** `workflow_runs` / `workflow_step_events` are written by offboarding, provisioning, approval, leaver-blocked override execute. Service is the only writer.

9. **Cloud per-capability sync uses `JSON_OVERLAPS` for locking.** Disjoint capabilities (servers + dns) run in parallel on the same tenant; overlapping ones serialize. Pre-2026-04-24 it was per-tenant single-flight.

10. **Cloud sync + roles + grants jobs auto-create when an integration is added.** `IntegrationService.create()` calls `_reconcile_cloud_scheduled_jobs()` which inserts per-capability rows. Admin must enable them via UI.

11. **Inventory cycles are immutable snapshots.** Asset changes after cycle start do NOT update cycle items. Responses carry `transferred_to` / `moved_to_location` instead.

12. **SuccessFactors is the authoritative HRIS.** Multi-instance support exists for data residency (China + EU SF). Other HRIS drivers (Workday, BambooHR) would slot in as `HrisDriver` subclasses.

13. **Cross-instance ownership in HRIS sync is an explicit skip — with a company-code canonical-owner override** ([`_stage_event` in `server/app/domains/hr/service.py`](../../server/app/domains/hr/service.py)). The instance-scoped existing-person lookup is followed by a cross-instance sweep on `(source_system, hris_person_id)`. If the user is owned by a different `hris_instance_id`, `_stage_event` returns `None` — the running sync does not touch the row, the staged batch shows zero rows for that user, no welcome email, no provisioning. Found 2026-05-02 when sf-eu-prod was emitting CN-tenant employees and the welcome trigger fired hourly. **Onboard-poach guard (2026-07-16)**: because CN+EU mirror one org, a mirrored pre-start hire could surface FIRST in the non-owning tenant's incremental window (neither instance has a row yet, so the skip above can't fire) and be created with the wrong `hris_instance_id` — permanently, since the true owner then hits the skip forever. Each `hris` integration may now declare the FOCompany codes it canonically owns via `config_public["owned_company_codes"]` (JSON list or comma-separated string; admin-form field on the SF driver). At onboard-create time `_company_owner_for(EmpJob.company)` resolves the canonical owner: the non-owner **defers** creation (stages nothing; the owner's own sync creates the row), and the owner **reclaims** an already-poached row (preview lets the event through instead of skipping; `_upsert_person` repoints `hris_instance_id` — both userId-keyed and, for differing userIds, via the empId fallback, which now also reclaims ACTIVE foreign rows when the mapping says this instance owns the code). Unmapped codes / absent config keep legacy first-writer behaviour, so single-instance installs are untouched. Regression: [`test_hr/test_onboard_poach_2026_07_16.py`](../../server/tests/test_hr/test_onboard_poach_2026_07_16.py).

13. **Worker reload is hourly.** Cron / enabled changes take effect within the hour. Intentional — avoid constant DB polling.

14. **Bailian usage rows often have 0 token counts.** Aliyun BSS Billing API doesn't expose tokens. Rely on `cost_*` fields; check `raw_data["source"]`.

15. **API key auto-grant binds dual-purpose IAM principals (or a fresh RG + Foundry project on Azure).** Bedrock creates an AWS IAM user; Bailian creates an Aliyun RAM user. Both also bind `cloud_scope_id` so chargeback works. Azure OpenAI / Microsoft Foundry is the third pattern — instead of an IAM principal, every approved request gets (a) a dedicated **Resource Group** under the standing PingCAP Azure OpenAI subscription, (b) a Microsoft Foundry **resource** (`Microsoft.CognitiveServices/accounts` of `kind=AIServices`, `allowProjectManagement=True`, api-version `2025-06-01`) inside it, and (c) a Foundry **project** (`accounts/{name}/projects/{project}`) underneath. The RG is mirrored as a `cmdb_cloud_scopes` row of type `azure_resource_group` (`parent_scope_id` → the subscription scope) and `apikey_assignments.cloud_scope_id` FKs there. The auto-written `cmdb_cost_allocation_rules` row (priority 10) keyed on `match_integration_ids=[<sub>]` + `match_resource_group=<rg name>` chargebacks every Azure cost line item under the RG to the applicant. Cost ground-truth lives in `cmdb_cost_facts.resource_group` so allocation works without RG-FK on facts. The legacy `kind=OpenAI` autogrant shape (briefly minted by PR #233 on 2026-05-10) was retired the same day in favour of the AIServices+project shape that matches the Foundry portal's "Create a project" wizard — Microsoft is consolidating standalone Azure OpenAI Service under Foundry.

16. **`asset_software_catalog.category='ai'` is the source of truth for "AI tool".** No legacy boolean.

17. **Cloud scope owner auto-fill races.** If exactly one person holds an owner-equivalent role, `cmdb.scopes_sync` auto-assigns. If two, neither — admin must decide. If owner later loses the role, ownership stays (no auto-revocation of ownership).

18. **Prod scopes are fully excluded from the expiry sweep (2026-07-16).** No reminder, no auto-revoke, no notification (member or owner). Production cloud access is not managed by Helix — the R&D security team owns it end-to-end; the prod cloud-write lock is default-locked so any Helix-side change is break-glass. (Historically prod opted out of revoke only and pinged the owner once; that owner ping is removed.)

19. **Discovered grant drift is noisy.** Helix-sourced grants disappearing cloud-side = serious incident. Discovered grants disappearing = backfill cleanup, less urgent.

21. **`approval_request_id` means two different things depending on the table — and only some of them have an FK.** `SoftwareSeatAllocation.approval_request_id` ([`asset/models.py`](../../server/app/domains/asset/models.py)) and `SoftwareAddonCharge.approval_request_id` are **FK-less** columns that hold an **`employee_requests.id`** (the seat/addon routers stamp `approval_resp.id`, and `create_asset_approval_request` returns an `ApprovalBackedEmployeeRequestResponse`). `IdentityConsumer` and the CMDB `String(36)` variants hold a real `approval_requests.id`. `ProvisioningTask.approval_request_id` has a **real FK to `approval_requests.id`** — so copying a seat's value straight into a task raises `IntegrityError` 1452. That is exactly the 2026-08-06 Zoom-seat incident. Both doors into that FK now resolve-or-null before writing: `_resolve_task_approval_request_id` in [`provisioning_coordinator.py`](../../server/app/core/coordinators/provisioning_coordinator.py) and the equivalent guard in `ProvisioningService.create_task`. **Before adding a third writer, check which meaning your source column carries.**

23. **A swallow-and-continue block hides `AttributeError` just as well as it hides DB errors.** `ApprovalService.audit` did not exist for seven call sites in the asset auto-execute path; every call raised, every raise was swallowed, and the only symptom was **missing audit rows** — nothing surfaced for months. When a block's whole purpose is "never fail the caller", nothing inside it is covered by "it would have blown up in testing". Assert the observable side effect (the audit row, the queued task), not just the happy-path state change.

25. **Three shapes that must NOT be wrapped in a savepoint** (all three cost a red CI round on 2026-08-06): a block that **commits mid-flight** (the commit releases the savepoint, so the block exit operates on a freed one and every item reports as failed — fix by `await db.rollback()` at the top of the handler instead); a block that **performs no DB writes** (e.g. pure vendor HTTP where `db` is only threaded through — tests may even pass `None`); and a **`finally` that releases a lock** while the original exception is propagating (opening a savepoint on an already-poisoned session raises and *masks* that exception). Mark these `# savepoint-exempt: <reason>` — the CI guard cannot detect an internal commit on its own.

24. **`app.core.txn.best_effort` is the one way to write a best-effort DB step.** It encodes both halves of the rule below (SAVEPOINT so the session survives; re-raise 1205/1213/1305 because a savepoint rollback cannot). `scripts/check_savepoint_isolation.py` is a CI **ratchet** — `savepoint-baseline.json` holds the known backlog (**32 findings / 22 files as of 2026-09-05**; the original 99 were drained to 0, then the widened detector below surfaced a fresh 33, of which one file has since been drained), new occurrences fail the build immediately, and the baseline may only shrink. Run it with `--list` for the backlog, `--update` after draining some. Escape hatch is `# savepoint-exempt: <reason>` and it requires a written reason.

    **The guard is interprocedural since 2026-09-02 (PR #3394 follow-up).** It used to see only DB work written LITERALLY in the try body, so the two github_org reconcile loops — `await reconcile_org_invitations(db, org, ...)` inside a broad `except` — passed it and shipped the bug. It now also flags an **awaited call handed the caller's session** (`await f(db, ...)` / `f(self.db, ...)` / `f(..., session=session)`) when the callee itself **mutates** that session, chasing one level into the callee's own callees (`WriteIndex`, `_WRITE_CHASE_DEPTH = 1`; depth 2 measured identical). **Only mutations count, and that distinction is the whole design:** the naive form — flag every `await f(db, ...)` — produced **148** findings on the 2026-09-02 tree, dominated by adapter reads like `await AwsAuditorAdapter.list_acm_certs(session)` that take the session only to load integration credentials. A SELECT cannot poison a transaction. Requiring a real mutation (`add`/`add_all`/`delete`/`merge`/`flush`/`commit`/`AuditService.log` on a session receiver, or `.execute()` whose argument **provably** roots at `insert()`/`update()`/`delete()`, following one local `stmt = update(...)` rebinding) cuts it to **34**. `.execute(<opaque var>)` is treated as a READ on purpose: a false negative costs one missed finding, a false positive costs a wrong CI failure on every unrelated PR. An unresolvable callee (third-party, dynamic) is never flagged. Cost: the repo-wide index is built once per run, ~9s. Unit tests must pass a hermetic `WriteIndex.from_sources([snippet])` rather than let it default to the repo index.

    🔴 **Constructor-injected sessions — `Service(db).method(...)` (rule added 2026-09-06).** The interprocedural rule originally matched only an awaited call whose FIRST POSITIONAL ARGUMENT is the session (`f(db, ...)`), so this codebase's most common shape — `ProvisioningService(db).retry_task(task_id, ...)`, or `svc = NotificationService(self.db)` then `await svc.dispatch(env)` — was invisible: the session goes into the *constructor*, and the outer call's first argument is a task id. Three per-row handlers in `automation_exception/replay.py` and the permission expiry sweep shipped through that hole and were caught by a post-merge review rather than by CI. The rule now also flags a method invoked on a receiver constructed with the caller's session (inline, or via a module-level `svc = Service(db)` binding), under the same mutation requirement as the original rule — a read-only callee is still not a finding. Mutation-verified: reverting `replay.py` to its pre-fix shape makes the guard report it.

    **`_SELF_ISOLATING_CALLS` is the exemption that makes the widening usable, and it is checked rather than trusted.** Widening produced 86 findings of which **73 were one fact**: `NotificationService.dispatch` / `enqueue` / `push_inbox_and_feishu` wrap their own writes. Those `(constructor, method)` pairs are exempt — keyed on the pair, not the bare method name, because `dispatch` alone is four different functions here (two ASGI middlewares plus the security-awareness campaign sender, which commits between phases and must never be wrapped for a different reason). `test_listed_self_isolating_calls_really_isolate` resolves each pair and asserts every session mutation in it (and in the same-class helpers it awaits on `self`) sits inside a savepoint. **That test went red the first time it ran**, and the fix landed in the primitive: `_deliver`'s trailing `self.audit.log(...)` was outside any savepoint, so "dispatch cannot poison the caller" had been true of the outbox INSERT and the status UPDATE and false of the audit row — with ~52 call sites relying on the sentence. The remaining 13 findings were drained in the same PR: 9 wrapped in `best_effort`, 4 marked `savepoint-exempt` (three callees commit mid-flight; one handler re-raises unconditionally and its durable audit uses its own session).

    **A savepoint rollback EXPIRES every object the block dirtied — the primary key included.** Reading any attribute of such an object outside an `await` then fires a lazy load from sync code and raises `MissingGreenlet`, which is how a `logger.warning(..., item.id, ...)` in a `if not step.ok:` handler blew up (2026-09-05). Read the ids you need for logging / summary rows **before** entering the block, and reload the object (`await db.refresh(obj)`) before the handler touches anything else. The flip side is the useful half of the same mechanism: partial in-memory writes made inside the block are discarded with it, so a later `flush()` cannot resurrect them.

    **Three shapes `best_effort` must NOT wrap, all seen in the 2026-09-05 sweep.** (a) A body that **commits and keeps working** — the commit closes the nested transaction and everything after it is silently dropped (`txn.py` says this; `apikey.reconcile_submission_intents` and `approval` reconcile are the live examples, both exempt with handlers that `rollback()` first). (b) A body that commits where the handler **also cannot** `rollback()` — `ApiKeyService._rotate_after_transfer` calls `rotation.start_rotation`, which commits a leak-safety tombstone before its cloud call; a rollback on the pre-commit failure paths would silently undo `apply_transfer`'s ownership rewrite, so a loud failure is the correct outcome and the site is exempt. (c) Genuinely **read-only** blocks (a SELECT cannot poison a transaction) — the guard's literal rule still flags `db.execute(select(...))`, so these need the marker, not a wrapper.

22. **A swallowed DB error does NOT leave the session usable — `except Exception` around a write must sit inside `begin_nested()`.** A failed `flush()` deactivates the whole transaction, so the swallow "succeeds" but the *next* statement (very often an `AuditService.log`, which is a bare `add()` + `flush()` and does **not** self-isolate) raises `PendingRollbackError` / `InvalidRequestError`. Every "best-effort, must never break the caller" comment in this codebase is a lie unless a SAVEPOINT backs it. Reference implementations: [`access_certification_coordinator.fulfil_pending_revocations`](../../server/app/core/coordinators/access_certification_coordinator.py), the hygiene sweeps in [`leaver_blocked/guard.py`](../../server/app/domains/leaver_blocked/guard.py). For the errno family that a SAVEPOINT rollback **cannot** rescue (1205/1213/1305 abort server-side), re-raise via `is_transaction_aborting_db_error()` ([`core/exceptions.py`](../../server/app/core/exceptions.py)) instead of swallowing. Note `NotificationService.enqueue/dispatch` already self-isolates; `AuditService.log` does not.

26. **Mounting a probe router on the shared `app` needs `tests.support.router_probe.mounted_router` — the obvious teardown is a no-op.** `app.main:app` is built once and shared by all ~15k tests; a few gate tests mount a throwaway `APIRouter` to prove a sweep isn't vacuous. Under **FastAPI 0.141 `include_router` no longer copies the child's routes up** — it appends one deferred `_IncludedRouter` wrapper that has **no `.path`** — so the teardown that used to be written for this (filter `app.router.routes` by `.path`) silently kept the probe mounted for the whole session. That is also why `app.routes` "reads empty" in this repo and why anything that wants a flat route list must use `app.openapi()` or the source routers (see [`open_meta_router._curated_spec`](../../server/app/api_clients/open_meta_router.py)). Restore the list itself (`app.router.routes[:] = saved`) and clear `app.openapi_schema` on both edges — `app.openapi()` re-generates only when `app.router._get_routes_version()` moves, and that version is a **sum** that mount+unmount returns to its previous value. An autouse fixture in `tests/conftest.py` fails any test that leaves the route table changed. Annotation caveat for a probe endpoint defined inside a test: every test module is `from __future__ import annotations`, so annotations resolve against **module** globals — a name imported inside the test function stays an unresolved forward ref, FastAPI turns it into a request-body field, and `app.openapi()` then raises `PydanticUserError` for as long as the probe is mounted. (2026-09-04, see CHANGELOG.)

20. **CSS / ESLint blocks hardcoded Chinese in JSX/props.** `[一-鿿]` regex enforced at lint. Ok in code comments, console logs, thrown Errors that don't reach users, and `t('key', '默认值')` fallbacks.

---

## 18. Glossary of "already exists, don't ask"

Run `grep` here before asking the user "should we build X":

- **Append-only audit?** Yes — `audit_logs` + DB trigger.
- **Idempotency?** Yes — `app/core/idempotency.py` (Redis-backed).
- **Trace ID?** Yes — every response, every audit row.
- **Outbox / retry / dead-letter?** Yes — `notification_outbox` + `notification.outbox_retry`.
- **Workflow journal?** Yes — `workflow_runs` + `workflow_step_events`.
- **Multi-channel notifications?** Yes — email / Feishu / Slack via outbox.
- **Approval framework?** Yes — `ChatApprovalDriver` (Feishu today). Generic — add Slack approval = new driver.
- **Cron framework?** Yes — `scheduled_jobs` + `@register_scheduled_job`. New cron = decorate a handler + seed a row.
- **PAT for scripts/agents?** Yes — `helix_pat_*`, hashed, expires_at, auditable.
- **MCP server for Claude?** Yes — `/api/v1/mcp`. Two auth modes: `helix_pat_…` PAT (Claude Code config files) **or** OAuth 2.1 + PKCE self-onboarding for the GUI "custom connector" flow (§15.2). Add it on claude.ai with just the URL `https://helix.pingcap.net/api/v1/mcp` + Individual sign-in.
- **Cloud account onboarding?** Yes — full lifecycle from creation through decommission, 8 providers.
- **Cloud cost chargeback?** Yes — `cmdb_cloud_billing_daily` + `ai_spending_*`.
- **Cloud access expiry sweep?** Yes — `cmdb.access_expiry_sweep` (T-14 / T-7 / T-1 nudges + T+0 auto-revoke). The previous `cmdb.scope_inactivity_sweep` was removed 2026-05-09 (migration 138) — `last_login_at` couldn't be populated reliably for SSO-class logins, so the alerts were false-positive noise. Expiry-based renewal is now the sole grant-recycling gate.
- **Self-service prod block?** Yes, **symmetric across every Helix grant flow** — `/api/v1/cmdb/scopes?requestable=true` excludes scopes whose `environment ∈ PROD_ENVIRONMENTS` (frozenset, currently `{"prod"}`); `submit_access_request` raises `CLOUD_SCOPE_PROD_BLOCKED` (403) on prod scope_id; `owner_add_member` raises the same error code. Both code paths call the shared `_refuse_prod_self_service(scope)` helper so the two cannot drift. Production cloud access is owned by the R&D security team (Kidd.li) outside Helix self-service. The frontend (`MyCloudGrantsPage.tsx`) catches the error code and toasts the bilingual `cloudLifecycle.myGrants.prodBlockTitle` i18n key — backend message stays English-only per the i18n hard rule. Pinned by `test_submit_blocks_prod_scope_for_every_caller` + `test_owner_add_blocks_prod_scope_symmetric_with_submit`.
- **Cloud grant lock namespaces** (added 2026-05-17 Round-23): three Redis advisory lock keys serialise the mutators on `cmdb_cloud_access_grants`:
  - `approve_group:<request_group_id>` — held by `approve_grant` for up to 15 min while it iterates every PENDING sibling sharing a `request_group_id` and dispatches `driver.grant_access` per role. Falls back to `approve_grant:<grant_id>` when the row has no `request_group_id` (legacy single-grant submissions). **Why group-scoped:** two scope owners clicking approve on different siblings X and Y of the same multi-role submission would otherwise grab different per-grant locks, each iterate the full group, and double-dispatch every role. Inside the locked region, every sibling is `refresh`-ed before dispatch and skipped if not PENDING — defends against a concurrent revoke/reject (different lock namespace) flipping the row between the SELECT and the loop iteration.
  - `grant_revoke:<grant_id>` — held by `revoke_grant`, `renew_grant`, `disable_grant`, `_reuse_or_create_grant_row` (resurrection), the per-row arm of the drift sweep, and the manual-revoke retry sweep. Per-grant scope; concurrent admin revokes / leaver-cascade revokes / drift-sweep closes on the same row serialise.
  - `cloud_sync:<integration_id>` — held by every cron sync handler (`run_grants_sync`, `run_scopes_sync`, `run_roles_sync`) AND by admin "立即执行" / "force drift sweep". One integration runs at most one sync of any kind at a time; prevents the mark-and-sweep mass-revoke hazard from interleaved syncs with different `last_seen_*_sync_job_id` stamps.

- **Grant resurrection on re-request?** Yes — when a `(scope, person, role)` row exists with status in `GRANT_STATUSES_RESURRECTABLE` (`{revoked, expired, disabled}`), both `submit_access_request` (employee path) and `owner_add_member` (admin direct-add) reuse the existing row in-place via `CloudLifecycleService._reuse_or_create_grant_row`. The shared helper preserves `id` + `created_at` (audit trail continuous), merges `detail` (forensic stash like `disable_result` survives, plus stamps `_resurrected_from_status` / `_resurrected_from_revoke_reason` / `_resurrected_from_revoked_at` markers), normalises `source='discovered'` rows to `source='helix'` and clears `last_seen_grants_sync_*` (drift sweep can't re-revoke), clears stale cloud metadata (`external_ref`, `external_principal_*`), and acquires the `grant_revoke:<id>` Redis advisory lock so a concurrent revoke / renew / re-resurrect serialises cleanly. Without resurrection the unique key `uq_cmdb_cloud_grants_scope_person_role` rejects the INSERT and the request 500s — the bug that motivated PR #138 (Wang Letian on `tidb-o11y` × `DBaaS-Prod-DevUser-Role-o11y`).
- **Cloud scope decommission with provider account-close?** Yes — irreversible.
- **HRIS staging + admin confirm?** Yes — `hr_sync_batches` flow.
- **Multi-HRIS / data residency?** Yes — `hris_instance_id` per person.
- **Manager scope?** Yes — derived from `manager_person_id`. No DB role.
- **Self-service portal?** Yes — `/me`, `/my/*`, employee_requests.
- **Asset inventory cycles?** Yes — `asset_inventory_cycles` + immutable snapshots.
- **Software seat cancel-at-period-end?** Yes — cron auto-revokes on month boundary.
- **Email group ownership transfer for offboarding?** Yes — MUST_TRANSFER blocker.
- **Relationship graph for blocker resolution?** Yes — `rel_relationships`.
- **Leaver Blocked guard?** Yes — SQLAlchemy listener intercepts every flush.
- **Dual-sign override for stuck leavers?** Yes — `leaver_dual_sign_override`.
- **Bilingual UI (zh + en)?** Yes — `preferred_language`, full coverage.
- **Auditor governance (MFA / privileged review / etc.)?** Yes — 9 sub-features.
- **AI spending dashboard?** Yes — `/admin/ai-spending`, finance + auditor read.
- **Insights / reports?** Yes — `/admin/insights` (4 tabs).
- **Help center?** Yes — `/help` (content sparse, expand as features ship).

If your question isn't on this list, search the migrations index (§16) and the domain map (§12) before asking.

### access-drift 控制项 epoch(2026-08-17,迁移 511)

`platform_settings` 两个键记录「这个控制项从什么时候起可判」:

- `access_drift.role_ledger_epoch` —— `auth_user_role_cycles` 开始记账的时刻。迁移 484 **故意不回填**,所以此前的授权没有区间是设计使然,不是漂移。
- `access_drift.offboarding_archive_epoch` —— 离职自动归档上线的时刻。

探测器只判 epoch 之后的事件;**键缺失 = 一条不判**(fail-safe,不是回退成全判)。
归档那条**不能**从数据推 epoch:「零 bundle」正是它要抓的故障状态。
新增控制项若要按时间设界,沿用这个模式而不要在代码里写死日期。
# Knowledge and answer traceability

The Feishu-backed support knowledge mirror stores configured root nodes as
`{token, description}` objects (legacy token-only settings remain readable).
Removing a root or a document archives the mirrored article and clears its
embedding while retaining the source text and audit history; a successful
reappearance can then rebuild the article's vector. Sync summaries distinguish
per-run embeddings from the cumulative ready count for the active embedding
model. Global admins and read-authorized auditors can use the admin-only KB
debug endpoint/page to simulate a question and inspect the exact returned
answer, retrieval strategy, and source documents. This trace is never included
in employee-facing responses.
